refactor(ai-vault): split session parsers into modular files

Split secondary-parsers into individual files per agent type (copilot,
cursor, hermes, opencode) for improved modularity. Add test coverage
for first-user-prompt envelope handling: unwrap user_query tags and
reject bare user_info dumps.
This commit is contained in:
Jinjing
2026-08-02 16:31:25 -07:00
parent fe8148fe4f
commit 6cff16951d
10 changed files with 410 additions and 335 deletions
@@ -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'
@@ -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'
/**
@@ -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<AiVaultSession | null> {
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<AiVaultSession | null> {
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<string> | Iterable<string>
platform: NodeJS.Platform
options?: ParserSessionOptions
}): Promise<AiVaultSession | null> {
const state = createCopilotSessionResumeState(args.file)
for await (const line of args.lines) {
state.consumeLine(line)
}
return state.finalize(args.platform, args.options)
}
@@ -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<AiVaultSession | null> {
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<AiVaultSession | null> {
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<string> | Iterable<string>
platform: NodeJS.Platform
options?: ParserSessionOptions
}): Promise<AiVaultSession | null> {
const state = createCursorSessionResumeState(args.file)
for await (const line of args.lines) {
state.consumeLine(line)
}
return state.finalize(args.platform, args.options)
}
@@ -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 = `<user_info>\n${'context '.repeat(SAFETY_LIMIT / 4)}\n<user_query>ship it</user_query>`
expect(normalizeFullFirstUserPromptText(raw)).toBe('ship it')
})
it('still rejects a user_info dump with no user_query anywhere', () => {
const raw = `<user_info>\n${'context '.repeat(SAFETY_LIMIT / 4)}`
expect(normalizeFullFirstUserPromptText(raw)).toBeNull()
})
})
function hasUnpairedSurrogate(value: string): boolean {
@@ -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 <user_query>, wherever it sat, so a match here means there was none.
const lower = bounded.toLowerCase()
if (lower.startsWith('<user_info>') && !lower.includes('<user_query>')) {
return null
@@ -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<AiVaultSession | null> {
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<AiVaultSession | null> {
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)
}
@@ -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<AiVaultSession | null> {
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<void> {
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)
}
}
}
@@ -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'
@@ -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<AiVaultSession | null> {
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<AiVaultSession | null> {
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<string> | Iterable<string>
platform: NodeJS.Platform
options?: ParserSessionOptions
}): Promise<AiVaultSession | null> {
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<AiVaultSession | null> {
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<AiVaultSession | null> {
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<string> | Iterable<string>
platform: NodeJS.Platform
options?: ParserSessionOptions
}): Promise<AiVaultSession | null> {
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<AiVaultSession | null> {
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<void> {
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<AiVaultSession | null> {
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<AiVaultSession | null> {
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)
}