diff --git a/src/main/ai-vault/session-scanner-claude-title.test.ts b/src/main/ai-vault/session-scanner-claude-title.test.ts new file mode 100644 index 000000000..6b83d30d8 --- /dev/null +++ b/src/main/ai-vault/session-scanner-claude-title.test.ts @@ -0,0 +1,104 @@ +import { mkdtemp, rm } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, describe, expect, it } from 'vitest' +import { scanAiVaultSessions } from './session-scanner' +import { isolatedScanRoots, writeJsonlFile } from './session-scanner-test-fixtures' + +let tempRoots: string[] = [] + +afterEach(async () => { + await Promise.all(tempRoots.map((root) => rm(root, { recursive: true, force: true }))) + tempRoots = [] +}) + +describe('scanAiVaultSessions Claude title selection', () => { + it('prefers the latest generated ai-title over the first user prompt, but a custom-title wins over both', async () => { + const root = await mkdtemp(join(tmpdir(), 'orca-ai-vault-ai-title-')) + tempRoots.push(root) + const roots = isolatedScanRoots(root) + const projectDir = join(roots.claudeProjectsDir, 'project') + + await writeJsonlFile(join(projectDir, 'generated.jsonl'), [ + { + type: 'user', + sessionId: 'generated', + timestamp: '2026-05-01T10:00:00.000Z', + cwd: '/tmp/claude', + message: { role: 'user', content: 'First user prompt' } + }, + { + type: 'ai-title', + sessionId: 'generated', + timestamp: '2026-05-01T10:01:00.000Z', + aiTitle: 'Understanding karma and moral accountability' + }, + { + type: 'ai-title', + sessionId: 'generated', + timestamp: '2026-05-01T10:02:00.000Z', + aiTitle: 'Updated karma discussion title' + } + ]) + await writeJsonlFile(join(projectDir, 'custom.jsonl'), [ + { + type: 'user', + sessionId: 'custom', + timestamp: '2026-05-01T11:00:00.000Z', + cwd: '/tmp/claude', + message: { role: 'user', content: 'First user prompt' } + }, + { + type: 'ai-title', + sessionId: 'custom', + timestamp: '2026-05-01T11:01:00.000Z', + aiTitle: 'Generated title that must lose' + }, + { + type: 'custom-title', + sessionId: 'custom', + timestamp: '2026-05-01T11:02:00.000Z', + customTitle: 'User set title' + } + ]) + + const result = await scanAiVaultSessions({ ...roots, platform: 'darwin' }) + + expect(result.issues).toEqual([]) + expect(result.sessions.map((session) => session.title).sort()).toEqual([ + 'Updated karma discussion title', + 'User set title' + ]) + }) + + it('excludes Claude Task subagent transcripts from the session list', async () => { + const root = await mkdtemp(join(tmpdir(), 'orca-ai-vault-subagents-')) + tempRoots.push(root) + const roots = isolatedScanRoots(root) + const sessionDir = join(roots.claudeProjectsDir, 'project', 'claude-session') + + await writeJsonlFile(join(roots.claudeProjectsDir, 'project', 'claude-session.jsonl'), [ + { + type: 'user', + sessionId: 'claude-session', + timestamp: '2026-05-01T10:00:00.000Z', + cwd: '/tmp/claude', + message: { role: 'user', content: 'Parent session prompt' } + } + ]) + await writeJsonlFile(join(sessionDir, 'subagents', 'agent-abc123.jsonl'), [ + { + type: 'user', + sessionId: 'claude-session', + timestamp: '2026-05-01T10:00:05.000Z', + cwd: '/tmp/claude', + message: { role: 'user', content: 'Subagent task prompt' } + } + ]) + + const result = await scanAiVaultSessions({ ...roots, platform: 'darwin' }) + + expect(result.issues).toEqual([]) + expect(result.sessions.map((session) => session.title)).toEqual(['Parent session prompt']) + }) +}) diff --git a/src/main/ai-vault/session-scanner-discovery.ts b/src/main/ai-vault/session-scanner-discovery.ts index 266e7a4db..f5541ca39 100644 --- a/src/main/ai-vault/session-scanner-discovery.ts +++ b/src/main/ai-vault/session-scanner-discovery.ts @@ -11,10 +11,12 @@ export async function discoverFiles(args: { issues: AiVaultScanIssue[] extensions: string[] filePredicate?: (path: string) => boolean + directoryPredicate?: (name: string) => boolean }): Promise { const paths = await walkSessionFiles(args.rootDir, args.agent, args.issues, { extensions: new Set(args.extensions), - filePredicate: args.filePredicate + filePredicate: args.filePredicate, + directoryPredicate: args.directoryPredicate }) const files: FileWithMtime[] = [] for (const path of paths) { @@ -67,6 +69,7 @@ export async function walkSessionFiles( options: { extensions: Set filePredicate?: (path: string) => boolean + directoryPredicate?: (name: string) => boolean } ): Promise { let entries @@ -80,7 +83,11 @@ export async function walkSessionFiles( for (const entry of entries) { const fullPath = join(dirPath, entry.name) if (entry.isDirectory()) { - files.push(...(await walkSessionFiles(fullPath, agent, issues, options))) + // Skip whole subtrees an agent never wants (e.g. subagent transcripts), + // avoiding the readdir cost of descending into them. + if (options.directoryPredicate?.(entry.name) ?? true) { + files.push(...(await walkSessionFiles(fullPath, agent, issues, options))) + } continue } if ( diff --git a/src/main/ai-vault/session-scanner-primary-parsers.ts b/src/main/ai-vault/session-scanner-primary-parsers.ts index 28a37602a..b574cd312 100644 --- a/src/main/ai-vault/session-scanner-primary-parsers.ts +++ b/src/main/ai-vault/session-scanner-primary-parsers.ts @@ -67,6 +67,7 @@ async function parseClaudeSessionLines(args: { }) let metaTitle: string | null = null let generatedTitle: string | null = null + let firstUserTitle: string | null = null for await (const line of args.lines) { const record = parseJsonObject(line) @@ -86,7 +87,11 @@ async function parseClaudeSessionLines(args: { } if (record.type === 'ai-title') { - generatedTitle ??= normalizeTitleText(extractString(record.aiTitle) ?? '') + const title = normalizeTitleText(extractString(record.aiTitle) ?? '') + if (title) { + // Claude can revise generated names; AI Vault should mirror the current one. + generatedTitle = title + } continue } @@ -99,10 +104,13 @@ async function parseClaudeSessionLines(args: { accumulator.messageCount++ const title = extractMessageText(record.message) addPreviewContent(accumulator, 'user', asRecord(record.message)?.content, record.timestamp) - if (title && record.isMeta !== true && !accumulator.title) { - accumulator.title = title - } else if (title && !metaTitle) { - metaTitle = title + if (title) { + // Meta prompts (injected context) only seed the last-resort title. + if (record.isMeta === true) { + metaTitle ??= title + } else { + firstUserTitle ??= title + } } continue } @@ -119,7 +127,9 @@ async function parseClaudeSessionLines(args: { } } - accumulator.fallbackTitle = generatedTitle ?? metaTitle + // Why: a user-set custom-title (accumulator.title) wins, but Claude's generated + // session name (ai-title) should outrank the raw first prompt when present. + accumulator.fallbackTitle = generatedTitle ?? firstUserTitle ?? metaTitle return finalizeSession(accumulator, args.platform, args.options) } diff --git a/src/main/ai-vault/session-scanner-source-discovery.ts b/src/main/ai-vault/session-scanner-source-discovery.ts index 9c2b99bd1..12acdeab1 100644 --- a/src/main/ai-vault/session-scanner-source-discovery.ts +++ b/src/main/ai-vault/session-scanner-source-discovery.ts @@ -76,7 +76,17 @@ function claudeDiscoveries( options.claudeProjectsDir ?? CLAUDE_PROJECTS_DIR, ...wslHomeDirs.map((homeDir) => join(homeDir, '.claude', 'projects')) ].map((rootDir) => - discoverFiles({ rootDir, limit, agent: 'claude', issues, extensions: ['.jsonl'] }) + discoverFiles({ + rootDir, + limit, + agent: 'claude', + issues, + extensions: ['.jsonl'], + // Why: Task subagent transcripts under `/subagents/` share the parent + // sessionId and aren't independently resumable, so they'd just duplicate the + // parent as untitled rows; prune the subtree instead of indexing it. + directoryPredicate: (name) => name !== 'subagents' + }) ) } diff --git a/src/main/ai-vault/session-scanner-test-fixtures.ts b/src/main/ai-vault/session-scanner-test-fixtures.ts new file mode 100644 index 000000000..b7c4dfd05 --- /dev/null +++ b/src/main/ai-vault/session-scanner-test-fixtures.ts @@ -0,0 +1,35 @@ +import { mkdir, writeFile } from 'node:fs/promises' +import { dirname, join } from 'node:path' + +export function isolatedScanRoots(root: string) { + return { + claudeProjectsDir: join(root, 'claude-projects'), + codexSessionsDir: join(root, 'codex-sessions'), + geminiSessionsDir: join(root, 'gemini-sessions'), + copilotSessionsDir: join(root, 'copilot-sessions'), + cursorProjectsDir: join(root, 'cursor-projects'), + opencodeStorageDir: join(root, 'opencode-storage'), + // Why: prevent the SQLite scanner from picking up the real + // ~/.local/share/opencode/opencode.db during tests. + opencodeDbPaths: [] as readonly string[], + grokSessionsDir: join(root, 'grok-sessions'), + devinTranscriptsDir: join(root, 'devin-transcripts'), + hermesSessionsDir: join(root, 'hermes-sessions'), + rovoSessionsDir: join(root, 'rovo-sessions'), + openclawStateDir: join(root, 'openclaw-state'), + openclawLegacyStateDir: join(root, 'openclaw-legacy-state'), + piSessionsDir: join(root, 'pi-sessions'), + droidSessionsDir: join(root, 'droid-sessions'), + droidProjectsDir: join(root, 'droid-projects'), + kimiSessionsDir: join(root, 'kimi-sessions') + } +} + +export function jsonLines(records: unknown[]): string { + return records.map((record) => JSON.stringify(record)).join('\n') +} + +export async function writeJsonlFile(filePath: string, records: unknown[]): Promise { + await mkdir(dirname(filePath), { recursive: true }) + await writeFile(filePath, jsonLines(records)) +} diff --git a/src/main/ai-vault/session-scanner.test.ts b/src/main/ai-vault/session-scanner.test.ts index d576d6aba..41b1b2b05 100644 --- a/src/main/ai-vault/session-scanner.test.ts +++ b/src/main/ai-vault/session-scanner.test.ts @@ -4,6 +4,7 @@ import { join } from 'node:path' import { afterEach, describe, expect, it, vi } from 'vitest' import { AI_VAULT_AGENTS, buildAiVaultResumeCommand } from '../../shared/ai-vault-types' import { scanAiVaultSessions } from './session-scanner' +import { isolatedScanRoots, jsonLines } from './session-scanner-test-fixtures' let tempRoots: string[] = [] @@ -13,34 +14,6 @@ afterEach(async () => { tempRoots = [] }) -function isolatedScanRoots(root: string) { - return { - claudeProjectsDir: join(root, 'claude-projects'), - codexSessionsDir: join(root, 'codex-sessions'), - geminiSessionsDir: join(root, 'gemini-sessions'), - copilotSessionsDir: join(root, 'copilot-sessions'), - cursorProjectsDir: join(root, 'cursor-projects'), - opencodeStorageDir: join(root, 'opencode-storage'), - // Why: prevent the SQLite scanner from picking up the real - // ~/.local/share/opencode/opencode.db during tests. - opencodeDbPaths: [] as readonly string[], - grokSessionsDir: join(root, 'grok-sessions'), - devinTranscriptsDir: join(root, 'devin-transcripts'), - hermesSessionsDir: join(root, 'hermes-sessions'), - rovoSessionsDir: join(root, 'rovo-sessions'), - openclawStateDir: join(root, 'openclaw-state'), - openclawLegacyStateDir: join(root, 'openclaw-legacy-state'), - piSessionsDir: join(root, 'pi-sessions'), - droidSessionsDir: join(root, 'droid-sessions'), - droidProjectsDir: join(root, 'droid-projects'), - kimiSessionsDir: join(root, 'kimi-sessions') - } -} - -function jsonLines(records: unknown[]): string { - return records.map((record) => JSON.stringify(record)).join('\n') -} - describe('scanAiVaultSessions', () => { it('indexes Claude and Codex transcripts with resume commands', async () => { const root = await mkdtemp(join(tmpdir(), 'orca-ai-vault-'))