export const LIMIT = 40; // Read one public Workspace baseline and close the follower immediately. export async function workspaceSnapshot(controller, signal) { const stream = controller.follow(signal); try { const frame = await stream.next(); if (frame.done || frame.value.type !== 'baseline') throw new Error('Workspace catalog unavailable'); return frame.value.value; } finally {await stream.return();} } export const UNGROUPED_WORKSPACE_ID = '__ungrouped__'; // Compare exact cwd roots only; a descendant directory is not evidence of // registry membership. Windows roots are case-insensitive, POSIX roots are not. function pathKey(value) { if (typeof value !== 'string' || !value) return ''; const path = value.replace(/\\/g, '/').replace(/\/+$/u, '') || '/'; return /^[a-z]:(?:\/|$)/iu.test(path) || path.startsWith('//') ? path.toLowerCase() : path; } export function workspaceCatalog(snapshot) { return [...snapshot.items.map(({workspaceId, title, path, createdAt, updatedAt}) => ({workspaceId, title, path, createdAt, updatedAt, sessionCount: 0})), {workspaceId: UNGROUPED_WORKSPACE_ID, title: 'Ungrouped sessions', path: '', sessionCount: 0, ungrouped: true}]; } function workspaceAssignments(snapshot, records) { const membership = new Map(), roots = new Map(), resolved = new Map(); const unique = (map, key, workspaceId) => { if (!map.has(key)) map.set(key, workspaceId); else if (map.get(key) !== workspaceId) map.set(key, UNGROUPED_WORKSPACE_ID); }; for (const workspace of snapshot.items) { for (const id of workspace.sessionIds) unique(membership, id, workspace.workspaceId); const root = pathKey(workspace.path); if (root) unique(roots, root, workspace.workspaceId); } return sessionId => { let current = sessionId, workspaceId; const chain = new Set(); while (current) { if (membership.has(current)) {workspaceId = membership.get(current); break;} if (resolved.has(current)) {workspaceId = resolved.get(current); break;} if (chain.has(current)) break; chain.add(current); const row = records.get(current); if (!row) break; // An explicit parent, even archived/blank or missing from the sidebar, // takes precedence over the child's cwd. Follow nested parents safely. if (row.origin === 'subagent' && row.parentSessionId) {current = row.parentSessionId; continue;} workspaceId = roots.get(pathKey(row.cwd)); break; } workspaceId ??= UNGROUPED_WORKSPACE_ID; for (const id of chain) resolved.set(id, workspaceId); return workspaceId; }; } export async function catalog(services, input, signal) { const [list, snapshot] = await Promise.all([ services.sessionController.list({}, signal), workspaceSnapshot(services.workspaceController, signal), ]); signal.throwIfAborted(); const archived = new Set(snapshot.archivedSessionIds); // Keep excluded parents as assignment evidence, never as eligible results. const records = new Map(); for (const row of list.items) if (!records.has(row.sessionId)) records.set(row.sessionId, row); const rows = [...records.values()].filter(row => !row.blank && (input.includeSubagents || row.origin !== 'subagent') && (input.includeArchived || !archived.has(row.sessionId))); // Cold subagents need not have a cwd, so the ordinary sidebar catalog may // omit them. Supplement only explicit subagent origins when opted in. if (input.includeSubagents) { for (const {header} of await services.sessionQuery.listSessions(signal)) { if (records.has(header.id)) continue; const row = {sessionId: header.id, updatedAt: header.createdAt, origin: header.origin, parentSessionId: header.parentSession, cwd: header.cwd}; records.set(header.id, row); if (header.origin === 'subagent' && (input.includeArchived || !archived.has(header.id))) rows.push(row); } } signal.throwIfAborted(); const workspaces = workspaceCatalog(snapshot); const byWorkspace = new Map(workspaces.map(workspace => [workspace.workspaceId, workspace])); const assignment = workspaceAssignments(snapshot, records); const titles = new Map(); const missing = rows.filter(row => row.projections?.values?.title === undefined).map(row => row.sessionId); if (missing.length) { for (const result of await services.sessionQuery.readTitleSnapshots(missing, signal)) { if (result.status === 'fulfilled') titles.set(result.sessionId, result.value.title?.title); } } rows.sort((a, b) => b.updatedAt - a.updatedAt || (a.sessionId < b.sessionId ? -1 : a.sessionId > b.sessionId ? 1 : 0)); const items = rows.map(row => { const workspace = byWorkspace.get(assignment(row.sessionId)); workspace.sessionCount++; return {sessionId: row.sessionId, title: (row.projections?.values?.title ?? titles.get(row.sessionId))?.trim() || '', updatedAt: row.updatedAt, cwd: row.cwd || '', archived: archived.has(row.sessionId), subagent: row.origin === 'subagent', workspaceId: workspace.workspaceId, workspaceTitle: workspace.title, workspacePath: workspace.path, ...(row.parentSessionId ? {parentSession: row.parentSessionId} : {})}; }); return {items, workspaces}; } // A final reply is the last successful text-only assistant response in a // completed turn, not commentary before tools or an abandoned partial attempt. export function finalDocuments(events, sessionId) { const documents = []; let candidate; let turn; for (const event of events) { if (event.type === 'turn/start') {turn = event.data.turn; candidate = undefined;} if (event.type === 'assistant/message' && event.data.turn === turn) candidate = event; if (event.type !== 'turn/end' || event.data.turn !== turn) continue; if (event.data.reason.kind === 'completed' && candidate && !candidate.data.interrupted) { const content = candidate.data.message.content; if (!content.some(block => block.type === 'tool-call')) { const text = content.filter(block => block.type === 'text').map(block => block.text).join('\n').trim(); if (text) documents.push({sessionId, seq: candidate.seq, time: candidate.time, type: candidate.type, text}); } } candidate = undefined; turn = undefined; } return documents; } export function processDocuments(events, sessionId) { const blockText = blocks => (blocks || []).flatMap(block => { if (block.type === 'text' || block.type === 'reasoning') return [block.text]; if (block.type === 'tool-call') return [block.name, block.arguments]; return []; }).join('\n'); return events.flatMap(event => { let text = ''; if (event.type === 'user/message') text = blockText(event.data.content); else if (['assistant/message', 'tool/result', 'developer/message', 'system/message'].includes(event.type)) text = blockText(event.data.message.content); else if (event.type === 'tool/call') text = `${event.data.name}\n${event.data.arguments}`; else if (event.type === 'assistant/attempt') text = (event.data.stream || []).flatMap(record => { if (record.type === 'text-chunks' || record.type === 'reasoning-chunks') return record.texts; if (record.type === 'chunk' && ['text-delta', 'reasoning-delta'].includes(record.chunk.type)) return [record.chunk.text]; return []; }).join(''); return text.trim() ? [{sessionId, seq: event.seq, time: event.time, type: event.type, text}] : []; }); }