Files
Codeman/src/local-history.ts
T
Aamer Akhter 17b2edce22 fix(history): count compressed rollouts, read each file once, degrade an unreadable parent
Codex's local_thread_store_compression rewrites cold rollouts to
*.jsonl.zst, which the store scan skipped without a trace. Each one is now
counted in compressedFiles (complete: false), skipped only when its plain
.jsonl sibling exists, as Codex's own discovery does, and a lookup that
misses names the compressed file whose name carries the wanted id. A
Zstandard file given to --file, or sitting in the store under a .jsonl
name, is recognised by its magic number.

Each selected rollout is parsed once into every view later needed of it,
so a legacy copy's boundary proof reuses the parent's own read instead of
charging it to --scan-bytes a second time (and counting its malformed or
inherited records twice).

A parent read that fails for any reason other than a budget now leaves
the boundary unproven (cut at the last candidate, unverifiedPrefixes,
complete: false) instead of failing a child whose own file is intact. A
budget error still fails the read.

--max-bytes is validated before the first file is read, next to the
other budgets, and every budget flag accepts plain decimal integers only.
2026-10-25 09:08:54 -04:00

843 lines
35 KiB
TypeScript

/**
* @fileoverview Offline reader for saved Codex conversations, behind
* `codeman history read` (`cli-history.ts`). It reads rollout JSONL files from
* disk directly: no HTTP route, no running server and no managed session.
*
* ## Selecting conversations
*
* Exactly one selector: `file` (one rollout, no store search), `sessionId` (a
* native thread id or unique prefix of at least 8 characters) or
* `rootSessionId` (a full id plus every transitive descendant). Identity always
* comes from each file's `session_meta`, never from its filename.
*
* ## Budgets
*
* Three separate bounds, each raisable from the CLI up to a fixed maximum, and
* each failing closed with an error rather than returning a partial answer:
*
* - `metadataBytes`: the head reads that attribute every rollout in the store.
* A `session_meta` line embeds Codex's base instructions (about 20 KiB on
* every rollout measured), so this scales with the store, not the query,
* and is kept apart from the transcript budget: a lookup on a store of
* thousands of rollouts must not spend the budget meant for the transcripts
* it returns. Each file's head read is also capped at 128 KiB.
* - `scanBytes`: the selected transcripts, read whole.
* - `maxEntries`: directory entries visited.
*
* ## Forks and sub-agents
*
* A thread Codex forks or spawns as a sub-agent can carry a copy of its
* parent's history in its own file (codex-rs `ForkPersistence::Copied`, written
* by `record_initial_history` in `core/src/session/mod.rs`; the copy keeps the
* parent's `session_meta`, see `keep_forked_rollout_item` in
* `core/src/agent/control/spawn.rs`). Such a file starts with its own
* `session_meta`, then the parent's, then the copied records, then one
* `thread_settings_applied` event, then the child's own records. The copy
* already lives in the parent's own file, so it is dropped here:
*
* - Paginated history (`history_mode: "paginated"`) numbers every record with
* an `ordinal` and declares `subagent_history_start_ordinal`; records below
* it are inherited, exactly as Codex's own projection treats them
* (`thread-store/src/local/thread_history_materialization.rs`). A paginated
* thread with a `history_base` references its prefix in another file and has
* nothing inherited to drop.
* - Legacy history has no ordinals. The prefix runs from the inherited
* `session_meta` to the `thread_settings_applied` event Codex appends after
* the copy. A legacy copy keeps the parent's own `thread_settings_applied`
* events too, so when more than one candidate follows, the boundary is the
* last one whose preceding messages are still a prefix of the parent's own
* messages, read from the parent's file (once: a family read that selects
* the parent reuses that same read, so no file is charged to `scanBytes`
* twice). Without a readable parent file that cannot be proven: the cut then
* falls at the LAST candidate, so parent text is never attributed to the
* child, and the result says so (`unverifiedPrefixes`, `complete: false`).
* Any failure of the parent's read other than a budget leads here, rather
* than failing the child whose own file is intact.
*
* A later `session_meta` must be the file's own id or a declared ancestor
* (`parent_thread_id`, `forked_from_id`, or the shared root `session_id`, and
* transitively the ancestors those declare); any other id still refuses the
* whole file rather than attribute one conversation's answer to another.
*
* ## Compressed rollouts
*
* Codex's `local_thread_store_compression` feature rewrites cold rollouts to
* `*.jsonl.zst` in place. This reader does not decompress them: a store scan
* counts each one in `compressedFiles` (so `complete` is false and a not-found
* error names them), skipping one whose plain `.jsonl` sibling exists, as
* Codex's own discovery does. A Zstandard file given to `file` is refused by
* its magic number, whatever its name.
*
* ## Live rollouts
*
* A running Codex appends to its rollout. Reads take a snapshot: the file's
* identity (device and inode) must match the attributed one and its size may
* only have grown; everything up to the last complete line is read, and an
* unterminated tail is reported as a partial record.
*
* ## Format
*
* The line shapes and the injected-context rule are shared with the Past
* Sessions scanner through `codex-rollout-format.ts`.
*
* @module local-history
*/
import { constants } from 'node:fs';
import { isUtf8 } from 'node:buffer';
import { lstat, open, opendir, stat, type FileHandle } from 'node:fs/promises';
import { basename, join, resolve } from 'node:path';
import { codexMessageFromLine, codexSessionsRoot, isCodexInjectedContext } from './codex-rollout-format.js';
const KIB = 1024;
const MIB = 1024 * KIB;
const GIB = 1024 * MIB;
export const HISTORY_DEFAULT_SCAN_BYTES = 16 * MIB;
export const HISTORY_MAX_SCAN_BYTES = 256 * MIB;
export const HISTORY_DEFAULT_METADATA_BYTES = 1 * GIB;
export const HISTORY_MAX_METADATA_BYTES = 16 * GIB;
export const HISTORY_DEFAULT_MAX_ENTRIES = 65536;
export const HISTORY_MAX_ENTRIES = 1048576;
export const HISTORY_DEFAULT_OUTPUT_BYTES = 8192;
export const HISTORY_MAX_OUTPUT_BYTES = 1 * MIB;
/** Per-file head window for `session_meta`. The line itself runs to about 20 KiB. */
const METADATA_HEAD_BYTES = 128 * KIB;
/** Read the head in steps, so a typical rollout costs one step past its session_meta line. */
const METADATA_CHUNK_BYTES = 32 * KIB;
/**
* Codex's compressed rollout suffix (codex-rs `rollout/src/compression.rs`:
* `is_compressed_rollout_path` matches a name ending in `.jsonl.zst`).
*/
const COMPRESSED_ROLLOUT_SUFFIX = '.jsonl.zst';
/** The Zstandard frame magic number, little-endian on disk. */
const ZSTD_MAGIC = Buffer.from([0x28, 0xb5, 0x2f, 0xfd]);
/** Codex writes `sessions/YYYY/MM/DD`; this leaves five levels of headroom. */
const MAX_DIRECTORY_DEPTH = 8;
const ID = /^[0-9a-f]{8}(?:[0-9a-f-]{0,28})$/i;
const FULL_ID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
type Json = Record<string, unknown>;
export interface LocalHistoryOptions {
file?: string;
sessionId?: string;
rootSessionId?: string;
sessionsRoot?: string;
topic?: string;
/** Inclusive UTC date, `YYYY-MM-DD`. */
after?: string;
/** Inclusive UTC date, `YYYY-MM-DD`. */
before?: string;
scanBytes?: number;
metadataBytes?: number;
maxEntries?: number;
}
export interface HistoryRecord {
rootSessionId: string | null;
sessionId: string;
timestamp: string;
role: 'user' | 'assistant';
/** Codex's own phase for assistant messages (`commentary`, `final_answer`); null when none was recorded. */
phase: string | null;
message: string;
}
interface Source {
path: string;
sessionId: string;
parentId?: string;
forkedFromId?: string;
rootId?: string;
/** `subagent_history_start_ordinal`: records below it are inherited. */
historyStart?: number;
timestamp: string;
/** `dev:ino` of the attributed file. */
identity: string;
/** Size when attributed; a later read may only see it grow. */
size: number;
}
export interface LocalHistoryResult {
source: 'codex-jsonl';
complete: boolean;
sessions: Array<{ sessionId: string; rootSessionId: string | null; parentSessionId?: string; path: string }>;
records: HistoryRecord[];
scan: {
/** Transcript bytes read (`--scan-bytes`). */
bytesRead: number;
/** Head bytes read to attribute rollouts (`--metadata-bytes`). */
metadataBytesRead: number;
entries: number;
malformedRecords: number;
partialRecords: number;
skippedSymlinks: number;
unattributedFiles: number;
/**
* Zstandard-compressed rollouts (`*.jsonl.zst`, Codex's `local_thread_store_compression`)
* this reader cannot open. Their threads are missing from the result.
*/
compressedFiles: number;
unreadableDirectories: number;
/** Inherited fork or sub-agent prefixes dropped (informational). */
inheritedPrefixes: number;
/** Inherited prefixes that never reached their end marker. */
incompletePrefixes: number;
/** Legacy prefixes whose end could not be checked against the parent's own file. */
unverifiedPrefixes: number;
};
}
interface Budget {
transcriptRemaining: number;
metadataRemaining: number;
entriesRemaining: number;
scan: LocalHistoryResult['scan'];
/** Paths behind `scan.compressedFiles`, for the not-found error. */
compressedPaths: string[];
}
/** A budget ran out: always fatal, never counted. */
class HistoryBudgetError extends Error {}
/** One store file could not be attributed: fatal for `--file`, counted for a store scan. */
class UnattributableFileError extends Error {}
function object(value: unknown): Json {
return value && typeof value === 'object' && !Array.isArray(value) ? (value as Json) : {};
}
function string(value: unknown): string | undefined {
return typeof value === 'string' ? value : undefined;
}
function fullId(value: unknown): string | undefined {
const id = string(value);
return id && FULL_ID.test(id) ? id.toLowerCase() : undefined;
}
function fileIdentity(value: { dev: number; ino: number }): string {
return `${value.dev}:${value.ino}`;
}
function validDate(value: string | undefined): boolean {
return (
value === undefined ||
(/^\d{4}-\d{2}-\d{2}$/.test(value) &&
Number.isFinite(Date.parse(value)) &&
new Date(value).toISOString().slice(0, 10) === value)
);
}
/** The UTC calendar date of a native timestamp, or undefined when it does not parse. */
function utcDate(timestamp: string): string | undefined {
const ms = Date.parse(timestamp);
return Number.isFinite(ms) ? new Date(ms).toISOString().slice(0, 10) : undefined;
}
function checkBudget(value: number, max: number, flag: string): void {
if (!Number.isSafeInteger(value) || value < 1 || value > max) throw new Error(`Invalid ${flag} (1-${max})`);
}
function validate(options: LocalHistoryOptions): void {
if ([options.file, options.sessionId, options.rootSessionId].filter((v) => v !== undefined).length !== 1)
throw new Error('Select exactly one of --file, --session-id, or --root-session-id');
if (options.file !== undefined && !options.file) throw new Error('A nonempty file is required');
if (options.sessionId !== undefined && !ID.test(options.sessionId)) throw new Error('Invalid native session id');
if (options.rootSessionId !== undefined && !FULL_ID.test(options.rootSessionId))
throw new Error('Invalid root session id');
if (
!validDate(options.after) ||
!validDate(options.before) ||
(options.after && options.before && options.after > options.before)
)
throw new Error('Invalid date or date range (use YYYY-MM-DD, UTC)');
if (options.topic !== undefined && !options.topic.trim()) throw new Error('Invalid empty topic');
checkBudget(options.scanBytes ?? HISTORY_DEFAULT_SCAN_BYTES, HISTORY_MAX_SCAN_BYTES, '--scan-bytes');
checkBudget(options.metadataBytes ?? HISTORY_DEFAULT_METADATA_BYTES, HISTORY_MAX_METADATA_BYTES, '--metadata-bytes');
checkBudget(options.maxEntries ?? HISTORY_DEFAULT_MAX_ENTRIES, HISTORY_MAX_ENTRIES, '--max-entries');
}
async function regularFile(path: string): Promise<FileHandle> {
// O_NOFOLLOW prevents a final-component link swap between lstat and open. fstat
// validates the opened descriptor, not a previously inspected pathname.
if (!(await lstat(path)).isFile()) throw new Error('History source must be a regular file, not a symlink');
const handle = await open(path, constants.O_RDONLY | constants.O_NOFOLLOW | constants.O_NONBLOCK);
if (!(await handle.stat()).isFile()) {
await handle.close();
throw new Error('History source must be a regular file');
}
return handle;
}
async function readAt(handle: FileHandle, position: number, size: number): Promise<Buffer> {
const buffer = Buffer.alloc(size);
let filled = 0;
while (filled < size) {
const { bytesRead } = await handle.read(buffer, filled, size - filled, position + filled);
if (!bytesRead) break;
filled += bytesRead;
}
return buffer.subarray(0, filled);
}
async function readMetadataChunk(handle: FileHandle, position: number, size: number, budget: Budget) {
if (size > budget.metadataRemaining)
throw new HistoryBudgetError('History metadata byte budget exhausted; raise --metadata-bytes');
const chunk = await readAt(handle, position, size);
budget.metadataRemaining -= chunk.length;
budget.scan.metadataBytesRead += chunk.length;
return chunk;
}
async function readTranscript(handle: FileHandle, size: number, budget: Budget): Promise<Buffer> {
if (size > budget.transcriptRemaining)
throw new HistoryBudgetError('History scan byte budget exhausted; narrow the selection or raise --scan-bytes');
const buffer = await readAt(handle, 0, size);
budget.transcriptRemaining -= buffer.length;
budget.scan.bytesRead += buffer.length;
return buffer;
}
type Identity = Omit<Source, 'identity' | 'size'>;
function metadata(value: unknown, path: string): Identity | undefined {
const entry = object(value);
if (entry.type !== 'session_meta') return undefined;
const p = object(entry.payload);
const sessionId = fullId(p.id) ?? fullId(p.session_id);
if (!sessionId) return undefined;
const spawn = object(object(object(p.source).subagent).thread_spawn);
const forkedFromId = fullId(p.forked_from_id);
// A fork with no spawn parent (a `/fork` in the TUI) still descends from its source thread.
const parentId =
fullId(p.parent_thread_id) ?? fullId(p.parent_session_id) ?? fullId(spawn.parent_thread_id) ?? forkedFromId;
// Codex: "session_id is equal to the root thread's ID".
const declaredRoot = fullId(p.root_session_id) ?? fullId(p.session_id);
const start = p.subagent_history_start_ordinal;
return {
path,
sessionId,
parentId,
forkedFromId,
rootId: declaredRoot && declaredRoot !== sessionId ? declaredRoot : undefined,
historyStart: Number.isSafeInteger(start) && (start as number) >= 0 ? (start as number) : undefined,
timestamp: string(p.timestamp) ?? string(entry.timestamp) ?? '',
};
}
function sameIdentity(a: Identity, b: Identity): boolean {
return (
a.sessionId === b.sessionId &&
a.parentId === b.parentId &&
a.forkedFromId === b.forkedFromId &&
a.rootId === b.rootId &&
a.historyStart === b.historyStart
);
}
/**
* The first `session_meta` within the file's head. Problems with the file
* itself throw {@link UnattributableFileError}; a budget throws
* {@link HistoryBudgetError}; a short file with no metadata yet returns undefined.
*/
async function readMetadata(path: string, budget: Budget): Promise<Source | undefined> {
let handle: FileHandle;
try {
handle = await regularFile(path);
} catch (error) {
throw new UnattributableFileError(error instanceof Error ? error.message : String(error));
}
try {
const before = await handle.stat();
const limit = Math.min(before.size, METADATA_HEAD_BYTES);
let buffer = Buffer.alloc(0);
let parsed = 0;
while (buffer.length < limit) {
const chunk = await readMetadataChunk(
handle,
buffer.length,
Math.min(METADATA_CHUNK_BYTES, limit - buffer.length),
budget
);
if (!chunk.length) break;
if (!buffer.length && chunk.subarray(0, ZSTD_MAGIC.length).equals(ZSTD_MAGIC))
throw new UnattributableFileError(
'History source is Zstandard-compressed (a Codex .jsonl.zst rollout); only plain .jsonl is read'
);
buffer = Buffer.concat([buffer, chunk]);
let end = buffer.indexOf(10, parsed);
while (end >= 0) {
const line = buffer.subarray(parsed, end);
parsed = end + 1;
end = buffer.indexOf(10, parsed);
if (!isUtf8(line)) throw new UnattributableFileError('History metadata contains invalid UTF8');
let value: unknown;
try {
value = JSON.parse(line.toString('utf8'));
} catch {
continue;
}
const found = metadata(value, path);
if (!found) continue;
// session_meta is written once; a growing file is a live rollout, not a change.
const after = await handle.stat();
if (fileIdentity(after) !== fileIdentity(before) || after.size < before.size)
throw new UnattributableFileError('History source changed during metadata scan');
return { ...found, identity: fileIdentity(before), size: before.size };
}
}
if (buffer.length < limit) throw new UnattributableFileError('History source changed during metadata scan');
if (before.size > METADATA_HEAD_BYTES)
throw new UnattributableFileError('No session metadata within the first 128 KiB');
return undefined;
} finally {
await handle.close();
}
}
async function findSources(root: string, budget: Budget): Promise<Source[]> {
// The root may itself be a symlink, as the Past Sessions scanner allows
// (`codex-transcript.ts` reads it with readdir, which follows it); links
// BELOW the root are never followed by either reader.
let rootStat;
try {
rootStat = await stat(root);
} catch {
throw new Error(`Sessions root not found: ${root}`);
}
if (!rootStat.isDirectory()) throw new Error('Sessions root must be a directory');
const sources: Source[] = [];
const visit = async (dir: string, depth: number): Promise<void> => {
if (depth > MAX_DIRECTORY_DEPTH) throw new HistoryBudgetError('History directory scan depth budget exhausted');
let entries;
try {
entries = await opendir(dir);
} catch {
budget.scan.unreadableDirectories++;
return;
}
for await (const entry of entries) {
if (--budget.entriesRemaining < 0)
throw new HistoryBudgetError('History directory scan entry budget exhausted; raise --max-entries');
budget.scan.entries++;
const path = join(dir, entry.name);
if (entry.isSymbolicLink()) {
budget.scan.skippedSymlinks++;
continue;
}
if (entry.isDirectory()) {
// Recheck immediately before descending; never intentionally follow links.
const now = await lstat(path).catch(() => undefined);
if (!now?.isDirectory()) {
budget.scan.unreadableDirectories++;
continue;
}
await visit(path, depth + 1);
} else if (entry.isFile() && entry.name.endsWith('.jsonl')) {
let source: Source | undefined;
try {
source = await readMetadata(path, budget);
} catch (error) {
if (!(error instanceof UnattributableFileError)) throw error;
}
if (source) sources.push(source);
else budget.scan.unattributedFiles++;
} else if (entry.isFile() && entry.name.endsWith(COMPRESSED_ROLLOUT_SUFFIX)) {
// Codex reads the plain sibling instead when both exist (`should_skip_compressed_sibling`),
// and that sibling is visited (or counted) on its own.
const plain = await lstat(path.slice(0, -'.zst'.length)).catch(() => undefined);
if (plain) continue;
budget.scan.compressedFiles++;
budget.compressedPaths.push(path);
}
}
};
await visit(root, 0);
return sources;
}
interface Message {
representation: 'event' | 'modern' | 'response';
role: 'user' | 'assistant';
message: string;
phase: string | null;
timestamp: string;
}
function message(value: unknown): Message | undefined {
const entry = object(value);
const p = object(entry.payload);
if (entry.isSidechain || p.isSidechain) return undefined;
const found = codexMessageFromLine(value);
if (!found) return undefined;
// Codex context is not a human turn. Real prompts also arrive as events, which are never filtered.
if (found.injectionProne && isCodexInjectedContext(found.text)) return undefined;
return {
role: found.role,
message: found.text,
// Codex records a phase on assistant messages only; a user turn has none.
phase: found.role === 'assistant' ? (found.phase ?? null) : null,
representation: found.shape === 'item' ? 'modern' : found.shape,
timestamp: string(entry.timestamp) ?? string(p.timestamp) ?? '',
};
}
/** Native events and response_item commonly duplicate one accepted message. */
function collapse(items: Message[]): Message[] {
const records: Message[] = [];
let representations = new Set<Message['representation']>();
for (const raw of items) {
const item = { ...raw };
const previous = records.at(-1);
if (
previous?.role === item.role &&
previous.message === item.message &&
!representations.has(item.representation)
) {
representations.add(item.representation);
if (!previous.timestamp) previous.timestamp = item.timestamp;
if (item.phase === 'final_answer' || previous.phase === null) previous.phase = item.phase ?? previous.phase;
continue;
}
records.push(item);
representations = new Set([item.representation]);
}
return records;
}
function isPrefixOf(prefix: Message[], whole: Message[]): boolean {
return (
prefix.length <= whole.length &&
prefix.every((item, i) => item.role === whole[i].role && item.message === whole[i].message)
);
}
interface ReadContext {
/**
* The ancestor's own messages, uncut, or undefined when its file is not in the
* store or could not be read for any reason other than a budget.
*/
ancestorMessages(id: string): Promise<Message[] | undefined>;
}
type Piece = Message | { boundary: true };
interface LegacyPrefix {
ancestorId: string;
/** Messages and `thread_settings_applied` boundaries after the inherited session_meta. */
pieces: Piece[];
}
/** One read of one rollout: every view later needed of it, so no file is read (or charged) twice. */
interface ParsedSource {
/** Every message in file order, inherited copies included: what a child's copy of this thread holds. */
raw: Message[];
/** Messages before any legacy inherited prefix, paginated inherited records already dropped. */
own: Message[];
legacy?: LegacyPrefix;
}
async function parseSource(source: Source, budget: Budget): Promise<ParsedSource> {
const handle = await regularFile(source.path);
let buffer: Buffer;
try {
const now = await handle.stat();
// A live rollout only grows. A different file or a shorter one is a rewrite.
if (fileIdentity(now) !== source.identity || now.size < source.size)
throw new Error('History source changed after attribution; retry explicitly');
buffer = await readTranscript(handle, now.size, budget);
if (buffer.length !== now.size) throw new Error('History source shrank while reading; retry explicitly');
} finally {
await handle.close();
}
// Snapshot up to the last complete line: a running Codex may be mid-append.
const end = buffer.lastIndexOf(10) + 1;
const complete = buffer.subarray(0, end);
if (buffer.subarray(end).toString('latin1').trim()) budget.scan.partialRecords++;
if (!isUtf8(complete)) throw new Error('History source contains invalid UTF8');
const ancestors = new Set([source.parentId, source.forkedFromId, source.rootId].filter((id): id is string => !!id));
const raw: Message[] = [];
const own: Message[] = [];
let legacy: LegacyPrefix | undefined;
let lastOrdinal = -1;
for (const line of complete.toString('utf8').split('\n')) {
if (!line.trim()) continue;
let parsed: unknown;
try {
parsed = JSON.parse(line);
} catch {
budget.scan.malformedRecords++;
continue;
}
const entry = object(parsed);
const ordinal = typeof entry.ordinal === 'number' ? entry.ordinal : undefined;
if (ordinal !== undefined) lastOrdinal = Math.max(lastOrdinal, ordinal);
if (source.historyStart !== undefined && ordinal === undefined) {
// Codex's projection skips a paginated record without an ordinal; so does this, visibly.
budget.scan.malformedRecords++;
continue;
}
const inherited = source.historyStart !== undefined && ordinal! < source.historyStart;
if (entry.type === 'session_meta') {
const declared = metadata(parsed, source.path);
if (declared && declared.sessionId === source.sessionId && sameIdentity(declared, source)) continue;
if (!declared || declared.sessionId === source.sessionId || !ancestors.has(declared.sessionId))
throw new Error('History source contains conflicting native metadata; attribution refused');
// An ancestor that was itself a fork carries its own ancestors' metadata in the copy.
for (const id of [declared.parentId, declared.forkedFromId, declared.rootId]) if (id) ancestors.add(id);
if (inherited || legacy) continue;
budget.scan.inheritedPrefixes++;
legacy = { ancestorId: declared.sessionId, pieces: [] };
continue;
}
if (legacy && entry.type === 'event_msg' && object(entry.payload).type === 'thread_settings_applied') {
if (!inherited) legacy.pieces.push({ boundary: true });
continue;
}
const item = message(parsed);
if (!item) continue;
raw.push(item);
if (inherited) continue;
if (legacy) legacy.pieces.push(item);
else own.push(item);
}
if (source.historyStart !== undefined && source.historyStart > 0) {
budget.scan.inheritedPrefixes++;
// Initialization died while copying the prefix: Codex refuses to resume such a file.
if (lastOrdinal < source.historyStart - 1) budget.scan.incompletePrefixes++;
}
return { raw: collapse(raw), own, legacy };
}
/** The thread's own messages: its inherited prefix dropped, proven against the parent where one is needed. */
async function readMessages(parsed: ParsedSource, budget: Budget, context: ReadContext): Promise<Message[]> {
const own = [...parsed.own];
if (parsed.legacy) own.push(...(await afterLegacyPrefix(parsed.legacy, budget, context)));
return collapse(own);
}
/** The child's own messages after a legacy inherited prefix. */
async function afterLegacyPrefix(prefix: LegacyPrefix, budget: Budget, context: ReadContext): Promise<Message[]> {
const boundaries = prefix.pieces.flatMap((piece, i) => ('boundary' in piece ? [i] : []));
if (!boundaries.length) {
budget.scan.incompletePrefixes++;
return [];
}
const messagesBefore = (index: number) =>
collapse(prefix.pieces.slice(0, index).filter((piece): piece is Message => !('boundary' in piece)));
let cut = boundaries[0];
if (boundaries.length > 1) {
// The last candidate whose preceding messages the parent's own file also holds.
const parent = await context.ancestorMessages(prefix.ancestorId);
const proven = parent ? boundaries.filter((candidate) => isPrefixOf(messagesBefore(candidate), parent)) : [];
if (proven.length) cut = proven.at(-1)!;
else {
// Unprovable: drop up to the last candidate, so parent text is never attributed to the child.
budget.scan.unverifiedPrefixes++;
cut = boundaries.at(-1)!;
}
}
return prefix.pieces.slice(cut + 1).filter((piece): piece is Message => !('boundary' in piece));
}
function selectedTurns(messages: Message[], options: LocalHistoryOptions): Message[] {
const turns: Message[][] = [];
for (const item of messages) {
if (item.role === 'user' || !turns.length) turns.push([]);
turns.at(-1)!.push(item);
}
const needle = options.topic?.trim().toLocaleLowerCase();
return turns
.filter((turn) => {
const dateMatches =
(!options.after && !options.before) ||
turn.some((item) => {
const date = utcDate(item.timestamp);
return (
date !== undefined &&
(!options.after || date >= options.after) &&
(!options.before || date <= options.before)
);
});
return dateMatches && (!needle || turn.some((item) => item.message.toLocaleLowerCase().includes(needle)));
})
.flat();
}
/** Native ancestry is metadata evidence, not the conversation selected by a query. */
function nativeRoot(source: Source, sources: Source[]): string | null {
const seen = new Set<string>();
let current = source;
while (true) {
if (seen.has(current.sessionId)) throw new Error('Native history contains cyclic ancestry');
seen.add(current.sessionId);
if (current.rootId) return current.rootId;
if (!current.parentId) return current.sessionId;
const parents = sources.filter((candidate) => candidate.sessionId === current.parentId);
if (parents.length === 0) return null;
if (parents.length > 1) throw new Error('Native ancestor id is ambiguous');
current = parents[0];
}
}
/**
* The not-found error, naming what the store held but could not attribute.
* Codex names a rollout `rollout-<timestamp>-<thread id>.jsonl[.zst]`, so a
* compressed file whose name carries the wanted id is named outright; the name
* is only a hint here, never identity.
*/
function missingSessionMessage(wanted: string, budget: Budget): string {
const { scan } = budget;
const notes: string[] = [];
if (scan.compressedFiles) {
// `rollout-` (8) + `YYYY-MM-DDTHH-MM-SS` (19) + `-` (1), then the thread id.
const named = budget.compressedPaths.filter((path) => {
const name = basename(path).toLowerCase();
return name.startsWith('rollout-') && name.slice(28).startsWith(wanted);
});
const shown = named.slice(0, 3).join(', ') + (named.length > 3 ? ` and ${named.length - 3} more` : '');
notes.push(
named.length
? `it may be in compressed rollout ${shown}, which this reader cannot open; read a decompressed copy with --file (zstd -dc <path> > copy.jsonl)`
: `${scan.compressedFiles} compressed .jsonl.zst rollout(s) could not be read`
);
}
if (scan.unattributedFiles) notes.push(`${scan.unattributedFiles} file(s) could not be attributed`);
return `Native session missing from history store${notes.length ? ` (${notes.join('; ')})` : ''}`;
}
export async function readLocalHistory(options: LocalHistoryOptions): Promise<LocalHistoryResult> {
validate(options);
const scan: LocalHistoryResult['scan'] = {
bytesRead: 0,
metadataBytesRead: 0,
entries: 0,
malformedRecords: 0,
partialRecords: 0,
skippedSymlinks: 0,
unattributedFiles: 0,
compressedFiles: 0,
unreadableDirectories: 0,
inheritedPrefixes: 0,
incompletePrefixes: 0,
unverifiedPrefixes: 0,
};
const budget: Budget = {
transcriptRemaining: options.scanBytes ?? HISTORY_DEFAULT_SCAN_BYTES,
metadataRemaining: options.metadataBytes ?? HISTORY_DEFAULT_METADATA_BYTES,
entriesRemaining: options.maxEntries ?? HISTORY_DEFAULT_MAX_ENTRIES,
scan,
compressedPaths: [],
};
let sources: Source[];
let attributionSources: Source[];
if (options.file !== undefined) {
let source: Source | undefined;
try {
source = await readMetadata(resolve(options.file), budget);
} catch (error) {
throw error instanceof UnattributableFileError ? new Error(error.message) : error;
}
if (!source) throw new Error('History source is missing valid native session metadata');
sources = [source];
attributionSources = sources;
} else {
const root = options.sessionsRoot ?? codexSessionsRoot();
const all = await findSources(resolve(root), budget);
const wanted = (options.sessionId ?? options.rootSessionId)!.toLowerCase();
const matches = all.filter((source) => source.sessionId.startsWith(wanted));
if (!matches.length) throw new Error(missingSessionMessage(wanted, budget));
if (matches.length !== 1) throw new Error('Native session id is ambiguous');
const target = matches[0];
attributionSources = all;
const queryRootId = target.sessionId;
sources = [target];
if (options.rootSessionId) {
const selected = new Set([target.sessionId]);
let changed = true;
while (changed) {
changed = false;
for (const source of all) {
if (
!selected.has(source.sessionId) &&
(source.rootId === queryRootId || (source.parentId && selected.has(source.parentId)))
) {
selected.add(source.sessionId);
sources.push(source);
changed = true;
}
}
}
for (const source of sources) {
if (all.filter((other) => other.sessionId === source.sessionId).length !== 1)
throw new Error('Native family contains ambiguous duplicated session ids');
}
}
}
sources.sort((a, b) => a.timestamp.localeCompare(b.timestamp) || a.sessionId.localeCompare(b.sessionId));
const roots = new Map(sources.map((source) => [source.sessionId, nativeRoot(source, attributionSources)]));
// Each selected file is read once, whether the main loop or a child's boundary proof asks first.
const parsedCache = new Map<Source, Promise<ParsedSource>>();
const parse = (source: Source) => {
let parsed = parsedCache.get(source);
if (!parsed) {
parsed = parseSource(source, budget);
parsedCache.set(source, parsed);
}
return parsed;
};
const context: ReadContext = {
// Raw messages need no boundary proof of their own, so this never recurses.
async ancestorMessages(id) {
const candidates = attributionSources.filter((source) => source.sessionId === id);
if (candidates.length !== 1) return undefined;
try {
return (await parse(candidates[0])).raw;
} catch (error) {
// A budget always fails the read; any other failure only leaves the boundary unproven.
if (error instanceof HistoryBudgetError) throw error;
return undefined;
}
},
};
const records: HistoryRecord[] = [];
for (const source of sources) {
records.push(
...selectedTurns(await readMessages(await parse(source), budget, context), options).map((item) => ({
rootSessionId: roots.get(source.sessionId) ?? null,
sessionId: source.sessionId,
role: item.role,
message: item.message,
phase: item.phase,
timestamp: item.timestamp,
}))
);
}
if (!records.length) throw new Error('No matching user/assistant evidence found');
return {
source: 'codex-jsonl',
complete:
scan.malformedRecords === 0 &&
scan.partialRecords === 0 &&
scan.skippedSymlinks === 0 &&
scan.unattributedFiles === 0 &&
scan.compressedFiles === 0 &&
scan.unreadableDirectories === 0 &&
scan.incompletePrefixes === 0 &&
scan.unverifiedPrefixes === 0 &&
[...roots.values()].every((root) => root !== null),
sessions: sources.map((source) => ({
sessionId: source.sessionId,
rootSessionId: roots.get(source.sessionId) ?? null,
...(source.parentId ? { parentSessionId: source.parentId } : {}),
path: source.path,
})),
records,
scan,
};
}
/** The `--max-bytes` check, exported so the CLI can make it before any file is read. */
export function validateOutputBytes(maxBytes: number): void {
checkBudget(maxBytes, HISTORY_MAX_OUTPUT_BYTES, '--max-bytes');
}
/** Output never splits a JSON document, UTF8 character, record, or selected turn. */
export function formatLocalHistory(
result: LocalHistoryResult,
format: 'json' | 'text' = 'json',
maxBytes = HISTORY_DEFAULT_OUTPUT_BYTES
): string {
validateOutputBytes(maxBytes);
const body =
format === 'json'
? JSON.stringify(result)
: [
`source=${result.source} complete=${result.complete} scannedBytes=${result.scan.bytesRead} metadataBytes=${result.scan.metadataBytesRead}`,
...result.records.map(
(row) =>
`[root=${row.rootSessionId} session=${row.sessionId} ${row.timestamp} ${row.role}${row.phase ? ` ${row.phase}` : ''}]\n${row.message}`
),
].join('\n\n');
if (Buffer.byteLength(body) + 1 > maxBytes)
throw new Error('History output byte budget exceeded; narrow filters or raise --max-bytes');
return body;
}