Skip to content
2 changes: 2 additions & 0 deletions config/tsconfig.cli.json
Original file line number Diff line number Diff line change
Expand Up @@ -10,10 +10,12 @@
"../src/main/antigravity/hook-service.ts",
"../src/main/claude/hook-settings.ts",
"../src/main/claude/hook-service.ts",
"../src/main/codex/codex-config-hooks-feature-normalize.ts",
"../src/main/codex/codex-config-mirror.ts",
"../src/main/codex/codex-config-path-reference-rewrite.ts",
"../src/main/codex/codex-home-paths.ts",
"../src/main/codex/codex-hook-identity.ts",
"../src/main/codex/codex-profile-v2-config-overlay-mirror.ts",
"../src/main/codex/codex-wsl-hook-install-plan.ts",
"../src/main/codex/config-settings-promotion.ts",
"../src/main/codex/config-toml-line-scan.ts",
Expand Down
53 changes: 53 additions & 0 deletions src/main/ai-vault/session-scanner-codex-item-completed.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
// AI Vault handling for Codex Paginated-history `item_completed` TurnItems.
// Kept separate from the main line fold so session-scanner-codex-parser stays
// under the max-lines budget while still counting messages for previews.

import { addPreviewContent } from './session-scanner-accumulator'
import type { SessionAccumulator } from './session-scanner-types'
import { asRecord, extractContentText, extractString } from './session-scanner-values'

export type CodexItemCompletedState = {
accumulator: SessionAccumulator
titleSource: 'meta' | 'user' | null
}

export function consumeCodexItemCompleted(
state: CodexItemCompletedState,
payload: Record<string, unknown>,
timestamp: unknown
): void {
const item = asRecord(payload.item)
if (!item) {
return
}
const itemType = normalizeCodexTurnItemType(item.type)
const { accumulator } = state

if (itemType === 'user_message') {
const text = extractContentText(item.content)
accumulator.messageCount++
if (!accumulator.title && text) {
accumulator.title = text
state.titleSource = 'user'
}
addPreviewContent(accumulator, 'user', item.content, timestamp)
return
}

if (itemType === 'agent_message') {
accumulator.messageCount++
addPreviewContent(accumulator, 'assistant', item.content, timestamp)
}
}

/** Normalize TurnItem wire tags (snake_case or PascalCase) to snake_case. */
function normalizeCodexTurnItemType(value: unknown): string | null {
const raw = extractString(value)
if (!raw) {
return null
}
return raw
.replace(/([a-z0-9])([A-Z])/g, '$1_$2')
.replace(/([A-Z]+)([A-Z][a-z])/g, '$1_$2')
.toLowerCase()
}
112 changes: 111 additions & 1 deletion src/main/ai-vault/session-scanner-codex-parser.test.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,9 @@
import { mkdir, mkdtemp, rm, stat, writeFile } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { dirname, join } from 'node:path'
import { zstdCompressSync } from 'node:zlib'
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 +17,115 @@ function jsonLines(records: unknown[]): string {
}

describe('parseCodexSessionFile', () => {
it('parses Paginated item_completed TurnItems 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',
thread_id: 'paginated-session',
turn_id: 'turn-1',
completed_at_ms: 1,
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',
thread_id: 'paginated-session',
turn_id: 'turn-1',
completed_at_ms: 2,
item: {
type: 'agent_message',
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('reads cold-compressed .jsonl.zst rollouts', async () => {
const root = await mkdtemp(join(tmpdir(), 'orca-ai-vault-codex-zst-'))
tempRoots.push(root)
const sessionId = '019f0000-1111-7222-8333-444444444444'
const sessionPath = join(
root,
'sessions',
'2026',
'06',
'18',
`rollout-2026-06-18T10-00-00-${sessionId}.jsonl.zst`
)
await mkdir(dirname(sessionPath), { recursive: true })
const plain = jsonLines([
{
timestamp: '2026-06-18T10:00:00.000Z',
type: 'session_meta',
payload: { id: sessionId, cwd: '/repo/app' }
},
{
timestamp: '2026-06-18T10:00:01.000Z',
type: 'event_msg',
payload: {
type: 'item_completed',
item: {
type: 'user_message',
id: 'u1',
content: [{ type: 'text', text: 'Compressed rollout prompt' }]
}
}
}
])
await writeFile(sessionPath, zstdCompressSync(Buffer.from(plain, 'utf-8')))

const sessionStat = await stat(sessionPath)
const session = await parseCodexSessionFile(
{
path: sessionPath,
mtimeMs: sessionStat.mtimeMs,
modifiedAt: sessionStat.mtime.toISOString()
},
'darwin',
root
)

expect(session?.sessionId).toBe(sessionId)
expect(session?.title).toBe('Compressed rollout prompt')
expect(session?.messageCount).toBe(1)
})

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
Loading