Skip to content
Closed
40 changes: 40 additions & 0 deletions src/main/ai-vault/session-scanner-codex-discovery.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
import type { AiVaultScanIssue } from '../../shared/ai-vault-types'
import {
CODEX_SESSION_ROLLOUT_EXTENSIONS,
isCodexSessionRolloutPath
} from './session-scanner-codex-paths'
import { discoverFiles } from './session-scanner-discovery'
import type { SessionFileDiscovery } from './session-scanner-types'

export function codexSessionDiscoveries(
rootDirs: readonly string[],
limit: number,
issues: AiVaultScanIssue[]
): Promise<SessionFileDiscovery>[] {
return rootDirs.map((rootDir) =>
discoverFiles({
rootDir,
limit,
agent: 'codex',
issues,
extensions: [...CODEX_SESSION_ROLLOUT_EXTENSIONS],
// Why: `.zst` alone is too broad; only Codex `.jsonl.zst` rollouts qualify.
filePredicate: isCodexSessionRolloutPath,
// Why: sibling duplicates must not consume the recency limit, and the
// writable plain rollout remains authoritative even when zstd is newer.
candidatePathSelector: preferPlainCodexRolloutSiblings
})
)
}

function preferPlainCodexRolloutSiblings(paths: readonly string[]): string[] {
const byPlainPath = new Map<string, string>()
for (const path of paths) {
const plainPath = path.endsWith('.jsonl.zst') ? path.slice(0, -'.zst'.length) : path
const existing = byPlainPath.get(plainPath)
if (!existing || path.endsWith('.jsonl')) {
byPlainPath.set(plainPath, path)
}
}
return [...byPlainPath.values()]
}
70 changes: 69 additions & 1 deletion src/main/ai-vault/session-scanner-codex-parser.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import { mkdir, mkdtemp, rm, stat, writeFile } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { dirname, join } from 'node:path'
import { afterEach, describe, expect, it } from 'vitest'
import { parseCodexSessionFile } from './session-scanner-codex-parser'
import { parseCodexSessionContent, parseCodexSessionFile } from './session-scanner-codex-parser'

let tempRoots: string[] = []

Expand All @@ -16,6 +16,74 @@ function jsonLines(records: unknown[]): string {
}

describe('parseCodexSessionFile', () => {
it('parses paginated item_completed turn items for preview and title', async () => {
const session = await parseCodexSessionContent({
file: {
path: '/tmp/rollout-paginated.jsonl',
mtimeMs: 1,
modifiedAt: '2026-06-18T10:00:00.000Z'
},
content: jsonLines([
{
timestamp: '2026-06-18T10:00:00.000Z',
type: 'session_meta',
payload: {
id: 'paginated-session',
cwd: '/repo/app',
history_mode: 'paginated'
}
},
{
timestamp: '2026-06-18T10:00:01.000Z',
type: 'response_item',
payload: {
type: 'message',
role: 'user',
content: [{ type: 'text', text: 'Paginated user prompt' }]
}
},
{
timestamp: '2026-06-18T10:00:01.000Z',
type: 'event_msg',
payload: {
type: 'item_completed',
item: {
type: 'user_message',
id: 'item-user-1',
content: [{ type: 'text', text: 'Paginated user prompt' }]
}
}
},
{
timestamp: '2026-06-18T10:00:02.000Z',
type: 'response_item',
payload: {
type: 'message',
role: 'assistant',
content: [{ type: 'text', text: 'Paginated assistant reply' }]
}
},
{
timestamp: '2026-06-18T10:00:02.000Z',
type: 'event_msg',
payload: {
type: 'item_completed',
item: {
type: 'AgentMessage',
id: 'item-agent-1',
content: [{ type: 'text', text: 'Paginated assistant reply' }]
}
}
}
])
})

expect(session?.sessionId).toBe('paginated-session')
expect(session?.title).toBe('Paginated user prompt')
expect(session?.messageCount).toBe(2)
expect(session?.previewMessages?.map((message) => message.role)).toEqual(['user', 'assistant'])
})

it('does not double-count usage when token count formats switch', async () => {
const root = await mkdtemp(join(tmpdir(), 'orca-ai-vault-codex-token-switch-'))
tempRoots.push(root)
Expand Down
214 changes: 29 additions & 185 deletions src/main/ai-vault/session-scanner-codex-parser.ts
Original file line number Diff line number Diff line change
@@ -1,55 +1,42 @@
import { createReadStream } from 'node:fs'
import { createInterface } from 'node:readline'
import type { AiVaultSession } from '../../shared/ai-vault-types'
import { readCodexSessionIndexTitle } from './session-scanner-codex-title-index'
import type { ExecutionHostId } from '../../shared/execution-host'
import {
addPreviewContent,
cloneSessionAccumulator,
createAccumulator,
finalizeSession,
sessionIdFromFileName,
updateTimeline
finalizeSession
} from './session-scanner-accumulator'
import { codexRolloutBaseName } from './session-scanner-codex-paths'
import {
consumeCodexRecordLine,
type CodexSessionParseState
} from './session-scanner-codex-record-consume'
import { iterateCodexRolloutLines } from './session-scanner-codex-rollout-read'
import type {
CodexUsageSnapshot,
FileWithMtime,
ResumableParseFinalizeOptions,
ResumableSessionParseState,
SessionAccumulator
ResumableSessionParseState
} from './session-scanner-types'
import {
addCodexUsage,
asRecord,
extractContentText,
extractGitBranch,
extractModel,
extractString,
normalizeCodexUsage,
normalizeTitleText,
parseJsonObject,
subtractCodexUsage
} from './session-scanner-values'

export async function parseCodexSessionFile(
file: FileWithMtime,
platform: NodeJS.Platform = process.platform,
codexHome: string | null = null,
executionHostId?: ExecutionHostId
): Promise<AiVaultSession | null> {
const lines = createInterface({
input: createReadStream(file.path, { encoding: 'utf-8' }),
crlfDelay: Infinity
})

return parseCodexSessionLines({
file,
lines,
platform,
codexHome,
executionHostId,
titleReader: (sessionId) => readCodexSessionIndexTitle(file.path, codexHome, sessionId)
})
const lines = iterateCodexRolloutLines(file.path)
try {
return await parseCodexSessionLines({
file,
lines,
platform,
codexHome,
executionHostId,
titleReader: (sessionId) => readCodexSessionIndexTitle(file.path, codexHome, sessionId)
})
} finally {
lines.close()
}
}

export async function parseCodexSessionContent(args: {
Expand All @@ -72,30 +59,27 @@ export async function parseCodexSessionContent(args: {
})
}

type CodexSessionParseState = {
accumulator: SessionAccumulator
previousTotals: CodexUsageSnapshot | null
rejectedWorkerSession: boolean
sawSessionMeta: boolean
// Which source set the current title; an index-file title outranks the raw
// first user prompt, so finalize must know whether 'meta' already won.
titleSource: 'meta' | 'user' | null
}

function createCodexParseState(file: FileWithMtime): CodexSessionParseState {
return {
accumulator: createAccumulator({
agent: 'codex',
file,
sessionId: sessionIdFromFileName(file.path)
sessionId: sessionIdFromCodexRolloutPath(file.path)
}),
historyMode: null,
previousTotals: null,
rejectedWorkerSession: false,
sawSessionMeta: false,
titleSource: null
}
}

function sessionIdFromCodexRolloutPath(filePath: string): string {
const baseName = codexRolloutBaseName(filePath)
const match = baseName.match(/[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i)
return match?.[0] ?? baseName
}

function cloneCodexParseState(state: CodexSessionParseState): CodexSessionParseState {
return {
// previousTotals snapshots are replaced, never mutated, so sharing is safe.
Expand All @@ -104,124 +88,6 @@ function cloneCodexParseState(state: CodexSessionParseState): CodexSessionParseS
}
}

function consumeCodexRecordLine(state: CodexSessionParseState, line: string): void {
if (state.rejectedWorkerSession) {
return
}
const record = parseJsonObject(line)
if (!record) {
return
}
const { accumulator } = state

updateTimeline(accumulator, extractString(record.timestamp))

const payload = asRecord(record.payload)
if (record.type === 'session_meta' && payload) {
if (isCodexWorkerSession(payload)) {
// Why: Codex writes internal worker/sub-agent transcripts into the same
// history tree; AI Vault should show user-started sessions only.
state.rejectedWorkerSession = true
return
}
state.sawSessionMeta = true
const sessionId = extractString(payload.id)
if (sessionId) {
accumulator.sessionId = sessionId
}
const metadataTitle = extractCodexSessionMetadataTitle(payload)
if (metadataTitle) {
accumulator.title = metadataTitle
state.titleSource = 'meta'
}
const cwd = extractString(payload.cwd)
if (cwd) {
accumulator.cwd = cwd
}
accumulator.branch = extractGitBranch(payload.git) ?? accumulator.branch
return
}

if (record.type === 'turn_context' && payload) {
const cwd = extractString(payload.cwd)
if (cwd) {
accumulator.cwd = cwd
}
const model = extractModel(payload)
if (model) {
accumulator.model = model
}
return
}

if (!payload) {
return
}

if (record.type === 'response_item' && payload.type === 'message') {
accumulator.messageCount++
if (payload.role === 'user' && !accumulator.title) {
accumulator.title = extractContentText(payload.content)
state.titleSource = accumulator.title ? 'user' : state.titleSource
}
addPreviewContent(
accumulator,
payload.role === 'assistant' ? 'assistant' : payload.role === 'user' ? 'user' : 'unknown',
payload.content,
record.timestamp
)
return
}

if (record.type !== 'event_msg') {
return
}

if (payload.type === 'user_message') {
accumulator.messageCount++
if (!accumulator.title) {
accumulator.title = extractContentText(payload.message)
state.titleSource = accumulator.title ? 'user' : state.titleSource
}
addPreviewContent(accumulator, 'user', payload.message, record.timestamp)
return
}

if (payload.type === 'agent_message') {
accumulator.messageCount++
addPreviewContent(accumulator, 'assistant', payload.message, record.timestamp)
return
}

if (payload.type !== 'token_count') {
return
}

const info = asRecord(payload.info)
if (!info) {
return
}
const totalUsage = normalizeCodexUsage(info.total_token_usage)
const lastUsage = normalizeCodexUsage(info.last_token_usage)
let delta: CodexUsageSnapshot | null = null
if (totalUsage) {
delta = subtractCodexUsage(totalUsage, state.previousTotals)
state.previousTotals = totalUsage
} else if (lastUsage) {
delta = lastUsage
state.previousTotals = state.previousTotals
? addCodexUsage(state.previousTotals, lastUsage)
: lastUsage
}
if (delta) {
accumulator.totalTokens += delta.totalTokens
}
const model = extractModel(payload)
if (model) {
accumulator.model = model
}
}

async function finalizeCodexParseState(
state: CodexSessionParseState,
platform: NodeJS.Platform,
Expand Down Expand Up @@ -303,25 +169,3 @@ async function parseCodexSessionLines(args: {
executionHostPlatform: args.executionHostPlatform
})
}

function extractCodexThreadSource(payload: Record<string, unknown>): string | null {
return extractString(payload.thread_source) ?? extractString(payload.threadSource)
}

function isCodexWorkerSession(payload: Record<string, unknown>): boolean {
const threadSource = extractCodexThreadSource(payload)
if (threadSource) {
return threadSource.toLowerCase() !== 'user'
}

const source = asRecord(payload.source)
return Boolean(asRecord(source?.subagent))
}

function extractCodexSessionMetadataTitle(payload: Record<string, unknown>): string | null {
return (
normalizeTitleText(extractString(payload.title) ?? '') ??
normalizeTitleText(extractString(payload.thread_name) ?? '') ??
normalizeTitleText(extractString(payload.threadName) ?? '')
)
}
Loading