From dbfffa6530f468169efd106b88b4db68b4818796 Mon Sep 17 00:00:00 2001 From: Jinjing <6427696+AmethystLiang@users.noreply.github.com> Date: Sat, 1 Aug 2026 18:38:49 -0700 Subject: [PATCH] Add first user prompt to AI Vault session history row (#12006) * Add first user prompt to AI Vault session history rows Re-parse transcripts on demand to extract and display the untruncated first user prompt for copy/reuse. List scans omit the body (payload/perf); UI loads it when session details expand. Grok sessions extract the typed ask from envelope, skipping injected bootstrap rows. Supports Claude, Codex, Grok, and OpenCode agents. * fix(ai-vault): split SessionTime out to pass max-lines lint AiVaultSessionDetails exceeded the 400-line oxlint limit after adding first-prompt UI; move SessionTime into its own module. * fix(ai-vault): handle corrupt transcripts and fix OpenCode prompt captur Corrupt transcripts now resolve null instead of rejecting the IPC call, matching behavior for other unavailable cases. OpenCode SQLite parsing now correctly captures all text parts from the earliest user message only, fixing truncation of large prompts and padding of small ones. Add stale-response guard in the UI to prevent late results from overwriting the current session when tabs switch. Consolidate text slicing via `sliceAtCodeUnitLimit` to avoid surrogate-pair splits across all callers. * test(ai-vault): add first-user-prompt UTF-16 safety tests Ensure truncation at safety limits doesn't split UTF-16 surrogate pairs, preventing corruption of astral characters in captured prompts. * fix(ai-vault): key first-prompt-card by session.id Remounting the card on session switches prevents late responses from a previous load from writing stale data into the component's refs. Also improves conversation-turn key stability. --- src/main/ai-vault/runtime-session-scanner.ts | 1 + .../session-first-user-prompt-read.test.ts | 137 ++++++++++ .../session-first-user-prompt-read.ts | 144 +++++++++++ .../ai-vault/session-scanner-accumulator.ts | 36 ++- ...ssion-scanner-first-user-prompt-capture.ts | 15 ++ .../session-scanner-first-user-prompt.test.ts | 39 +++ .../session-scanner-first-user-prompt.ts | 104 ++++++++ .../session-scanner-grok-parser.test.ts | 94 ++++++- .../ai-vault/session-scanner-grok-parser.ts | 91 ++++--- .../session-scanner-grok-user-text.test.ts | 50 ++++ .../session-scanner-grok-user-text.ts | 104 ++++++++ .../session-scanner-injected-title.test.ts | 2 + .../session-scanner-opencode-sqlite.test.ts | 104 ++++++++ .../session-scanner-opencode-sqlite.ts | 68 ++++- .../session-scanner-primary-parsers.ts | 16 +- .../session-scanner-secondary-parsers.ts | 6 +- .../session-scanner-text-normalization.ts | 30 ++- src/main/ai-vault/session-scanner-types.ts | 1 + .../ai-vault/session-scanner-values.test.ts | 19 ++ src/main/ai-vault/session-scanner-values.ts | 8 +- src/main/ai-vault/session-scanner.test.ts | 3 + src/main/ipc/ai-vault.ts | 5 + src/preload/api-types.ts | 4 + src/preload/index.ts | 8 +- .../right-sidebar/AiVaultSessionDetails.tsx | 243 +++++++----------- .../right-sidebar/AiVaultSessionRow.tsx | 31 ++- .../ai-vault-first-prompt-card.tsx | 188 ++++++++++++++ .../ai-vault-session-display.test.ts | 24 ++ .../right-sidebar/ai-vault-session-display.ts | 1 + .../ai-vault-session-row-display.tsx | 4 +- .../right-sidebar/ai-vault-session-time.tsx | 74 ++++++ src/renderer/src/i18n/locales/en.json | 9 +- src/renderer/src/i18n/locales/es.json | 9 +- src/renderer/src/i18n/locales/ja.json | 9 +- src/renderer/src/i18n/locales/ko.json | 9 +- src/renderer/src/i18n/locales/zh.json | 9 +- src/renderer/src/web/web-preload-api.ts | 2 + src/shared/ai-vault-session-display.ts | 24 ++ src/shared/ai-vault-types.ts | 20 ++ .../harness-injected-user-turns.test.ts | 6 + src/shared/harness-injected-user-turns.ts | 20 +- 41 files changed, 1533 insertions(+), 238 deletions(-) create mode 100644 src/main/ai-vault/session-first-user-prompt-read.test.ts create mode 100644 src/main/ai-vault/session-first-user-prompt-read.ts create mode 100644 src/main/ai-vault/session-scanner-first-user-prompt-capture.ts create mode 100644 src/main/ai-vault/session-scanner-first-user-prompt.test.ts create mode 100644 src/main/ai-vault/session-scanner-first-user-prompt.ts create mode 100644 src/main/ai-vault/session-scanner-grok-user-text.test.ts create mode 100644 src/main/ai-vault/session-scanner-grok-user-text.ts create mode 100644 src/renderer/src/components/right-sidebar/ai-vault-first-prompt-card.tsx create mode 100644 src/renderer/src/components/right-sidebar/ai-vault-session-time.tsx 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({