mirror of
https://github.com/stablyai/orca.git
synced 2026-10-20 16:03:20 +00:00
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:
@@ -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)
|
||||
}
|
||||
Reference in New Issue
Block a user