From e172adf19135f962701d4e5469ef535b3f968ef9 Mon Sep 17 00:00:00 2001 From: Prince <20267810+hmrserver@users.noreply.github.com> Date: Mon, 6 Jul 2026 11:42:55 +0530 Subject: [PATCH] fix: prefer Claude-generated titles in AI Vault (#7369) * fix: prefer Claude-generated titles in AI Vault Agent Session History labeled Claude Code sessions with a truncated first prompt even when the session already had a Claude-generated name (the ai-title shown in /status and the tab title). Reserve the top title slot for a user-set custom-title and rank the generated ai-title above the first prompt: custom-title > ai-title > first prompt > meta. New sessions still fall back to the first prompt until the ai-title is written. Also prune /subagents/ during discovery via an injected directoryPredicate so Task subagent transcripts, which share the parent sessionId and are not independently resumable, stop appearing as separate untitled history rows. Pruning at the directory level avoids readdir'ing the excluded subtree and is cross-platform safe. * Use latest generated Claude title in session scanner Ensure the scanner updates the generated session title when Claude revises it, rather than only keeping the first parsed 'ai-title' record. --------- Co-authored-by: Jinjing <6427696+AmethystLiang@users.noreply.github.com> --- .../session-scanner-claude-title.test.ts | 104 ++++++++++++++++++ .../ai-vault/session-scanner-discovery.ts | 11 +- .../session-scanner-primary-parsers.ts | 22 +++- .../session-scanner-source-discovery.ts | 12 +- .../ai-vault/session-scanner-test-fixtures.ts | 35 ++++++ src/main/ai-vault/session-scanner.test.ts | 29 +---- 6 files changed, 176 insertions(+), 37 deletions(-) create mode 100644 src/main/ai-vault/session-scanner-claude-title.test.ts create mode 100644 src/main/ai-vault/session-scanner-test-fixtures.ts 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 00000000000..6b83d30d8e4 --- /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 266e7a4dbcf..f5541ca39c2 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 28a37602a6c..b574cd3124e 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 9c2b99bd118..12acdeab188 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 00000000000..b7c4dfd05cf --- /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 d576d6abae4..41b1b2b05ab 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-'))