diff --git a/src/main/ai-vault/remote-session-scanner-sources.ts b/src/main/ai-vault/remote-session-scanner-sources.ts index f68ba32d6c1..5f57fa47c7b 100644 --- a/src/main/ai-vault/remote-session-scanner-sources.ts +++ b/src/main/ai-vault/remote-session-scanner-sources.ts @@ -9,11 +9,9 @@ import { parseDroidSessionContent } from './session-scanner-droid-parser' import { parseMessageGraphSessionContent } from './session-scanner-graph-parsers' import { parseClaudeSessionContent } from './session-scanner-primary-parsers' import { parseGeminiSessionContent } from './session-scanner-gemini-parsers' -import { - parseCopilotSessionContent, - parseCursorSessionContent, - parseHermesSessionContent -} from './session-scanner-secondary-parsers' +import { parseCopilotSessionContent } from './session-scanner-copilot-parser' +import { parseCursorSessionContent } from './session-scanner-cursor-parser' +import { parseHermesSessionContent } from './session-scanner-hermes-parser' import type { FileWithMtime } from './session-scanner-types' import { normalizeAgentSessionsDir } from './session-scanner-values' import { remoteCodexIndexTitles } from './remote-session-scanner-codex-index' diff --git a/src/main/ai-vault/session-scanner-agent-parser.ts b/src/main/ai-vault/session-scanner-agent-parser.ts index bcfb8babdfc..1c2c9e84e8e 100644 --- a/src/main/ai-vault/session-scanner-agent-parser.ts +++ b/src/main/ai-vault/session-scanner-agent-parser.ts @@ -10,12 +10,10 @@ import { parseOpenCodeSqliteSessionViaWorker } from './session-scanner-opencode- import { parseClaudeSessionFile } from './session-scanner-primary-parsers' import { parseGeminiSessionFile } from './session-scanner-gemini-parsers' import { parseCodexSessionFile } from './session-scanner-codex-parser' -import { - parseCopilotSessionFile, - parseCursorSessionFile, - parseHermesSessionFile, - parseOpenCodeSessionFile -} from './session-scanner-secondary-parsers' +import { parseCopilotSessionFile } from './session-scanner-copilot-parser' +import { parseCursorSessionFile } from './session-scanner-cursor-parser' +import { parseHermesSessionFile } from './session-scanner-hermes-parser' +import { parseOpenCodeSessionFile } from './session-scanner-opencode-parser' import type { SessionFileCandidate } from './session-scanner-types' /** diff --git a/src/main/ai-vault/session-scanner-copilot-parser.ts b/src/main/ai-vault/session-scanner-copilot-parser.ts new file mode 100644 index 00000000000..3bb0b4ce1f0 --- /dev/null +++ b/src/main/ai-vault/session-scanner-copilot-parser.ts @@ -0,0 +1,126 @@ +import { createReadStream } from 'node:fs' +import { createInterface } from 'node:readline' +import type { AiVaultSession } from '../../shared/ai-vault-types' +import type { ExecutionHostId } from '../../shared/execution-host' +import type { + FileWithMtime, + ResumableSessionParseState, + SessionAccumulator +} from './session-scanner-types' +import { + accumulatorFoldResumeState, + addPreviewMessage, + createAccumulator, + sessionIdFromFileName, + updateTimeline +} from './session-scanner-accumulator' +import { + asRecord, + copilotModelMetricsTotal, + extractString, + extractTrustedFolder, + normalizeTitleText, + numberValue, + parseJsonObject +} from './session-scanner-values' + +type ParserSessionOptions = { + executionHostId?: ExecutionHostId + executionHostPlatform?: NodeJS.Platform | null +} + +export async function parseCopilotSessionFile( + file: FileWithMtime, + platform: NodeJS.Platform = process.platform +): Promise { + const lines = createInterface({ + input: createReadStream(file.path, { encoding: 'utf-8' }), + crlfDelay: Infinity + }) + return parseCopilotSessionLines({ file, lines, platform }) +} + +export async function parseCopilotSessionContent( + file: FileWithMtime, + content: string, + platform: NodeJS.Platform = process.platform, + options: ParserSessionOptions = {} +): Promise { + return parseCopilotSessionLines({ + file, + lines: content.split(/\r?\n/), + platform, + options + }) +} + +function consumeCopilotRecordLine(accumulator: SessionAccumulator, line: string): void { + const record = parseJsonObject(line) + if (!record) { + return + } + updateTimeline(accumulator, extractString(record.timestamp)) + const data = asRecord(record.data) + if (record.type === 'session.start' && data) { + const sessionId = extractString(data.sessionId) + if (sessionId) { + accumulator.sessionId = sessionId + } + updateTimeline(accumulator, extractString(data.startTime)) + return + } + if (record.type === 'session.model_change' && data) { + accumulator.model = extractString(data.newModel) ?? accumulator.model + return + } + if (record.type === 'session.info' && data) { + accumulator.cwd = extractTrustedFolder(data.message) ?? accumulator.cwd + return + } + if (record.type === 'user.message' && data) { + accumulator.messageCount++ + accumulator.title ??= normalizeTitleText( + extractString(data.transformedContent) ?? extractString(data.content) ?? '' + ) + addPreviewMessage(accumulator, { + role: 'user', + text: extractString(data.transformedContent) ?? extractString(data.content), + timestamp: record.timestamp + }) + return + } + if (record.type === 'assistant.message' && data) { + accumulator.messageCount++ + addPreviewMessage(accumulator, { + role: 'assistant', + text: extractString(data.content), + timestamp: record.timestamp + }) + return + } + if (record.type === 'session.shutdown' && data) { + accumulator.model = extractString(data.currentModel) ?? accumulator.model + accumulator.totalTokens += numberValue(data.currentTokens) + accumulator.totalTokens += copilotModelMetricsTotal(data.modelMetrics) + } +} + +export function createCopilotSessionResumeState(file: FileWithMtime): ResumableSessionParseState { + return accumulatorFoldResumeState( + createAccumulator({ agent: 'copilot', file, sessionId: sessionIdFromFileName(file.path) }), + consumeCopilotRecordLine + ) +} + +async function parseCopilotSessionLines(args: { + file: FileWithMtime + lines: AsyncIterable | Iterable + platform: NodeJS.Platform + options?: ParserSessionOptions +}): Promise { + const state = createCopilotSessionResumeState(args.file) + for await (const line of args.lines) { + state.consumeLine(line) + } + return state.finalize(args.platform, args.options) +} diff --git a/src/main/ai-vault/session-scanner-cursor-parser.ts b/src/main/ai-vault/session-scanner-cursor-parser.ts new file mode 100644 index 00000000000..44274d3b671 --- /dev/null +++ b/src/main/ai-vault/session-scanner-cursor-parser.ts @@ -0,0 +1,94 @@ +import { createReadStream } from 'node:fs' +import { createInterface } from 'node:readline' +import type { AiVaultSession } from '../../shared/ai-vault-types' +import type { ExecutionHostId } from '../../shared/execution-host' +import type { + FileWithMtime, + ResumableSessionParseState, + SessionAccumulator +} from './session-scanner-types' +import { + accumulatorFoldResumeState, + addPreviewContent, + createAccumulator, + sessionIdFromFileName, + updateTimeline +} from './session-scanner-accumulator' +import { + asRecord, + extractContentText, + extractMessageText, + extractString, + parseJsonObject +} from './session-scanner-values' + +type ParserSessionOptions = { + executionHostId?: ExecutionHostId + executionHostPlatform?: NodeJS.Platform | null +} + +export async function parseCursorSessionFile( + file: FileWithMtime, + platform: NodeJS.Platform = process.platform +): Promise { + const lines = createInterface({ + input: createReadStream(file.path, { encoding: 'utf-8' }), + crlfDelay: Infinity + }) + return parseCursorSessionLines({ file, lines, platform }) +} + +export async function parseCursorSessionContent( + file: FileWithMtime, + content: string, + platform: NodeJS.Platform = process.platform, + options: ParserSessionOptions = {} +): Promise { + return parseCursorSessionLines({ + file, + lines: content.split(/\r?\n/), + platform, + options + }) +} + +function consumeCursorRecordLine(accumulator: SessionAccumulator, line: string): void { + const record = parseJsonObject(line) + if (!record) { + return + } + updateTimeline(accumulator, extractString(record.timestamp)) + const role = extractString(record.role) + if (role === 'user' || role === 'assistant') { + accumulator.messageCount++ + if (role === 'user') { + accumulator.title ??= extractMessageText(record.message) ?? extractContentText(record.content) + } + addPreviewContent( + accumulator, + role, + asRecord(record.message)?.content ?? record.content, + record.timestamp + ) + } +} + +export function createCursorSessionResumeState(file: FileWithMtime): ResumableSessionParseState { + return accumulatorFoldResumeState( + createAccumulator({ agent: 'cursor', file, sessionId: sessionIdFromFileName(file.path) }), + consumeCursorRecordLine + ) +} + +async function parseCursorSessionLines(args: { + file: FileWithMtime + lines: AsyncIterable | Iterable + platform: NodeJS.Platform + options?: ParserSessionOptions +}): Promise { + const state = createCursorSessionResumeState(args.file) + for await (const line of args.lines) { + state.consumeLine(line) + } + return state.finalize(args.platform, args.options) +} 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 index 47235cbce12..6400b5b6488 100644 --- a/src/main/ai-vault/session-scanner-first-user-prompt.test.ts +++ b/src/main/ai-vault/session-scanner-first-user-prompt.test.ts @@ -16,6 +16,21 @@ describe('AI Vault full first-user-prompt normalization', () => { it('keeps a prompt shorter than the safety limit intact', () => { expect(normalizeFullFirstUserPromptText('ship it 😀')).toBe('ship it 😀') }) + + // Why this matters: the bootstrap probe runs on the bounded slice. That is only + // safe because the envelope strip is unbounded, so a query past the cap is still + // unwrapped and never reaches the probe as a bare user_info dump. + it('unwraps a user_query that lands past the safety limit', () => { + const raw = `\n${'context '.repeat(SAFETY_LIMIT / 4)}\nship it` + + expect(normalizeFullFirstUserPromptText(raw)).toBe('ship it') + }) + + it('still rejects a user_info dump with no user_query anywhere', () => { + const raw = `\n${'context '.repeat(SAFETY_LIMIT / 4)}` + + expect(normalizeFullFirstUserPromptText(raw)).toBeNull() + }) }) function hasUnpairedSurrogate(value: string): boolean { diff --git a/src/main/ai-vault/session-scanner-first-user-prompt.ts b/src/main/ai-vault/session-scanner-first-user-prompt.ts index f7ec1c72299..8751817c577 100644 --- a/src/main/ai-vault/session-scanner-first-user-prompt.ts +++ b/src/main/ai-vault/session-scanner-first-user-prompt.ts @@ -71,6 +71,8 @@ function finalizeFullFirstUserPrompt(value: string): string | null { // Bound before scanning so a multi-MB paste cannot force a full lowercase copy. const bounded = sliceAtCodeUnitLimit(trimmed, FULL_FIRST_USER_PROMPT_SAFETY_LIMIT) // Reject pure Grok bootstrap dumps even when they arrived via a non-Grok path. + // Safe on the bounded slice: stripGrokUserQueryEnvelope above already unwrapped + // any , wherever it sat, so a match here means there was none. const lower = bounded.toLowerCase() if (lower.startsWith('') && !lower.includes('')) { return null diff --git a/src/main/ai-vault/session-scanner-hermes-parser.ts b/src/main/ai-vault/session-scanner-hermes-parser.ts new file mode 100644 index 00000000000..be844cc6627 --- /dev/null +++ b/src/main/ai-vault/session-scanner-hermes-parser.ts @@ -0,0 +1,66 @@ +import { readFile } from 'node:fs/promises' +import type { AiVaultSession } from '../../shared/ai-vault-types' +import type { ExecutionHostId } from '../../shared/execution-host' +import type { FileWithMtime } from './session-scanner-types' +import { + addPreviewContent, + createAccumulator, + finalizeSession, + sessionIdFromFileName, + updateTimeline +} from './session-scanner-accumulator' +import { + arrayValue, + asRecord, + extractContentText, + extractString, + numberValue +} from './session-scanner-values' + +type ParserSessionOptions = { + executionHostId?: ExecutionHostId + executionHostPlatform?: NodeJS.Platform | null +} + +export async function parseHermesSessionFile( + file: FileWithMtime, + platform: NodeJS.Platform = process.platform +): Promise { + return parseHermesSessionContent(file, await readFile(file.path, 'utf-8'), platform) +} + +export async function parseHermesSessionContent( + file: FileWithMtime, + content: string, + platform: NodeJS.Platform = process.platform, + options: ParserSessionOptions = {} +): Promise { + const record = asRecord(JSON.parse(content) as unknown) + if (!record) { + return null + } + const accumulator = createAccumulator({ + agent: 'hermes', + file, + sessionId: extractString(record.session_id) ?? sessionIdFromFileName(file.path) + }) + accumulator.model = extractString(record.model) + accumulator.cwd = extractString(record.cwd) + updateTimeline(accumulator, extractString(record.session_start)) + updateTimeline(accumulator, extractString(record.last_updated)) + for (const message of arrayValue(record.messages)) { + const messageRecord = asRecord(message) + const role = extractString(messageRecord?.role) + if (role === 'user' || role === 'assistant') { + accumulator.messageCount++ + if (role === 'user') { + accumulator.title ??= extractContentText(messageRecord?.content) + } + addPreviewContent(accumulator, role, messageRecord?.content) + } + } + if (accumulator.messageCount === 0) { + accumulator.messageCount = numberValue(record.message_count) + } + return finalizeSession(accumulator, platform, options) +} diff --git a/src/main/ai-vault/session-scanner-opencode-parser.ts b/src/main/ai-vault/session-scanner-opencode-parser.ts new file mode 100644 index 00000000000..c3a0cea6e22 --- /dev/null +++ b/src/main/ai-vault/session-scanner-opencode-parser.ts @@ -0,0 +1,98 @@ +import { readFile, readdir } from 'node:fs/promises' +import { join } from 'node:path' +import type { AiVaultSession } from '../../shared/ai-vault-types' +import type { FileWithMtime, SessionAccumulator } from './session-scanner-types' +import { + addPreviewMessage, + createAccumulator, + finalizeSession, + seedFullFirstUserPrompt, + sessionIdFromFileName, + updateTimeline +} from './session-scanner-accumulator' +import { extractFullFirstUserPromptText } from './session-scanner-first-user-prompt' +import { + asRecord, + extractPreviewContentText, + extractString, + findOpenCodeStorageRoot, + normalizeTitleText, + timeObjectValue, + tokenTotal +} from './session-scanner-values' + +export async function parseOpenCodeSessionFile( + file: FileWithMtime, + platform: NodeJS.Platform = process.platform +): Promise { + const record = asRecord(JSON.parse(await readFile(file.path, 'utf-8')) as unknown) + if (!record) { + return null + } + const sessionId = extractString(record.id) ?? sessionIdFromFileName(file.path) + const accumulator = createAccumulator({ agent: 'opencode', file, sessionId }) + accumulator.title = normalizeTitleText(extractString(record.title) ?? '') + accumulator.cwd = extractString(record.directory) + updateTimeline(accumulator, timeObjectValue(record.time, 'created')) + updateTimeline(accumulator, timeObjectValue(record.time, 'updated')) + await consumeOpenCodeMessages(accumulator, findOpenCodeStorageRoot(file.path), sessionId) + return finalizeSession(accumulator, platform) +} + +export async function consumeOpenCodeMessages( + accumulator: SessionAccumulator, + storageRoot: string | null, + sessionId: string +): Promise { + if (!storageRoot) { + return + } + const messageDir = join(storageRoot, 'message', sessionId) + let entries + try { + entries = await readdir(messageDir, { withFileTypes: true }) + } catch { + return + } + for (const entry of entries) { + if (!entry.isFile() || !entry.name.endsWith('.json')) { + continue + } + const message = asRecord( + JSON.parse(await readFile(join(messageDir, entry.name), 'utf-8')) as unknown + ) + if (!message) { + continue + } + const role = extractString(message.role) + if (role === 'user' || role === 'assistant') { + accumulator.messageCount++ + updateTimeline(accumulator, timeObjectValue(message.time, 'created')) + const summary = asRecord(message.summary) + if (role === 'user') { + accumulator.title ??= extractString(summary?.title) ?? extractString(summary?.body) + } + // Why: the summary fallbacks are AI-generated. Seed the copyable first + // prompt from real typed content only, never from a summary of a + // harness-injected turn. Extraction stays inside the thunk so list scans + // (capture mode `none`) never pay for it. + seedFullFirstUserPrompt(accumulator, role, () => + extractFullFirstUserPromptText(message.content) + ) + addPreviewMessage(accumulator, { + role, + text: + extractPreviewContentText(message.content) ?? + extractString(summary?.body) ?? + extractString(summary?.title), + timestamp: timeObjectValue(message.time, 'created'), + seedFirstUserPrompt: false + }) + accumulator.model = + extractString(asRecord(message.model)?.modelID) || + extractString(message.modelID) || + accumulator.model + accumulator.totalTokens += tokenTotal(message.tokens) + } + } +} diff --git a/src/main/ai-vault/session-scanner-parse-cache.ts b/src/main/ai-vault/session-scanner-parse-cache.ts index b3e4448a410..572002197e0 100644 --- a/src/main/ai-vault/session-scanner-parse-cache.ts +++ b/src/main/ai-vault/session-scanner-parse-cache.ts @@ -8,10 +8,8 @@ import { createDroidSessionResumeState } from './session-scanner-droid-parser' import { createMessageGraphSessionResumeState } from './session-scanner-graph-parsers' import { createClaudeSessionResumeState } from './session-scanner-primary-parsers' import { createGeminiJsonlSessionResumeState } from './session-scanner-gemini-parsers' -import { - createCopilotSessionResumeState, - createCursorSessionResumeState -} from './session-scanner-secondary-parsers' +import { createCopilotSessionResumeState } from './session-scanner-copilot-parser' +import { createCursorSessionResumeState } from './session-scanner-cursor-parser' import { countSubagentTranscripts } from './session-scanner-subagent-transcripts' import type { ResumableSessionParseState, SessionFileCandidate } from './session-scanner-types' diff --git a/src/main/ai-vault/session-scanner-secondary-parsers.ts b/src/main/ai-vault/session-scanner-secondary-parsers.ts deleted file mode 100644 index 0abcdfb5052..00000000000 --- a/src/main/ai-vault/session-scanner-secondary-parsers.ts +++ /dev/null @@ -1,320 +0,0 @@ -import { createReadStream } from 'node:fs' -import { readFile, readdir } from 'node:fs/promises' -import { join } from 'node:path' -import { createInterface } from 'node:readline' -import type { AiVaultSession } from '../../shared/ai-vault-types' -import type { ExecutionHostId } from '../../shared/execution-host' -import type { - FileWithMtime, - ResumableSessionParseState, - SessionAccumulator -} from './session-scanner-types' -import { - accumulatorFoldResumeState, - addPreviewContent, - addPreviewMessage, - createAccumulator, - finalizeSession, - seedFullFirstUserPrompt, - sessionIdFromFileName, - updateTimeline -} from './session-scanner-accumulator' -import { extractFullFirstUserPromptText } from './session-scanner-first-user-prompt' -import { - arrayValue, - asRecord, - copilotModelMetricsTotal, - extractContentText, - extractMessageText, - extractString, - extractTrustedFolder, - findOpenCodeStorageRoot, - normalizeTitleText, - numberValue, - parseJsonObject, - timeObjectValue, - tokenTotal -} from './session-scanner-values' - -type ParserSessionOptions = { - executionHostId?: ExecutionHostId - executionHostPlatform?: NodeJS.Platform | null -} - -export async function parseCopilotSessionFile( - file: FileWithMtime, - platform: NodeJS.Platform = process.platform -): Promise { - const lines = createInterface({ - input: createReadStream(file.path, { encoding: 'utf-8' }), - crlfDelay: Infinity - }) - return parseCopilotSessionLines({ file, lines, platform }) -} - -export async function parseCopilotSessionContent( - file: FileWithMtime, - content: string, - platform: NodeJS.Platform = process.platform, - options: ParserSessionOptions = {} -): Promise { - return parseCopilotSessionLines({ - file, - lines: content.split(/\r?\n/), - platform, - options - }) -} - -function consumeCopilotRecordLine(accumulator: SessionAccumulator, line: string): void { - const record = parseJsonObject(line) - if (!record) { - return - } - updateTimeline(accumulator, extractString(record.timestamp)) - const data = asRecord(record.data) - if (record.type === 'session.start' && data) { - const sessionId = extractString(data.sessionId) - if (sessionId) { - accumulator.sessionId = sessionId - } - updateTimeline(accumulator, extractString(data.startTime)) - return - } - if (record.type === 'session.model_change' && data) { - accumulator.model = extractString(data.newModel) ?? accumulator.model - return - } - if (record.type === 'session.info' && data) { - accumulator.cwd = extractTrustedFolder(data.message) ?? accumulator.cwd - return - } - if (record.type === 'user.message' && data) { - accumulator.messageCount++ - accumulator.title ??= normalizeTitleText( - extractString(data.transformedContent) ?? extractString(data.content) ?? '' - ) - addPreviewMessage(accumulator, { - role: 'user', - text: extractString(data.transformedContent) ?? extractString(data.content), - timestamp: record.timestamp - }) - return - } - if (record.type === 'assistant.message' && data) { - accumulator.messageCount++ - addPreviewMessage(accumulator, { - role: 'assistant', - text: extractString(data.content), - timestamp: record.timestamp - }) - return - } - if (record.type === 'session.shutdown' && data) { - accumulator.model = extractString(data.currentModel) ?? accumulator.model - accumulator.totalTokens += numberValue(data.currentTokens) - accumulator.totalTokens += copilotModelMetricsTotal(data.modelMetrics) - } -} - -export function createCopilotSessionResumeState(file: FileWithMtime): ResumableSessionParseState { - return accumulatorFoldResumeState( - createAccumulator({ agent: 'copilot', file, sessionId: sessionIdFromFileName(file.path) }), - consumeCopilotRecordLine - ) -} - -async function parseCopilotSessionLines(args: { - file: FileWithMtime - lines: AsyncIterable | Iterable - platform: NodeJS.Platform - options?: ParserSessionOptions -}): Promise { - const state = createCopilotSessionResumeState(args.file) - for await (const line of args.lines) { - state.consumeLine(line) - } - return state.finalize(args.platform, args.options) -} - -export async function parseCursorSessionFile( - file: FileWithMtime, - platform: NodeJS.Platform = process.platform -): Promise { - const lines = createInterface({ - input: createReadStream(file.path, { encoding: 'utf-8' }), - crlfDelay: Infinity - }) - return parseCursorSessionLines({ file, lines, platform }) -} - -export async function parseCursorSessionContent( - file: FileWithMtime, - content: string, - platform: NodeJS.Platform = process.platform, - options: ParserSessionOptions = {} -): Promise { - return parseCursorSessionLines({ - file, - lines: content.split(/\r?\n/), - platform, - options - }) -} - -function consumeCursorRecordLine(accumulator: SessionAccumulator, line: string): void { - const record = parseJsonObject(line) - if (!record) { - return - } - updateTimeline(accumulator, extractString(record.timestamp)) - const role = extractString(record.role) - if (role === 'user' || role === 'assistant') { - accumulator.messageCount++ - if (role === 'user') { - accumulator.title ??= extractMessageText(record.message) ?? extractContentText(record.content) - } - addPreviewContent( - accumulator, - role, - asRecord(record.message)?.content ?? record.content, - record.timestamp - ) - } -} - -export function createCursorSessionResumeState(file: FileWithMtime): ResumableSessionParseState { - return accumulatorFoldResumeState( - createAccumulator({ agent: 'cursor', file, sessionId: sessionIdFromFileName(file.path) }), - consumeCursorRecordLine - ) -} - -async function parseCursorSessionLines(args: { - file: FileWithMtime - lines: AsyncIterable | Iterable - platform: NodeJS.Platform - options?: ParserSessionOptions -}): Promise { - const state = createCursorSessionResumeState(args.file) - for await (const line of args.lines) { - state.consumeLine(line) - } - return state.finalize(args.platform, args.options) -} - -export async function parseOpenCodeSessionFile( - file: FileWithMtime, - platform: NodeJS.Platform = process.platform -): Promise { - const record = asRecord(JSON.parse(await readFile(file.path, 'utf-8')) as unknown) - if (!record) { - return null - } - const sessionId = extractString(record.id) ?? sessionIdFromFileName(file.path) - const accumulator = createAccumulator({ agent: 'opencode', file, sessionId }) - accumulator.title = normalizeTitleText(extractString(record.title) ?? '') - accumulator.cwd = extractString(record.directory) - updateTimeline(accumulator, timeObjectValue(record.time, 'created')) - updateTimeline(accumulator, timeObjectValue(record.time, 'updated')) - await consumeOpenCodeMessages(accumulator, findOpenCodeStorageRoot(file.path), sessionId) - return finalizeSession(accumulator, platform) -} - -export async function consumeOpenCodeMessages( - accumulator: SessionAccumulator, - storageRoot: string | null, - sessionId: string -): Promise { - if (!storageRoot) { - return - } - const messageDir = join(storageRoot, 'message', sessionId) - let entries - try { - entries = await readdir(messageDir, { withFileTypes: true }) - } catch { - return - } - for (const entry of entries) { - if (!entry.isFile() || !entry.name.endsWith('.json')) { - continue - } - const message = asRecord( - JSON.parse(await readFile(join(messageDir, entry.name), 'utf-8')) as unknown - ) - if (!message) { - continue - } - const role = extractString(message.role) - if (role === 'user' || role === 'assistant') { - accumulator.messageCount++ - updateTimeline(accumulator, timeObjectValue(message.time, 'created')) - const summary = asRecord(message.summary) - if (role === 'user') { - accumulator.title ??= extractString(summary?.title) ?? extractString(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). - const contentText = extractFullFirstUserPromptText(message.content) - // Why: the summary fallbacks are AI-generated. Seed the copyable first - // prompt from real typed content only, never from a summary of a - // harness-injected turn. - seedFullFirstUserPrompt(accumulator, role, () => contentText) - addPreviewMessage(accumulator, { - role, - text: contentText ?? extractString(summary?.body) ?? extractString(summary?.title), - timestamp: timeObjectValue(message.time, 'created'), - seedFirstUserPrompt: false - }) - accumulator.model = - extractString(asRecord(message.model)?.modelID) || - extractString(message.modelID) || - accumulator.model - accumulator.totalTokens += tokenTotal(message.tokens) - } - } -} - -export async function parseHermesSessionFile( - file: FileWithMtime, - platform: NodeJS.Platform = process.platform -): Promise { - return parseHermesSessionContent(file, await readFile(file.path, 'utf-8'), platform) -} - -export async function parseHermesSessionContent( - file: FileWithMtime, - content: string, - platform: NodeJS.Platform = process.platform, - options: ParserSessionOptions = {} -): Promise { - const record = asRecord(JSON.parse(content) as unknown) - if (!record) { - return null - } - const accumulator = createAccumulator({ - agent: 'hermes', - file, - sessionId: extractString(record.session_id) ?? sessionIdFromFileName(file.path) - }) - accumulator.model = extractString(record.model) - accumulator.cwd = extractString(record.cwd) - updateTimeline(accumulator, extractString(record.session_start)) - updateTimeline(accumulator, extractString(record.last_updated)) - for (const message of arrayValue(record.messages)) { - const messageRecord = asRecord(message) - const role = extractString(messageRecord?.role) - if (role === 'user' || role === 'assistant') { - accumulator.messageCount++ - if (role === 'user') { - accumulator.title ??= extractContentText(messageRecord?.content) - } - addPreviewContent(accumulator, role, messageRecord?.content) - } - } - if (accumulator.messageCount === 0) { - accumulator.messageCount = numberValue(record.message_count) - } - return finalizeSession(accumulator, platform, options) -}