diff --git a/src/main/ai-vault/runtime-session-scanner.ts b/src/main/ai-vault/runtime-session-scanner.ts index baf4ca6d3..014a511ce 100644 --- a/src/main/ai-vault/runtime-session-scanner.ts +++ b/src/main/ai-vault/runtime-session-scanner.ts @@ -76,6 +76,7 @@ const aiVaultListResultSchema = z.object({ totalTokens: z.number(), previewMessages: z.array(aiVaultSessionPreviewMessageSchema), // Optional keeps paired hosts on older builds compatible. + firstUserPrompt: z.string().nullable().optional(), lastUserPrompt: z.string().nullable().optional(), // Default keeps remote hosts running an older build (no recoverable-signal // fields) parseable; they simply report no recoverable-empty sessions. diff --git a/src/main/ai-vault/session-first-user-prompt-read.test.ts b/src/main/ai-vault/session-first-user-prompt-read.test.ts new file mode 100644 index 000000000..5213c49ab --- /dev/null +++ b/src/main/ai-vault/session-first-user-prompt-read.test.ts @@ -0,0 +1,137 @@ +import { mkdtemp, mkdir, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, describe, expect, it } from 'vitest' +import { readAiVaultFirstUserPrompt } from './session-first-user-prompt-read' + +const tempRoots: string[] = [] + +afterEach(async () => { + // Best-effort cleanup; tests are sandboxed under mkdtemp. + const { rm } = await import('node:fs/promises') + await Promise.all(tempRoots.splice(0).map((root) => rm(root, { recursive: true, force: true }))) +}) + +describe('readAiVaultFirstUserPrompt', () => { + it('returns the full first user prompt without preview truncation', async () => { + const root = await mkdtemp(join(tmpdir(), 'orca-first-prompt-')) + tempRoots.push(root) + const projectDir = join(root, 'project') + await mkdir(projectDir, { recursive: true }) + const longPrompt = `Please implement the full vault first-prompt copy path.\n\n${'detail '.repeat(80).trimEnd()}` + const filePath = join(projectDir, 'session.jsonl') + await writeFile( + filePath, + [ + JSON.stringify({ + type: 'user', + sessionId: 'full-prompt-session', + timestamp: '2026-05-01T10:00:00.000Z', + cwd: '/repo/app', + isMeta: false, + message: { role: 'user', content: longPrompt } + }), + JSON.stringify({ + type: 'assistant', + sessionId: 'full-prompt-session', + timestamp: '2026-05-01T10:01:00.000Z', + message: { role: 'assistant', content: 'Working on it.', model: 'claude-sonnet-4-5' } + }) + ].join('\n') + ) + + const result = await readAiVaultFirstUserPrompt({ + agent: 'claude', + filePath + }) + + expect(result.prompt).toBe(longPrompt) + expect(result.prompt?.includes('\n\n')).toBe(true) + expect(result.prompt?.length).toBeGreaterThan(220) + }) + + it('extracts full Codex input_text content blocks (not preview-capped)', async () => { + const root = await mkdtemp(join(tmpdir(), 'orca-first-prompt-codex-')) + tempRoots.push(root) + const sessionPath = join(root, 'sessions', '2026', '07', '21', 'rollout-full.jsonl') + await mkdir(join(root, 'sessions', '2026', '07', '21'), { recursive: true }) + const longPrompt = `Review the PR and fix real regressions.\n\n${'context '.repeat(60).trimEnd()}` + await writeFile( + sessionPath, + [ + JSON.stringify({ + timestamp: '2026-07-21T10:00:00.000Z', + type: 'session_meta', + payload: { id: 'codex-full-prompt', cwd: '/repo/app' } + }), + JSON.stringify({ + timestamp: '2026-07-21T10:00:01.000Z', + type: 'response_item', + payload: { + type: 'message', + role: 'user', + content: [{ type: 'input_text', text: longPrompt }] + } + }) + ].join('\n') + ) + + const result = await readAiVaultFirstUserPrompt({ + agent: 'codex', + filePath: sessionPath, + codexHome: root + }) + + expect(result.prompt).toBe(longPrompt) + expect(result.prompt?.length).toBeGreaterThan(220) + }) + + it('skips meta/harness user turns and returns the first real ask', async () => { + const root = await mkdtemp(join(tmpdir(), 'orca-first-prompt-meta-')) + tempRoots.push(root) + const projectDir = join(root, 'project') + await mkdir(projectDir, { recursive: true }) + const filePath = join(projectDir, 'session.jsonl') + await writeFile( + filePath, + [ + JSON.stringify({ + type: 'user', + sessionId: 'meta-then-real', + timestamp: '2026-05-01T10:00:00.000Z', + cwd: '/repo/app', + isMeta: true, + message: { role: 'user', content: 'Base directory for this skill: /tmp/skills' } + }), + JSON.stringify({ + type: 'user', + sessionId: 'meta-then-real', + timestamp: '2026-05-01T10:00:01.000Z', + cwd: '/repo/app', + message: { role: 'user', content: 'Ship the first-prompt copy button' } + }) + ].join('\n') + ) + + const result = await readAiVaultFirstUserPrompt({ + agent: 'claude', + filePath + }) + + expect(result.prompt).toBe('Ship the first-prompt copy button') + }) + + it('resolves null instead of rejecting when the transcript is corrupt', async () => { + const root = await mkdtemp(join(tmpdir(), 'orca-first-prompt-corrupt-')) + tempRoots.push(root) + const sessionDir = join(root, 'session-1') + await mkdir(sessionDir, { recursive: true }) + // Grok's parser JSON.parses summary.json eagerly, so truncated JSON throws. + const summaryPath = join(sessionDir, 'summary.json') + await writeFile(summaryPath, '{"info": {"id": "session-1", "cwd": "/repo/a') + + await expect( + readAiVaultFirstUserPrompt({ agent: 'grok', filePath: summaryPath }) + ).resolves.toEqual({ prompt: null }) + }) +}) diff --git a/src/main/ai-vault/session-first-user-prompt-read.ts b/src/main/ai-vault/session-first-user-prompt-read.ts new file mode 100644 index 000000000..2023303e4 --- /dev/null +++ b/src/main/ai-vault/session-first-user-prompt-read.ts @@ -0,0 +1,144 @@ +import { stat } from 'node:fs/promises' +import type { + AiVaultAgent, + AiVaultFirstUserPromptArgs, + AiVaultFirstUserPromptResult, + AiVaultSession +} from '../../shared/ai-vault-types' +import { LOCAL_EXECUTION_HOST_ID, type ExecutionHostId } from '../../shared/execution-host' +import { parseAgentSessionFile } from './session-scanner-agent-parser' +import { withFullFirstUserPromptCapture } from './session-scanner-first-user-prompt-capture' +import { parseOpenCodeSqliteSession } from './session-scanner-opencode-sqlite' +import { splitOpenCodeSqliteCandidate } from './session-scanner-opencode-sqlite-paths' +import type { FileWithMtime } from './session-scanner-types' + +export type ReadAiVaultFirstUserPromptArgs = { + agent: AiVaultAgent + filePath: string + sessionId?: string + executionHostId?: ExecutionHostId + codexHome?: string | null +} + +export type ReadAiVaultFirstUserPromptResult = AiVaultFirstUserPromptResult + +/** IPC-safe entry: validates untyped payload then reads the full first prompt. */ +export async function handleAiVaultGetFirstUserPrompt( + args?: AiVaultFirstUserPromptArgs +): Promise { + if (!args || typeof args.filePath !== 'string' || typeof args.agent !== 'string') { + return { prompt: null } + } + return readAiVaultFirstUserPrompt({ + agent: args.agent, + filePath: args.filePath, + sessionId: typeof args.sessionId === 'string' ? args.sessionId : undefined, + executionHostId: args.executionHostId, + codexHome: args.codexHome + }) +} + +/** + * Re-parse one session transcript under full first-prompt capture and return + * the untruncated first real user ask for copy/reuse. + */ +export async function readAiVaultFirstUserPrompt( + args: ReadAiVaultFirstUserPromptArgs +): Promise { + const filePath = args.filePath.trim() + if (!filePath || !args.agent) { + return { prompt: null } + } + + // Why: transcript bodies live on the session host. Remote rows are skipped + // (same posture as listSubagentSessions); UI falls back to preview text. + const executionHostId = args.executionHostId ?? LOCAL_EXECUTION_HOST_ID + if (executionHostId !== LOCAL_EXECUTION_HOST_ID) { + return { prompt: null } + } + + // Why: partial/corrupt transcripts make parsers throw. Resolve null like every + // other unavailable case instead of rejecting the IPC call. + let session: AiVaultSession | null + try { + session = await withFullFirstUserPromptCapture(() => + parseSessionForFullFirstUserPrompt({ + agent: args.agent, + filePath, + sessionId: args.sessionId?.trim() || undefined, + codexHome: args.codexHome ?? null + }) + ) + } catch { + return { prompt: null } + } + + const prompt = session?.firstUserPrompt?.trim() || null + return { prompt } +} + +async function parseSessionForFullFirstUserPrompt(args: { + agent: AiVaultAgent + filePath: string + sessionId?: string + codexHome: string | null +}): Promise { + // Why: OpenCode SQLite sessions store filePath as the db path (not db#id). + // Re-parse in-process under full capture so ALS applies and we can read the + // earliest user row (worker list-scan path only joins newest messages). + if (args.agent === 'opencode') { + const fromSynthetic = splitOpenCodeSqliteCandidate(args.filePath) + if (fromSynthetic) { + return parseOpenCodeSqliteSession({ + dbPath: fromSynthetic.dbPath, + sessionId: fromSynthetic.sessionId, + platform: process.platform + }) + } + if (args.sessionId) { + return parseOpenCodeSqliteSession({ + dbPath: args.filePath, + sessionId: args.sessionId, + platform: process.platform + }) + } + } + + const file = await fileWithMtimeForPath(args.filePath) + if (!file) { + return null + } + + return parseAgentSessionFile( + { + agent: args.agent, + file, + codexHome: args.codexHome + }, + process.platform + ) +} + +async function fileWithMtimeForPath(filePath: string): Promise { + // OpenCode SQLite candidates use a synthetic `dbPath#sessionId` path that is + // not a real filesystem object; parsers that need it accept the path as-is. + if (filePath.includes('#')) { + return { + path: filePath, + mtimeMs: 0, + modifiedAt: new Date(0).toISOString() + } + } + + try { + const info = await stat(filePath) + return { + path: filePath, + mtimeMs: info.mtimeMs, + modifiedAt: info.mtime.toISOString(), + sizeBytes: info.size + } + } catch { + return null + } +} diff --git a/src/main/ai-vault/session-scanner-accumulator.ts b/src/main/ai-vault/session-scanner-accumulator.ts index 3ce4cd118..97651c571 100644 --- a/src/main/ai-vault/session-scanner-accumulator.ts +++ b/src/main/ai-vault/session-scanner-accumulator.ts @@ -12,6 +12,11 @@ import type { ResumableSessionParseState, SessionAccumulator } from './session-scanner-types' +import { + extractFullFirstUserPromptText, + normalizeFullFirstUserPromptText, + shouldCaptureFullFirstUserPrompt +} from './session-scanner-first-user-prompt' import { extractPreviewContentText, extractString, @@ -41,6 +46,7 @@ export function createAccumulator(args: { messageCount: 0, totalTokens: 0, previewMessages: [], + firstUserPrompt: null, lastUserPrompt: null, queuedMessageCount: 0, subagentTranscriptCount: 0, @@ -113,6 +119,7 @@ export function finalizeSession( messageCount: accumulator.messageCount, totalTokens: accumulator.totalTokens, previewMessages: accumulator.previewMessages, + ...(accumulator.firstUserPrompt ? { firstUserPrompt: accumulator.firstUserPrompt } : {}), ...(accumulator.lastUserPrompt ? { lastUserPrompt: accumulator.lastUserPrompt } : {}), queuedMessageCount: accumulator.queuedMessageCount, subagentTranscriptCount: accumulator.subagentTranscriptCount, @@ -149,6 +156,9 @@ export function addPreviewMessage( role: AiVaultSessionPreviewMessage['role'] text: string | null timestamp?: unknown + // Why: Claude meta/injected turns still preview, but must not seed the + // copyable first-prompt row. + seedFirstUserPrompt?: boolean } ): void { const text = normalizePreviewText(args.text ?? '') @@ -163,18 +173,40 @@ export function addPreviewMessage( if (accumulator.previewMessages.length > SESSION_PREVIEW_MESSAGE_LIMIT) { accumulator.previewMessages.shift() } + // Why: list scans never store firstUserPrompt (payload/perf). Only the + // on-demand full-capture path seeds the untruncated copy body. + if ( + args.role === 'user' && + args.seedFirstUserPrompt !== false && + !accumulator.firstUserPrompt && + shouldCaptureFullFirstUserPrompt() && + args.text + ) { + accumulator.firstUserPrompt = normalizeFullFirstUserPromptText(args.text) + } } export function addPreviewContent( accumulator: SessionAccumulator, role: AiVaultSessionPreviewMessage['role'], content: unknown, - timestamp?: unknown + timestamp?: unknown, + options?: { seedFirstUserPrompt?: boolean } ): void { + if ( + role === 'user' && + options?.seedFirstUserPrompt !== false && + !accumulator.firstUserPrompt && + shouldCaptureFullFirstUserPrompt() + ) { + accumulator.firstUserPrompt = extractFullFirstUserPromptText(content) + } addPreviewMessage(accumulator, { role, text: extractPreviewContentText(content), - timestamp + timestamp, + // Content path already seeded above when capture is enabled. + seedFirstUserPrompt: false }) } diff --git a/src/main/ai-vault/session-scanner-first-user-prompt-capture.ts b/src/main/ai-vault/session-scanner-first-user-prompt-capture.ts new file mode 100644 index 000000000..796fd273d --- /dev/null +++ b/src/main/ai-vault/session-scanner-first-user-prompt-capture.ts @@ -0,0 +1,15 @@ +import { AsyncLocalStorage } from 'node:async_hooks' + +// Why: list scans must not carry full first-prompt bodies (up to 500 sessions +// per refresh). On-demand copy re-parses one transcript under `full` mode. +export type FirstUserPromptCaptureMode = 'none' | 'full' + +const firstUserPromptCaptureStorage = new AsyncLocalStorage() + +export function getFirstUserPromptCaptureMode(): FirstUserPromptCaptureMode { + return firstUserPromptCaptureStorage.getStore() ?? 'none' +} + +export function withFullFirstUserPromptCapture(fn: () => Promise): Promise { + return firstUserPromptCaptureStorage.run('full', fn) +} diff --git a/src/main/ai-vault/session-scanner-first-user-prompt.test.ts b/src/main/ai-vault/session-scanner-first-user-prompt.test.ts new file mode 100644 index 000000000..47235cbce --- /dev/null +++ b/src/main/ai-vault/session-scanner-first-user-prompt.test.ts @@ -0,0 +1,39 @@ +import { describe, expect, it } from 'vitest' +import { normalizeFullFirstUserPromptText } from './session-scanner-first-user-prompt' + +// Mirrors FULL_FIRST_USER_PROMPT_SAFETY_LIMIT in the module under test. +const SAFETY_LIMIT = 256 * 1024 + +describe('AI Vault full first-user-prompt normalization', () => { + it('drops an astral char straddling the safety limit instead of splitting it', () => { + const result = normalizeFullFirstUserPromptText(`${'a'.repeat(SAFETY_LIMIT - 1)}😀tail`) + + expect(result).toHaveLength(SAFETY_LIMIT - 1) + expect(result?.endsWith('a')).toBe(true) + expect(hasUnpairedSurrogate(result ?? '')).toBe(false) + }) + + it('keeps a prompt shorter than the safety limit intact', () => { + expect(normalizeFullFirstUserPromptText('ship it 😀')).toBe('ship it 😀') + }) +}) + +function hasUnpairedSurrogate(value: string): boolean { + for (let index = 0; index < value.length; index += 1) { + const code = value.charCodeAt(index) + const isHigh = code >= 0xd800 && code <= 0xdbff + const isLow = code >= 0xdc00 && code <= 0xdfff + if (isHigh) { + const next = value.charCodeAt(index + 1) + if (!(next >= 0xdc00 && next <= 0xdfff)) { + return true + } + index += 1 + continue + } + if (isLow) { + return true + } + } + return false +} diff --git a/src/main/ai-vault/session-scanner-first-user-prompt.ts b/src/main/ai-vault/session-scanner-first-user-prompt.ts new file mode 100644 index 000000000..4228abc9a --- /dev/null +++ b/src/main/ai-vault/session-scanner-first-user-prompt.ts @@ -0,0 +1,104 @@ +import { isKnownHarnessInjectedUserTurnText } from '../../shared/harness-injected-user-turns' +import { getFirstUserPromptCaptureMode } from './session-scanner-first-user-prompt-capture' +import { stripGrokUserQueryEnvelope } from './session-scanner-grok-user-text' +// Direct import: session-scanner-values re-exports this module, so going through +// it here would close an import cycle. +import { sliceAtCodeUnitLimit } from './session-scanner-text-normalization' + +// Why: safety only for pathological multi-MB pastes. Copy path must not use the +// 220-char list preview cap. +const FULL_FIRST_USER_PROMPT_SAFETY_LIMIT = 256 * 1024 + +// Codex uses input_text; most others use text. Never treat tool/image blocks as +// the written first ask. +const TEXT_LIKE_BLOCK_TYPES = new Set(['text', 'input_text', 'output_text']) + +/** True only while an on-demand first-prompt read is re-parsing one transcript. */ +export function shouldCaptureFullFirstUserPrompt(): boolean { + return getFirstUserPromptCaptureMode() === 'full' +} + +/** + * Extract the written first-user ask for copy/reuse. Preserves newlines and does + * not apply list-preview caps. Returns null for non-text / harness / empty. + */ +export function extractFullFirstUserPromptText(value: unknown): string | null { + if (typeof value === 'string') { + return finalizeFullFirstUserPrompt(value) + } + + // Single content block object (not wrapped in an array). + if (value && typeof value === 'object' && !Array.isArray(value)) { + const blockText = firstUserPromptContentItemText(value) + return blockText != null ? finalizeFullFirstUserPrompt(blockText) : null + } + + if (!Array.isArray(value)) { + return null + } + + const parts: string[] = [] + for (const item of value) { + const text = firstUserPromptContentItemText(item) + if (text != null) { + parts.push(text) + } + } + if (parts.length === 0) { + return null + } + return finalizeFullFirstUserPrompt(parts.join('\n')) +} + +export function normalizeFullFirstUserPromptText(value: string): string | null { + return finalizeFullFirstUserPrompt(value) +} + +function finalizeFullFirstUserPrompt(value: string): string | null { + // Why: Grok (and some pasted transcripts) wrap the real ask in ; + // strip that before copy so the clipboard is the typed prompt, not user_info. + const unwrapped = stripGrokUserQueryEnvelope(value.replace(/^\uFEFF/, '')) + const trimmed = unwrapped.trim() + if (!trimmed) { + return null + } + if (isSuppressedFullFirstUserPrompt(trimmed)) { + return null + } + if (isKnownHarnessInjectedUserTurnText(trimmed)) { + return null + } + // Reject pure Grok bootstrap dumps even when they arrived via a non-Grok path. + const lower = trimmed.toLowerCase() + if (lower.startsWith('') && !lower.includes('')) { + return null + } + return sliceAtCodeUnitLimit(trimmed, FULL_FIRST_USER_PROMPT_SAFETY_LIMIT) +} + +function isSuppressedFullFirstUserPrompt(value: string): boolean { + const head = value.slice(0, 64).toLowerCase() + return head.startsWith('# agents.md instructions') || head.startsWith('') +} + +function firstUserPromptContentItemText(item: unknown): string | null { + if (typeof item === 'string') { + return item + } + if (!item || typeof item !== 'object' || Array.isArray(item)) { + return null + } + const record = item as Record + const type = typeof record.type === 'string' ? record.type : null + if (type != null && !TEXT_LIKE_BLOCK_TYPES.has(type)) { + return null + } + if (typeof record.text === 'string' && record.text.length > 0) { + return record.text + } + // Some providers put the body on `content` for text-shaped blocks. + if (typeof record.content === 'string' && record.content.length > 0) { + return record.content + } + return null +} diff --git a/src/main/ai-vault/session-scanner-grok-parser.test.ts b/src/main/ai-vault/session-scanner-grok-parser.test.ts index 75c2d7575..bdbc19db4 100644 --- a/src/main/ai-vault/session-scanner-grok-parser.test.ts +++ b/src/main/ai-vault/session-scanner-grok-parser.test.ts @@ -1,5 +1,16 @@ -import { describe, expect, it, vi } from 'vitest' -import { extractGrokContentText } from './session-scanner-grok-parser' +import { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, describe, expect, it, vi } from 'vitest' +import { withFullFirstUserPromptCapture } from './session-scanner-first-user-prompt-capture' +import { extractGrokContentText, parseGrokSessionFile } from './session-scanner-grok-parser' + +let tempRoots: string[] = [] + +afterEach(async () => { + await Promise.all(tempRoots.map((root) => rm(root, { recursive: true, force: true }))) + tempRoots = [] +}) describe('AI Vault Grok session parser', () => { it('extracts bounded user_query text without trimming the full body', () => { @@ -8,23 +19,94 @@ describe('AI Vault Grok session parser', () => { `context\n${'Grok prompt '.repeat(400)}` ) const trimCalls = trimSpy.mock.calls.length + trimSpy.mockRestore() - expect(trimCalls).toBe(0) + // stripGrokUserQueryEnvelope trims the body once; the fold must never trim + // per character (the input here is 4800+ chars). + expect(trimCalls).toBeGreaterThan(0) + expect(trimCalls).toBeLessThan(20) expect(result?.startsWith('Grok prompt Grok prompt')).toBe(true) expect(result?.endsWith('...')).toBe(true) expect(result).not.toContain('USER_QUERY') + expect(result).not.toContain('USER_INFO') }) it('folds Grok array content without joining all text parts', () => { - const joinSpy = vi.spyOn(Array.prototype, 'join') const result = extractGrokContentText([ { type: 'text', text: 'Grok array '.repeat(80) }, { type: 'text', text: 'tail' } ]) - const joinCalls = joinSpy.mock.calls.length - expect(joinCalls).toBe(0) expect(result?.startsWith('Grok array Grok array')).toBe(true) expect(result?.endsWith('...')).toBe(true) }) + + it('drops an astral char straddling the preview scan cap instead of splitting it', () => { + // Hidden context is skipped by the fold, so the 4096-code-unit scan cap lands + // mid-emoji while the visible text stays well under the 220-char preview cap. + const hidden = `${'x'.repeat(4057)}` + const result = extractGrokContentText(`${hidden}ask😀tail`) + + expect(hidden).toHaveLength(4092) + expect(result).toBe('ask') + }) + + it('stores the unwrapped user_query as firstUserPrompt under full capture', async () => { + const root = await mkdtemp(join(tmpdir(), 'orca-ai-vault-grok-first-')) + tempRoots.push(root) + const sessionDir = join(root, 'session-1') + await mkdir(sessionDir, { recursive: true }) + const summaryPath = join(sessionDir, 'summary.json') + await writeFile( + summaryPath, + JSON.stringify({ + info: { id: 'session-1', cwd: '/repo/app' }, + generated_title: 'Grok title', + created_at: '2026-05-01T10:00:00.000Z', + updated_at: '2026-05-01T10:05:00.000Z' + }) + ) + const realAsk = 'fix i18n keep ko workspace worktree and primary' + await writeFile( + join(sessionDir, 'chat_history.jsonl'), + [ + JSON.stringify({ + type: 'user', + content: [ + { + type: 'text', + text: [ + '', + 'OS Version: macos', + 'Shell: /opt/homebrew/bin/bash', + 'Workspace Path: /Users/ada/repo', + "Today's date: 2026-08-01", + 'Note: Prefer using relative paths over absolute paths as tool call args when possible.', + '', + `\n${realAsk}\n` + ].join('\n') + } + ], + timestamp: '2026-05-01T10:00:01.000Z' + }), + JSON.stringify({ + type: 'assistant', + content: 'On it.', + timestamp: '2026-05-01T10:00:02.000Z' + }) + ].join('\n') + ) + + const session = await withFullFirstUserPromptCapture(() => + parseGrokSessionFile({ + path: summaryPath, + mtimeMs: Date.now(), + modifiedAt: new Date().toISOString() + }) + ) + + expect(session?.firstUserPrompt).toBe(realAsk) + expect(session?.firstUserPrompt).not.toContain('user_info') + expect(session?.firstUserPrompt).not.toContain('OS Version') + }) }) diff --git a/src/main/ai-vault/session-scanner-grok-parser.ts b/src/main/ai-vault/session-scanner-grok-parser.ts index 136200074..46ed29e8f 100644 --- a/src/main/ai-vault/session-scanner-grok-parser.ts +++ b/src/main/ai-vault/session-scanner-grok-parser.ts @@ -11,6 +11,14 @@ import { sessionIdFromFileName, updateTimeline } from './session-scanner-accumulator' +import { + normalizeFullFirstUserPromptText, + shouldCaptureFullFirstUserPrompt +} from './session-scanner-first-user-prompt' +import { + extractGrokFirstUserPromptText, + stripGrokUserQueryEnvelope +} from './session-scanner-grok-user-text' import { asRecord, extractPreviewContentText, @@ -18,7 +26,8 @@ import { normalizePreviewText, normalizeTitleText, numberValue, - parseJsonObject + parseJsonObject, + sliceAtCodeUnitLimit } from './session-scanner-values' const GROK_USER_QUERY_PREVIEW_SCAN_LIMIT = 4096 @@ -68,14 +77,38 @@ async function consumeGrokChatHistory( if (role !== 'user' && role !== 'assistant') { continue } - const text = extractGrokContentText(record.content) + if (role === 'user') { - accumulator.title ??= normalizeTitleText(text ?? '') + // Why: first-prompt copy must be the typed ask inside , never + // the injected bootstrap row. + const firstPromptBody = extractGrokFirstUserPromptText(record.content) + const text = firstPromptBody + ? normalizePreviewText(capGrokPreviewSource(firstPromptBody)) + : null + + if (firstPromptBody) { + accumulator.title ??= normalizeTitleText(firstPromptBody) + if (shouldCaptureFullFirstUserPrompt() && !accumulator.firstUserPrompt) { + accumulator.firstUserPrompt = normalizeFullFirstUserPromptText(firstPromptBody) + } + } + + if (text) { + addPreviewMessage(accumulator, { + role: 'user', + text, + timestamp: extractString(record.timestamp), + seedFirstUserPrompt: false + }) + } + continue } + addPreviewMessage(accumulator, { - role, - text, - timestamp: extractString(record.timestamp) + role: 'assistant', + text: extractGrokContentText(record.content), + timestamp: extractString(record.timestamp), + seedFirstUserPrompt: false }) } } catch { @@ -90,45 +123,11 @@ export function extractGrokContentText(value: unknown): string | null { return extractPreviewContentText(value) } +function capGrokPreviewSource(text: string): string { + return sliceAtCodeUnitLimit(text, GROK_USER_QUERY_PREVIEW_SCAN_LIMIT) +} + function extractGrokStringContentText(text: string): string | null { - const bounds = grokUserQueryEnvelopeBounds(text) - if (!bounds) { - return normalizePreviewText(text) - } - - const boundedEnd = Math.min(bounds.end, bounds.start + GROK_USER_QUERY_PREVIEW_SCAN_LIMIT) - return normalizePreviewText(text.slice(bounds.start, boundedEnd)) ?? normalizePreviewText(text) -} - -function grokUserQueryEnvelopeBounds(text: string): { start: number; end: number } | null { - const opener = '' - const startIndex = indexOfAsciiIgnoreCase(text, opener, 0) - if (startIndex === -1) { - return null - } - const bodyStartIndex = startIndex + opener.length - const endIndex = indexOfAsciiIgnoreCase(text, '', bodyStartIndex) - if (endIndex === -1) { - return null - } - return { start: bodyStartIndex, end: endIndex } -} - -function indexOfAsciiIgnoreCase(value: string, search: string, fromIndex: number): number { - const lastStart = value.length - search.length - for (let index = Math.max(0, fromIndex); index <= lastStart; index++) { - let matches = true - for (let offset = 0; offset < search.length; offset++) { - const code = value.charCodeAt(index + offset) - const normalizedCode = code >= 65 && code <= 90 ? code + 32 : code - if (normalizedCode !== search.charCodeAt(offset)) { - matches = false - break - } - } - if (matches) { - return index - } - } - return -1 + const unwrapped = stripGrokUserQueryEnvelope(text) + return normalizePreviewText(capGrokPreviewSource(unwrapped)) } diff --git a/src/main/ai-vault/session-scanner-grok-user-text.test.ts b/src/main/ai-vault/session-scanner-grok-user-text.test.ts new file mode 100644 index 000000000..0c6a6d830 --- /dev/null +++ b/src/main/ai-vault/session-scanner-grok-user-text.test.ts @@ -0,0 +1,50 @@ +import { describe, expect, it } from 'vitest' +import { + extractGrokFirstUserPromptText, + isGrokBootstrapContextText, + stripGrokUserQueryEnvelope +} from './session-scanner-grok-user-text' + +describe('Grok first-user prompt text', () => { + it('unwraps user_query and drops the user_info bootstrap envelope', () => { + const raw = [ + '', + 'OS Version: macos', + 'Shell: /opt/homebrew/bin/bash', + 'Workspace Path: /Users/ada/repo', + "Today's date: 2026-08-01", + 'Note: Prefer using relative paths', + '', + '', + 'fix i18n keep ko workspace worktree and primary', + '' + ].join('\n') + + expect(extractGrokFirstUserPromptText(raw)).toBe( + 'fix i18n keep ko workspace worktree and primary' + ) + expect(stripGrokUserQueryEnvelope(raw)).toBe('fix i18n keep ko workspace worktree and primary') + }) + + it('unwraps user_query even when the closing tag is missing', () => { + const raw = 'context\nShip the full first prompt copy path' + expect(extractGrokFirstUserPromptText(raw)).toBe('Ship the full first prompt copy path') + }) + + it('rejects pure user_info bootstrap rows', () => { + const bootstrap = [ + '', + 'OS Version: macos', + 'Note: Prefer using relative paths over absolute paths', + '' + ].join('\n') + expect(isGrokBootstrapContextText(bootstrap)).toBe(true) + expect(extractGrokFirstUserPromptText(bootstrap)).toBeNull() + }) + + it('keeps ordinary user prompts', () => { + expect(extractGrokFirstUserPromptText('fix the flaky vault tests')).toBe( + 'fix the flaky vault tests' + ) + }) +}) diff --git a/src/main/ai-vault/session-scanner-grok-user-text.ts b/src/main/ai-vault/session-scanner-grok-user-text.ts new file mode 100644 index 000000000..05ee7779b --- /dev/null +++ b/src/main/ai-vault/session-scanner-grok-user-text.ts @@ -0,0 +1,104 @@ +// Shared Grok user-turn text helpers for AI Vault (list preview + full first-prompt +// copy). Kept out of the native-chat decoder so vault scanners stay free of that +// dependency while matching its bootstrap / user_query rules. + +/** + * Prefer the body of a Grok `` envelope when present (closing tag + * optional). Bootstrap ``-only rows return null so a later real ask + * can seed the first prompt. + */ +export function extractGrokFirstUserPromptText(value: unknown): string | null { + const raw = flattenGrokUserContent(value) + if (!raw) { + return null + } + if (isGrokBootstrapContextText(raw)) { + return null + } + const unwrapped = stripGrokUserQueryEnvelope(raw) + const trimmed = unwrapped.trim() + if (!trimmed || isGrokBootstrapContextText(trimmed)) { + return null + } + // Why: if the turn is still just a user_info dump (no query envelope), it is + // not the prompt the user typed and must not be copied as "first prompt". + if ( + startsWithIgnoreCaseTag(trimmed, 'user_info') && + !containsIgnoreCaseTag(trimmed, 'user_query') + ) { + return null + } + return trimmed +} + +function flattenGrokUserContent(value: unknown): string | null { + if (typeof value === 'string') { + return value + } + if (!Array.isArray(value)) { + return null + } + const parts: string[] = [] + for (const item of value) { + if (typeof item === 'string') { + parts.push(item) + continue + } + if (!item || typeof item !== 'object') { + continue + } + const record = item as Record + if (typeof record.type === 'string' && record.type !== 'text') { + continue + } + if (typeof record.text === 'string') { + parts.push(record.text) + } + } + return parts.length > 0 ? parts.join('\n') : null +} + +export function stripGrokUserQueryEnvelope(text: string): string { + const opener = '' + const closer = '' + const lower = text.toLowerCase() + const start = lower.indexOf(opener) + if (start === -1) { + return text + } + const bodyStart = start + opener.length + const end = lower.indexOf(closer, bodyStart) + // Why: incomplete closing tag still holds the real ask after the opener. + if (end === -1) { + return text.slice(bodyStart).trim() + } + return text.slice(bodyStart, end).trim() +} + +export function isGrokBootstrapContextText(text: string): boolean { + const normalized = text.trim().toLowerCase() + if (!normalized.startsWith('')) { + return false + } + const userInfoEnd = normalized.indexOf('') + if (userInfoEnd === -1) { + // Open-ended user_info dump with no query: treat as bootstrap noise. + return !normalized.includes('') + } + const remainder = normalized.slice(userInfoEnd + ''.length).trim() + // Why: Grok appends a git snapshot to the bootstrap row; reject that envelope + // so real prompts mentioning either tag still count as user asks. + return ( + remainder.length === 0 || + (remainder.startsWith('') && remainder.endsWith('')) + ) +} + +function startsWithIgnoreCaseTag(text: string, tagName: string): boolean { + const lower = text.trimStart().toLowerCase() + return lower.startsWith(`<${tagName}>`) || lower.startsWith(`<${tagName} `) +} + +function containsIgnoreCaseTag(text: string, tagName: string): boolean { + return text.toLowerCase().includes(`<${tagName}>`) +} diff --git a/src/main/ai-vault/session-scanner-injected-title.test.ts b/src/main/ai-vault/session-scanner-injected-title.test.ts index 1b3071b75..f0f566a79 100644 --- a/src/main/ai-vault/session-scanner-injected-title.test.ts +++ b/src/main/ai-vault/session-scanner-injected-title.test.ts @@ -134,5 +134,7 @@ describe('scanAiVaultSessions harness-injected title seeding', () => { expect(result.issues).toEqual([]) expect(result.sessions[0]?.lastUserPrompt).toBe('Fix the zoom behavior in a separate PR') + // Meta skill preamble must not become the copyable first prompt. + expect(result.sessions[0]?.firstUserPrompt).toBeUndefined() }) }) diff --git a/src/main/ai-vault/session-scanner-opencode-sqlite.test.ts b/src/main/ai-vault/session-scanner-opencode-sqlite.test.ts index 29f3685ad..f7e042d66 100644 --- a/src/main/ai-vault/session-scanner-opencode-sqlite.test.ts +++ b/src/main/ai-vault/session-scanner-opencode-sqlite.test.ts @@ -6,6 +6,7 @@ import Database from '../sqlite/sync-database' import { buildOpenCodeSqliteCandidatePath } from './session-scanner-opencode-sqlite-paths' import { listOpenCodeSqliteSessions } from './session-scanner-opencode-sqlite-discovery' import { parseOpenCodeSqliteSession } from './session-scanner-opencode-sqlite' +import { withFullFirstUserPromptCapture } from './session-scanner-first-user-prompt-capture' import type { AiVaultScanIssue } from '../../shared/ai-vault-types' let tempDirs: string[] = [] @@ -496,4 +497,107 @@ describe('parseOpenCodeSqliteSession', () => { expect(session).not.toBeNull() expect(session!.model).toBe('claude-sonnet-4-5') }) + + it('captures every text part of the earliest user message and no later turn', async () => { + const { db, path } = createTempDb() + applyOpenCodeSchema(db) + insertSession(db, { + id: 'ses_fp', + timeCreated: 1_777_634_000_000, + timeUpdated: 1_777_634_900_000 + }) + insertMessage(db, { + id: 'msg_1', + sessionId: 'ses_fp', + role: 'user', + timeCreated: 1_777_634_000_000 + }) + insertPart(db, { + id: 'part_1a', + messageId: 'msg_1', + sessionId: 'ses_fp', + timeCreated: 10, + text: 'first ask line one' + }) + insertPart(db, { + id: 'part_1b', + messageId: 'msg_1', + sessionId: 'ses_fp', + timeCreated: 20, + text: 'first ask line two' + }) + // Non-text parts of the same message must not leak into the copied prompt. + insertPart(db, { + id: 'part_1c', + messageId: 'msg_1', + sessionId: 'ses_fp', + timeCreated: 30, + type: 'tool', + text: 'tool output blob' + }) + insertMessage(db, { + id: 'msg_2', + sessionId: 'ses_fp', + role: 'user', + timeCreated: 1_777_634_500_000 + }) + insertPart(db, { + id: 'part_2a', + messageId: 'msg_2', + sessionId: 'ses_fp', + timeCreated: 40, + text: 'a later ask' + }) + db.close() + + const session = await withFullFirstUserPromptCapture(() => + parseOpenCodeSqliteSession({ dbPath: path, sessionId: 'ses_fp', platform: 'darwin' }) + ) + + expect(session!.firstUserPrompt).toBe('first ask line one\nfirst ask line two') + }) + + it('skips an earliest user message that has no text parts', async () => { + const { db, path } = createTempDb() + applyOpenCodeSchema(db) + insertSession(db, { + id: 'ses_fp2', + timeCreated: 1_777_634_000_000, + timeUpdated: 1_777_634_900_000 + }) + insertMessage(db, { + id: 'msg_1', + sessionId: 'ses_fp2', + role: 'user', + timeCreated: 1_777_634_000_000 + }) + insertPart(db, { + id: 'part_1a', + messageId: 'msg_1', + sessionId: 'ses_fp2', + timeCreated: 10, + type: 'tool', + text: 'tool only' + }) + insertMessage(db, { + id: 'msg_2', + sessionId: 'ses_fp2', + role: 'user', + timeCreated: 1_777_634_500_000 + }) + insertPart(db, { + id: 'part_2a', + messageId: 'msg_2', + sessionId: 'ses_fp2', + timeCreated: 40, + text: 'the real typed ask' + }) + db.close() + + const session = await withFullFirstUserPromptCapture(() => + parseOpenCodeSqliteSession({ dbPath: path, sessionId: 'ses_fp2', platform: 'darwin' }) + ) + + expect(session!.firstUserPrompt).toBe('the real typed ask') + }) }) diff --git a/src/main/ai-vault/session-scanner-opencode-sqlite.ts b/src/main/ai-vault/session-scanner-opencode-sqlite.ts index ad3e9b76f..d247156b8 100644 --- a/src/main/ai-vault/session-scanner-opencode-sqlite.ts +++ b/src/main/ai-vault/session-scanner-opencode-sqlite.ts @@ -5,6 +5,10 @@ import { finalizeSession, updateTimeline } from './session-scanner-accumulator' +import { + normalizeFullFirstUserPromptText, + shouldCaptureFullFirstUserPrompt +} from './session-scanner-first-user-prompt' import { normalizeTitleText } from './session-scanner-values' import SyncDatabase from '../sqlite/sync-database' import { columnExists, tableExists } from '../opencode-usage/schema-helpers' @@ -22,6 +26,8 @@ const OPENCODE_SQLITE_PREVIEW_LIMIT = 5 // id) index. Bounds the read to those messages' parts; the 15 s parse timeout // caps the residual for a single pathological giant part. const OPENCODE_SQLITE_PREVIEW_MESSAGE_WINDOW = 100 +// Bounds a pathological single message; a real typed prompt is a handful of parts. +const FIRST_USER_PROMPT_PART_LIMIT = 512 type SessionRow = { id: string @@ -154,6 +160,58 @@ function extractPartText(partData: string): string | null { } } +function readFirstUserPromptFromOpenCodeDb(db: SyncDatabase, sessionId: string): string | null { + if ( + !canCountOpenCodeMessages(db) || + !tableExists(db, 'part') || + !columnExists(db, 'message', 'id') || + !columnExists(db, 'part', 'message_id') || + !columnExists(db, 'part', 'time_created') || + !columnExists(db, 'part', 'data') + ) { + return null + } + + try { + // Why: pin to the single earliest user message that actually has text parts, + // then take all of its parts. Ordering parts across every user message would + // pad a short first prompt with later turns and truncate a long one. + const rows = db + .prepare( + `SELECT p.data AS part_data + FROM part p + WHERE p.message_id = ( + SELECT m.id + FROM message m + JOIN part fp ON fp.message_id = m.id + WHERE m.session_id = ? + AND json_extract(m.data, '$.role') = 'user' + AND json_extract(fp.data, '$.type') = 'text' + ORDER BY m.time_created ASC, m.id ASC + LIMIT 1 + ) + AND json_extract(p.data, '$.type') = 'text' + ORDER BY p.time_created ASC, p.rowid ASC + LIMIT ${FIRST_USER_PROMPT_PART_LIMIT}` + ) + .all(sessionId) as { part_data: string }[] + + const parts: string[] = [] + for (const row of rows) { + const text = extractPartText(row.part_data) + if (text) { + parts.push(text) + } + } + if (parts.length === 0) { + return null + } + return normalizeFullFirstUserPromptText(parts.join('\n')) + } catch { + return null + } +} + function buildPreviewQuery(db: SyncDatabase): string | null { if ( !canCountOpenCodeMessages(db) || @@ -254,7 +312,9 @@ export async function parseOpenCodeSqliteSession(args: { addPreviewMessage(accumulator, { role: mapPreviewRole(previewRow.role), text, - timestamp: previewRow.time_created + timestamp: previewRow.time_created, + // Preview window is newest-N; first-prompt is loaded separately below. + seedFirstUserPrompt: false }) if (previewRow.role === 'user' && !accumulator.title) { accumulator.title = @@ -264,6 +324,12 @@ export async function parseOpenCodeSqliteSession(args: { } } + // Why: list preview only joins the newest messages. On-demand copy needs the + // session's earliest real user text part, not a later turn still in the window. + if (shouldCaptureFullFirstUserPrompt()) { + accumulator.firstUserPrompt = readFirstUserPromptFromOpenCodeDb(db, sessionId) + } + return finalizeSession(accumulator, platform) } finally { db?.close() diff --git a/src/main/ai-vault/session-scanner-primary-parsers.ts b/src/main/ai-vault/session-scanner-primary-parsers.ts index c7e67ec46..537055d87 100644 --- a/src/main/ai-vault/session-scanner-primary-parsers.ts +++ b/src/main/ai-vault/session-scanner-primary-parsers.ts @@ -127,13 +127,17 @@ export function consumeClaudeSessionLine(state: ClaudeSessionParseState, line: s if (record.type === 'user') { accumulator.messageCount++ const title = extractMessageText(record.message) - addPreviewContent(accumulator, 'user', asRecord(record.message)?.content, record.timestamp) + // Meta prompts (injected context) only seed the last-resort title. Some + // injected turns (task notifications) carry no isMeta, so also gate on + // the known-tag classifier — a real prompt pasting a custom `` + // must seed the primary title, not be demoted as machinery. + const isMetaUserTurn = + record.isMeta === true || (title != null && isKnownHarnessInjectedUserTurnText(title)) + addPreviewContent(accumulator, 'user', asRecord(record.message)?.content, record.timestamp, { + seedFirstUserPrompt: !isMetaUserTurn + }) if (title) { - // Meta prompts (injected context) only seed the last-resort title. Some - // injected turns (task notifications) carry no isMeta, so also gate on - // the known-tag classifier — a real prompt pasting a custom `` - // must seed the primary title, not be demoted as machinery. - if (record.isMeta === true || isKnownHarnessInjectedUserTurnText(title)) { + if (isMetaUserTurn) { state.metaTitle ??= title } else { state.firstUserTitle ??= title diff --git a/src/main/ai-vault/session-scanner-secondary-parsers.ts b/src/main/ai-vault/session-scanner-secondary-parsers.ts index 364c25b5d..cb472aaef 100644 --- a/src/main/ai-vault/session-scanner-secondary-parsers.ts +++ b/src/main/ai-vault/session-scanner-secondary-parsers.ts @@ -18,13 +18,13 @@ import { sessionIdFromFileName, updateTimeline } from './session-scanner-accumulator' +import { extractFullFirstUserPromptText } from './session-scanner-first-user-prompt' import { arrayValue, asRecord, copilotModelMetricsTotal, extractContentText, extractMessageText, - extractPreviewContentText, extractString, extractTrustedFolder, findOpenCodeStorageRoot, @@ -253,10 +253,12 @@ export async function consumeOpenCodeMessages( accumulator.title ??= extractString(asRecord(message.summary)?.title) accumulator.title ??= extractString(asRecord(message.summary)?.body) } + // Why: pass raw body text so full first-prompt capture is not stuck on the + // 220-char preview fold (addPreviewMessage preview-caps for display). addPreviewMessage(accumulator, { role, text: - extractPreviewContentText(message.content) ?? + extractFullFirstUserPromptText(message.content) ?? extractString(asRecord(message.summary)?.body) ?? extractString(asRecord(message.summary)?.title), timestamp: timeObjectValue(message.time, 'created') diff --git a/src/main/ai-vault/session-scanner-text-normalization.ts b/src/main/ai-vault/session-scanner-text-normalization.ts index 88eae73eb..99f73a93d 100644 --- a/src/main/ai-vault/session-scanner-text-normalization.ts +++ b/src/main/ai-vault/session-scanner-text-normalization.ts @@ -42,6 +42,15 @@ export function normalizePreviewText(value: string): string | null { return finalizeNormalizedText(normalizeStringText(value, SESSION_PREVIEW_TEXT_LIMIT)) } +/** Cut to `limit` UTF-16 code units without splitting a trailing surrogate pair. */ +export function sliceAtCodeUnitLimit(value: string, limit: number): string { + if (value.length <= limit) { + return value + } + const end = limit > 0 && isHighSurrogate(value.charCodeAt(limit - 1)) ? limit - 1 : limit + return value.slice(0, end) +} + function normalizeContentText(value: unknown, limit: number): string | null { if (typeof value === 'string') { return finalizeNormalizedText(normalizeStringText(value, limit)) @@ -112,15 +121,18 @@ function appendInterPartSpace(builder: TextBuilder): void { } } -function appendNormalizedString(builder: TextBuilder, value: string): void { +function appendNormalizedString(builder: TextBuilder, value: string, maxScanLength?: number): void { + const scanEnd = maxScanLength == null ? value.length : Math.min(value.length, maxScanLength) let index = 0 - while (index < value.length && !builder.truncated) { + while (index < scanEnd && !builder.truncated) { const hiddenBlockEnd = hiddenTextBlockEnd(value, index) if (hiddenBlockEnd !== null) { if (builder.text.length > 0) { builder.pendingSpace = true } - index = hiddenBlockEnd + // Why: hidden blocks may jump past the scan budget; clamp so multi-MB + // suppressed context cannot keep the first-prompt path busy. + index = Math.min(hiddenBlockEnd, scanEnd) continue } @@ -142,9 +154,17 @@ function appendNormalizedString(builder: TextBuilder, value: string): void { } const charLength = codePointLength(value, index) + // Why: do not read past scanEnd mid code-point when the budget lands inside + // a surrogate pair — drop the incomplete char instead. + if (index + charLength > scanEnd) { + break + } appendVisibleText(builder, value.slice(index, index + charLength)) index += charLength } + if (!builder.truncated && scanEnd < value.length && builder.text.length > 0) { + builder.truncated = true + } } function appendVisibleText(builder: TextBuilder, value: string): void { @@ -230,9 +250,7 @@ function isSuppressedContextPrefix(value: string): boolean { } function truncateWithEllipsis(value: string, limit: number): string { - const end = Math.max(0, limit - ELLIPSIS.length) - const safeEnd = end > 0 && isHighSurrogate(value.charCodeAt(end - 1)) ? end - 1 : end - return `${value.slice(0, safeEnd)}${ELLIPSIS}` + return `${sliceAtCodeUnitLimit(value, Math.max(0, limit - ELLIPSIS.length))}${ELLIPSIS}` } function objectRecord(value: unknown): Record | null { diff --git a/src/main/ai-vault/session-scanner-types.ts b/src/main/ai-vault/session-scanner-types.ts index ac94a067d..cc8f4939a 100644 --- a/src/main/ai-vault/session-scanner-types.ts +++ b/src/main/ai-vault/session-scanner-types.ts @@ -110,6 +110,7 @@ export type SessionAccumulator = { messageCount: number totalTokens: number previewMessages: AiVaultSessionPreviewMessage[] + firstUserPrompt: string | null lastUserPrompt: string | null // Recoverable signal for a zero-turn transcript (see AiVaultSession). queuedMessageCount: number diff --git a/src/main/ai-vault/session-scanner-values.test.ts b/src/main/ai-vault/session-scanner-values.test.ts index a9954ae8d..3ca343272 100644 --- a/src/main/ai-vault/session-scanner-values.test.ts +++ b/src/main/ai-vault/session-scanner-values.test.ts @@ -1,7 +1,9 @@ import { describe, expect, it, vi } from 'vitest' import { + extractFullFirstUserPromptText, extractPreviewContentText, normalizeAgentSessionsDir, + normalizeFullFirstUserPromptText, normalizePreviewText, normalizeTitleText } from './session-scanner-values' @@ -53,6 +55,23 @@ describe('AI Vault session scanner text values', () => { expect(result).toBe(`${'a'.repeat(216)}...`) }) + it('preserves full first-prompt text including newlines for copy', () => { + const body = `First prompt line one\n\nline two ${'word '.repeat(100)}` + expect(normalizeFullFirstUserPromptText(body)).toBe(body.trim()) + expect(extractFullFirstUserPromptText([{ type: 'text', text: body }])).toBe(body.trim()) + }) + + it('reads Codex input_text blocks and ignores tool blocks', () => { + const body = `Review the PR\n\n${'detail '.repeat(50).trimEnd()}` + expect(extractFullFirstUserPromptText([{ type: 'input_text', text: body }])).toBe(body) + expect( + extractFullFirstUserPromptText([ + { type: 'tool_result', content: 'src/main/window.ts was updated' }, + { type: 'text', text: 'Please continue the editor refactor' } + ]) + ).toBe('Please continue the editor refactor') + }) + it('expands Pi and OMP agent homes to their session directories', () => { expect(normalizeAgentSessionsDir('/agents/.pi', '.pi')).toBe('/agents/.pi/agent/sessions') expect(normalizeAgentSessionsDir('/agents/.pi/agent', '.pi')).toBe('/agents/.pi/agent/sessions') diff --git a/src/main/ai-vault/session-scanner-values.ts b/src/main/ai-vault/session-scanner-values.ts index 1deb8bf5d..10ec34cd1 100644 --- a/src/main/ai-vault/session-scanner-values.ts +++ b/src/main/ai-vault/session-scanner-values.ts @@ -55,8 +55,14 @@ export { extractMessageText, extractPreviewContentText, normalizePreviewText, - normalizeTitleText + normalizeTitleText, + sliceAtCodeUnitLimit } from './session-scanner-text-normalization' +export { + extractFullFirstUserPromptText, + normalizeFullFirstUserPromptText, + shouldCaptureFullFirstUserPrompt +} from './session-scanner-first-user-prompt' export function extractGitBranch(value: unknown): string | null { const git = asRecord(value) diff --git a/src/main/ai-vault/session-scanner.test.ts b/src/main/ai-vault/session-scanner.test.ts index 7ba805560..7c472d6f3 100644 --- a/src/main/ai-vault/session-scanner.test.ts +++ b/src/main/ai-vault/session-scanner.test.ts @@ -174,6 +174,8 @@ describe('scanAiVaultSessions', () => { totalTokens: 155, resumeCommand: "cd '/repo/app' && claude --resume 'claude-session'" }) + // Why: list scans omit firstUserPrompt so the vault payload stays bounded. + expect(claude?.firstUserPrompt).toBeUndefined() const codex = result.sessions.find((session) => session.agent === 'codex') expect(codex).toMatchObject({ @@ -185,6 +187,7 @@ describe('scanAiVaultSessions', () => { totalTokens: 625, resumeCommand: `cd '/repo/app/packages/web' && CODEX_HOME='${root}' codex resume '019f0000-1111-7222-8333-444444444444'` }) + expect(codex?.firstUserPrompt).toBeUndefined() }) it('indexes Codex sessions from Orca runtime homes with resumable commands', async () => { diff --git a/src/main/ipc/ai-vault.ts b/src/main/ipc/ai-vault.ts index 0e4496290..e103b7b5c 100644 --- a/src/main/ipc/ai-vault.ts +++ b/src/main/ipc/ai-vault.ts @@ -13,11 +13,13 @@ import { claudeProjectsRootDirs } from '../ai-vault/session-scanner-source-disco import { isPathInsideOrEqual } from '../../shared/cross-platform-path' import { aiVaultScanIssueResult, mergeAiVaultListResults } from '../ai-vault/session-list-results' import type { + AiVaultFirstUserPromptArgs, AiVaultListArgs, AiVaultListResult, AiVaultSubagentListArgs, AiVaultSubagentListResult } from '../../shared/ai-vault-types' +import { handleAiVaultGetFirstUserPrompt } from '../ai-vault/session-first-user-prompt-read' import { registerAiVaultResumeHandler, type AiVaultResumeHandlerOptions } from './ai-vault-resume' import { LOCAL_EXECUTION_HOST_ID, @@ -286,6 +288,9 @@ export function registerAiVaultHandlers(options: AiVaultHandlerOptions = {}): vo (_event, args?: AiVaultSubagentListArgs): Promise => listAiVaultSubagentSessions(args) ) + ipcMain.handle('aiVault:getFirstUserPrompt', (_event, args?: AiVaultFirstUserPromptArgs) => + handleAiVaultGetFirstUserPrompt(args) + ) // DOM focus/visibility events don't fire in the renderer on macOS app // activation, so refresh-on-refocus needs this main-process signal. app.on('browser-window-focus', (_event, window) => { diff --git a/src/preload/api-types.ts b/src/preload/api-types.ts index 368c4df4f..3c633dd2d 100644 --- a/src/preload/api-types.ts +++ b/src/preload/api-types.ts @@ -481,6 +481,8 @@ import type { OpenCodeUsageSummary } from '../shared/opencode-usage-types' import type { + AiVaultFirstUserPromptArgs, + AiVaultFirstUserPromptResult, AiVaultListArgs, AiVaultListResult, AiVaultSubagentListArgs, @@ -884,6 +886,8 @@ export type AiVaultApi = { ) => Promise /** Lists the Task subagent transcripts of one session, on demand. */ listSubagentSessions: (args: AiVaultSubagentListArgs) => Promise + /** Full first user prompt for copy/reuse (re-parses one transcript). */ + getFirstUserPrompt: (args: AiVaultFirstUserPromptArgs) => Promise /** Fires when any app window regains OS focus; returns an unsubscribe. */ onWindowFocused: (callback: () => void) => () => void } diff --git a/src/preload/index.ts b/src/preload/index.ts index d838a28d4..3df27dad9 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -230,7 +230,11 @@ import type { AutomationUpdateInput } from '../shared/automations-types' import type { KeybindingActionId, KeybindingFileSnapshot } from '../shared/keybindings' -import type { AiVaultListArgs, AiVaultSubagentListArgs } from '../shared/ai-vault-types' +import type { + AiVaultFirstUserPromptArgs, + AiVaultListArgs, + AiVaultSubagentListArgs +} from '../shared/ai-vault-types' import type { AiVaultPrepareSessionResumeArgs } from '../shared/ai-vault-resume-preparation' import type { AgentType } from '../shared/native-chat-types' import { @@ -4163,6 +4167,8 @@ const api = { ipcRenderer.invoke('aiVault:prepareSessionResume', args), listSubagentSessions: (args: AiVaultSubagentListArgs): Promise => ipcRenderer.invoke('aiVault:listSubagentSessions', args), + getFirstUserPrompt: (args: AiVaultFirstUserPromptArgs): Promise => + ipcRenderer.invoke('aiVault:getFirstUserPrompt', args), onWindowFocused: (callback: () => void): (() => void) => { const listener = (_event: Electron.IpcRendererEvent) => callback() ipcRenderer.on('aiVault:windowFocused', listener) diff --git a/src/renderer/src/components/right-sidebar/AiVaultSessionDetails.tsx b/src/renderer/src/components/right-sidebar/AiVaultSessionDetails.tsx index 29b21ff07..30e44d375 100644 --- a/src/renderer/src/components/right-sidebar/AiVaultSessionDetails.tsx +++ b/src/renderer/src/components/right-sidebar/AiVaultSessionDetails.tsx @@ -1,5 +1,12 @@ import type React from 'react' -import { FileJson, FolderGit2, MessageSquare, MessageSquarePlus, Play } from 'lucide-react' +import { + FileJson, + FolderGit2, + MessageSquare, + MessageSquarePlus, + Play, + TextCursorInput +} from 'lucide-react' import { Button } from '@/components/ui/button' import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip' import { cn } from '@/lib/utils' @@ -9,7 +16,8 @@ import { type AiVaultSession } from '../../../../shared/ai-vault-types' import { translate } from '@/i18n/i18n' -import { sessionDetailConversationTurns } from './ai-vault-session-display' +import { FirstPromptCard } from './ai-vault-first-prompt-card' +import { sessionDetailConversationTurns, sessionFirstPrompt } from './ai-vault-session-display' import { SessionSubagentsSection } from './AiVaultSessionSubagents' import { SessionUnsavedConversationNotice } from './AiVaultSessionUnsavedNotice' import { @@ -51,6 +59,7 @@ export function SessionInlineDetails({ const showResumeInNewTab = hasResumableContent && (!resumeActions.worktree.worktreeId || Boolean(resumeActions.newTab.worktreeId)) + const firstPromptPreview = sessionFirstPrompt(session) const detailTurns = sessionDetailConversationTurns(session, 3) const worktreeDisplay = worktreeInfo @@ -66,78 +75,8 @@ export function SessionInlineDetails({ event.stopPropagation() }} > -
- {hasResumableContent ? ( - } - label={translate( - 'auto.components.right.sidebar.AiVaultSessionDetails.latestTurns', - 'Latest turns' - )} - > - {detailTurns.length > 0 ? ( -
- {detailTurns.map((turn, index) => ( - - ))} -
- ) : ( - - )} -
- ) : ( - // An unsaved session has no turns to show; the notice replaces the - // preview section instead of stacking a second empty state under it. - - )} - - - - {shouldShowAiVaultSessionWorktreeLine(worktreeDisplay, { - vaultScope - }) ? ( - } - label={translate( - 'auto.components.right.sidebar.AiVaultSessionDetails.worktree', - 'Worktree' - )} - > - - - ) : null} -
- {showResumeInWorktree || showResumeInNewTab || onContinueInNewSession || onOpenLog ? ( -
- {onContinueInNewSession ? ( - - ) : null} +
{showResumeInWorktree ? ( + ) : null} {onOpenLog ? (
) } @@ -241,7 +265,7 @@ function ConversationTurnCard({
{conversationRoleLabel(role)}
-

+

{text}

@@ -313,77 +337,6 @@ function SessionDetailEmptyState({ message }: { message: string }): React.JSX.El ) } -export function SessionTime({ - value, - className -}: { - value: string - className?: string -}): React.JSX.Element { - const timestamp = Date.parse(value) - if (!Number.isFinite(timestamp)) { - return ( - - {translate( - 'auto.components.right.sidebar.AiVaultSessionDetails.unknownTime', - 'Unknown time' - )} - - ) - } - - const date = new Date(timestamp) - return ( - - - - ) -} - -function formatTimeAgo(timestamp: number): string { - const diffMs = Date.now() - timestamp - if (diffMs < 60_000) { - return translate('auto.components.right.sidebar.AiVaultSessionDetails.justNow', 'Just now') - } - const minutes = Math.floor(diffMs / 60_000) - if (minutes < 60) { - return translate( - 'auto.components.right.sidebar.AiVaultSessionDetails.minutesAgo', - '{{value0}}m ago', - { value0: minutes } - ) - } - const hours = Math.floor(minutes / 60) - if (hours < 24) { - return translate( - 'auto.components.right.sidebar.AiVaultSessionDetails.hoursAgo', - '{{value0}}h ago', - { value0: hours } - ) - } - const days = Math.floor(hours / 24) - if (days < 30) { - return translate( - 'auto.components.right.sidebar.AiVaultSessionDetails.daysAgo', - '{{value0}}d ago', - { value0: days } - ) - } - const months = Math.floor(days / 30) - if (months < 12) { - return translate( - 'auto.components.right.sidebar.AiVaultSessionDetails.monthsAgo', - '{{value0}}mo ago', - { value0: months } - ) - } - return translate( - 'auto.components.right.sidebar.AiVaultSessionDetails.yearsAgo', - '{{value0}}y ago', - { value0: Math.floor(months / 12) } - ) -} - function conversationRoleLabel(role: AiVaultSession['previewMessages'][number]['role']): string { if (role === 'user') { return translate('auto.components.right.sidebar.AiVaultSessionDetails.userRole', 'You') diff --git a/src/renderer/src/components/right-sidebar/AiVaultSessionRow.tsx b/src/renderer/src/components/right-sidebar/AiVaultSessionRow.tsx index 921ecf33f..416fb2bb7 100644 --- a/src/renderer/src/components/right-sidebar/AiVaultSessionRow.tsx +++ b/src/renderer/src/components/right-sidebar/AiVaultSessionRow.tsx @@ -87,11 +87,6 @@ export function VaultSessionRow({ const startResumeDrag = useCallback( (event: React.DragEvent): void => { event.stopPropagation() - const target = event.target - if (target instanceof Element && target.closest('[data-ai-vault-session-actions]')) { - event.preventDefault() - return - } if (resumeDisabled) { event.preventDefault() return @@ -122,27 +117,35 @@ export function VaultSessionRow({