Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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()]
}
52 changes: 51 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,56 @@ 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: '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: '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
213 changes: 28 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,22 +59,12 @@ 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)
}),
previousTotals: null,
rejectedWorkerSession: false,
Expand All @@ -96,6 +73,12 @@ function createCodexParseState(file: FileWithMtime): CodexSessionParseState {
}
}

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 +87,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 +168,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