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:
parent
430d4b9482
commit
e172adf191
|
|
@ -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'])
|
||||
})
|
||||
})
|
||||
|
|
@ -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 (
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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'
|
||||
})
|
||||
)
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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))
|
||||
}
|
||||
|
|
@ -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-'))
|
||||
|
|
|
|||
Loading…
Reference in New Issue