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 <session>/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>
This commit is contained in:
Prince 2026-07-06 11:42:55 +05:30 committed by GitHub
parent 430d4b9482
commit e172adf191
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
6 changed files with 176 additions and 37 deletions

View File

@ -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'])
})
})

View File

@ -11,10 +11,12 @@ export async function discoverFiles(args: {
issues: AiVaultScanIssue[]
extensions: string[]
filePredicate?: (path: string) => boolean
directoryPredicate?: (name: string) => boolean
}): Promise<SessionFileDiscovery> {
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<string>
filePredicate?: (path: string) => boolean
directoryPredicate?: (name: string) => boolean
}
): Promise<string[]> {
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 (

View File

@ -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)
}

View File

@ -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 `<session>/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'
})
)
}

View File

@ -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<void> {
await mkdir(dirname(filePath), { recursive: true })
await writeFile(filePath, jsonLines(records))
}

View File

@ -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-'))