@100xprompt/brain 0.0.0-stage → 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +48 -0
- package/README.md +172 -2
- package/package.json +48 -4
- package/src/adapters/aider/index.ts +8 -0
- package/src/adapters/aider/reader.ts +129 -0
- package/src/adapters/aider/records.ts +220 -0
- package/src/adapters/aider/writer.ts +269 -0
- package/src/adapters/amazon-q-cli/index.ts +8 -0
- package/src/adapters/amazon-q-cli/reader.ts +84 -0
- package/src/adapters/amazon-q-cli/records.ts +191 -0
- package/src/adapters/amazon-q-cli/writer.ts +370 -0
- package/src/adapters/amazon-q-ide/index.ts +9 -0
- package/src/adapters/amazon-q-ide/reader.ts +131 -0
- package/src/adapters/amazon-q-ide/records.ts +141 -0
- package/src/adapters/amazon-q-ide/writer.ts +325 -0
- package/src/adapters/antigravity/index.ts +9 -0
- package/src/adapters/antigravity/proto.ts +194 -0
- package/src/adapters/antigravity/reader.ts +424 -0
- package/src/adapters/antigravity/records.ts +236 -0
- package/src/adapters/antigravity/writer.ts +463 -0
- package/src/adapters/auggie/index.ts +36 -0
- package/src/adapters/claude-code/index.ts +8 -0
- package/src/adapters/claude-code/reader.ts +639 -0
- package/src/adapters/claude-code/records.ts +88 -0
- package/src/adapters/claude-code/writer.ts +329 -0
- package/src/adapters/cline/index.ts +12 -0
- package/src/adapters/cline/reader.ts +432 -0
- package/src/adapters/cline/records.ts +151 -0
- package/src/adapters/cline/vscdb.ts +56 -0
- package/src/adapters/cline/writer.ts +656 -0
- package/src/adapters/cline/xml.ts +177 -0
- package/src/adapters/cline4/index.ts +9 -0
- package/src/adapters/cline4/reader.ts +179 -0
- package/src/adapters/cline4/records.ts +92 -0
- package/src/adapters/cline4/writer.ts +361 -0
- package/src/adapters/codebuff/index.ts +8 -0
- package/src/adapters/codebuff/project.ts +207 -0
- package/src/adapters/codebuff/reader.ts +215 -0
- package/src/adapters/codebuff/records.ts +173 -0
- package/src/adapters/codebuff/writer.ts +383 -0
- package/src/adapters/codex/index.ts +8 -0
- package/src/adapters/codex/native.ts +239 -0
- package/src/adapters/codex/reader.ts +747 -0
- package/src/adapters/codex/records.ts +114 -0
- package/src/adapters/codex/writer.ts +479 -0
- package/src/adapters/continue/index.ts +9 -0
- package/src/adapters/continue/reader.ts +68 -0
- package/src/adapters/continue/records.ts +418 -0
- package/src/adapters/continue/writer.ts +162 -0
- package/src/adapters/copilot-cli/index.ts +9 -0
- package/src/adapters/copilot-cli/reader.ts +186 -0
- package/src/adapters/copilot-cli/records.ts +241 -0
- package/src/adapters/copilot-cli/writer.ts +353 -0
- package/src/adapters/crush/index.ts +8 -0
- package/src/adapters/crush/reader.ts +180 -0
- package/src/adapters/crush/records.ts +169 -0
- package/src/adapters/crush/writer.ts +333 -0
- package/src/adapters/cursor-cli/index.ts +405 -0
- package/src/adapters/cursor-ide/agentkv.ts +95 -0
- package/src/adapters/cursor-ide/index.ts +501 -0
- package/src/adapters/cursor-ide/transcripts.ts +122 -0
- package/src/adapters/cursor-ide/workspaces.ts +119 -0
- package/src/adapters/cursor-ide/writer.ts +170 -0
- package/src/adapters/devin/index.ts +8 -0
- package/src/adapters/devin/reader.ts +305 -0
- package/src/adapters/devin/records.ts +277 -0
- package/src/adapters/devin/writer.ts +523 -0
- package/src/adapters/factory-droid/index.ts +12 -0
- package/src/adapters/factory-droid/reader.ts +184 -0
- package/src/adapters/factory-droid/records.ts +79 -0
- package/src/adapters/factory-droid/writer.ts +350 -0
- package/src/adapters/gemini-cli/exchanges.ts +228 -0
- package/src/adapters/gemini-cli/index.ts +396 -0
- package/src/adapters/gemini-cli/records.ts +521 -0
- package/src/adapters/gemini-cli/writer.ts +354 -0
- package/src/adapters/goose/index.ts +8 -0
- package/src/adapters/goose/reader.ts +138 -0
- package/src/adapters/goose/records.ts +199 -0
- package/src/adapters/goose/writer.ts +340 -0
- package/src/adapters/grok-cli/image.ts +44 -0
- package/src/adapters/grok-cli/index.ts +8 -0
- package/src/adapters/grok-cli/reader.ts +302 -0
- package/src/adapters/grok-cli/records.ts +172 -0
- package/src/adapters/grok-cli/writer.ts +329 -0
- package/src/adapters/hermes/index.ts +8 -0
- package/src/adapters/hermes/reader.ts +249 -0
- package/src/adapters/hermes/records.ts +285 -0
- package/src/adapters/hermes/writer.ts +452 -0
- package/src/adapters/iflow-cli/index.ts +72 -0
- package/src/adapters/iflow-cli/records.ts +215 -0
- package/src/adapters/iflow-cli/writer.ts +204 -0
- package/src/adapters/index.ts +112 -0
- package/src/adapters/kilo-cli/index.ts +21 -0
- package/src/adapters/kilo-code/index.ts +11 -0
- package/src/adapters/kilo-code/writer.ts +92 -0
- package/src/adapters/kimi/index.ts +9 -0
- package/src/adapters/kimi/reader.ts +92 -0
- package/src/adapters/kimi/records.ts +221 -0
- package/src/adapters/kimi/writer.ts +367 -0
- package/src/adapters/kiro-cli/index.ts +8 -0
- package/src/adapters/kiro-cli/reader.ts +87 -0
- package/src/adapters/kiro-cli/records.ts +260 -0
- package/src/adapters/kiro-cli/writer.ts +305 -0
- package/src/adapters/kiro-ide/index.ts +8 -0
- package/src/adapters/kiro-ide/writer.ts +52 -0
- package/src/adapters/letta/index.ts +8 -0
- package/src/adapters/letta/reader.ts +177 -0
- package/src/adapters/letta/records.ts +119 -0
- package/src/adapters/letta/writer.ts +413 -0
- package/src/adapters/mastracode/index.ts +8 -0
- package/src/adapters/mastracode/reader.ts +323 -0
- package/src/adapters/mastracode/records.ts +336 -0
- package/src/adapters/mastracode/writer.ts +385 -0
- package/src/adapters/mistral-vibe/index.ts +9 -0
- package/src/adapters/mistral-vibe/reader.ts +62 -0
- package/src/adapters/mistral-vibe/records.ts +104 -0
- package/src/adapters/mistral-vibe/writer.ts +359 -0
- package/src/adapters/muse/index.ts +10 -0
- package/src/adapters/muse/reader.ts +332 -0
- package/src/adapters/muse/records.ts +104 -0
- package/src/adapters/muse/writer.ts +387 -0
- package/src/adapters/omp/index.ts +13 -0
- package/src/adapters/omp/reader.ts +314 -0
- package/src/adapters/omp/records.ts +153 -0
- package/src/adapters/omp/writer.ts +216 -0
- package/src/adapters/opencode/ids.ts +52 -0
- package/src/adapters/opencode/index.ts +31 -0
- package/src/adapters/opencode/note.ts +8 -0
- package/src/adapters/opencode/reader.ts +452 -0
- package/src/adapters/opencode/tools.ts +174 -0
- package/src/adapters/opencode/v2.ts +367 -0
- package/src/adapters/opencode/writer.ts +379 -0
- package/src/adapters/openhands/index.ts +8 -0
- package/src/adapters/openhands/reader.ts +222 -0
- package/src/adapters/openhands/records.ts +136 -0
- package/src/adapters/openhands/writer.ts +404 -0
- package/src/adapters/pearai/index.ts +10 -0
- package/src/adapters/pi/index.ts +28 -0
- package/src/adapters/pi/reader.ts +261 -0
- package/src/adapters/pi/records.ts +99 -0
- package/src/adapters/pi/writer.ts +362 -0
- package/src/adapters/qoder-cli/index.ts +8 -0
- package/src/adapters/qoder-cli/reader.ts +83 -0
- package/src/adapters/qoder-cli/records.ts +45 -0
- package/src/adapters/qoder-cli/writer.ts +72 -0
- package/src/adapters/qwen-code/index.ts +84 -0
- package/src/adapters/qwen-code/records.ts +194 -0
- package/src/adapters/qwen-code/writer.ts +224 -0
- package/src/adapters/tabnine-cli/index.ts +8 -0
- package/src/adapters/tabnine-cli/reader.ts +106 -0
- package/src/adapters/tabnine-cli/records.ts +33 -0
- package/src/adapters/tabnine-cli/writer.ts +156 -0
- package/src/adapters/types.ts +73 -0
- package/src/adapters/void/index.ts +9 -0
- package/src/adapters/void/reader.ts +48 -0
- package/src/adapters/void/records.ts +285 -0
- package/src/adapters/void/writer.ts +93 -0
- package/src/adapters/vscode-copilot/index.ts +9 -0
- package/src/adapters/vscode-copilot/reader.ts +212 -0
- package/src/adapters/vscode-copilot/records.ts +388 -0
- package/src/adapters/vscode-copilot/shared.ts +68 -0
- package/src/adapters/vscode-copilot/writer.ts +546 -0
- package/src/adapters/zed/index.ts +8 -0
- package/src/adapters/zed/reader.ts +198 -0
- package/src/adapters/zed/records.ts +87 -0
- package/src/adapters/zed/writer.ts +341 -0
- package/src/brief-writers.ts +71 -0
- package/src/brief.ts +238 -0
- package/src/cli.ts +326 -0
- package/src/fsutil.ts +133 -0
- package/src/gateway/connect.ts +845 -0
- package/src/gateway/signin.ts +323 -0
- package/src/handoff.ts +104 -0
- package/src/hooks.ts +455 -0
- package/src/memory/audit.ts +514 -0
- package/src/memory/blobs.ts +94 -0
- package/src/memory/cli.ts +117 -0
- package/src/memory/distill.ts +381 -0
- package/src/memory/externalize.ts +435 -0
- package/src/memory/lookups.ts +70 -0
- package/src/memory/mcp-install.ts +401 -0
- package/src/memory/mcp-server.ts +296 -0
- package/src/memory/prepare.ts +179 -0
- package/src/memory/preview.ts +154 -0
- package/src/memory/project.ts +35 -0
- package/src/memory/redact.ts +72 -0
- package/src/memory/relevance.ts +187 -0
- package/src/memory/repomap.ts +516 -0
- package/src/memory/store.ts +270 -0
- package/src/memory/sync.ts +166 -0
- package/src/memory/tidy.ts +193 -0
- package/src/memory/types.ts +118 -0
- package/src/org/admin-ops.ts +96 -0
- package/src/org/auth.ts +112 -0
- package/src/org/backup.ts +115 -0
- package/src/org/cli.ts +438 -0
- package/src/org/client.ts +404 -0
- package/src/org/config.ts +44 -0
- package/src/org/crypto.ts +537 -0
- package/src/org/db.ts +435 -0
- package/src/org/graph.ts +318 -0
- package/src/org/oidc.ts +435 -0
- package/src/org/policy.ts +398 -0
- package/src/org/server.ts +920 -0
- package/src/org/sso.ts +316 -0
- package/src/org/tls.ts +66 -0
- package/src/org/types.ts +193 -0
- package/src/self.ts +68 -0
- package/src/session/attachments.ts +53 -0
- package/src/session/peekcache.ts +50 -0
- package/src/session/types.ts +104 -0
- package/src/session/util.ts +130 -0
- package/src/sqlite.ts +48 -0
|
@@ -0,0 +1,747 @@
|
|
|
1
|
+
import { closeSync, openSync, readFileSync, readSync } from "node:fs"
|
|
2
|
+
import { join } from "node:path"
|
|
3
|
+
import { exists, findFiles, gitInfo, ls, mtime, readJson } from "../../fsutil.ts"
|
|
4
|
+
import { keepImage, parseDataUrl } from "../../session/attachments.ts"
|
|
5
|
+
import type { Attachment, Project, Session, SessionHeader, Turn, Usage } from "../../session/types.ts"
|
|
6
|
+
import { clip, header, iso, lossCounter, noteModel, toolKindOf } from "../../session/util.ts"
|
|
7
|
+
import { withSnapshot } from "../../sqlite.ts"
|
|
8
|
+
import type { Reader, SessionRef } from "../types.ts"
|
|
9
|
+
import { summaryBody } from "./native.ts"
|
|
10
|
+
import { IMPORT_MARKER, LABEL, TOOL, idFromName, isContext, isImageLabel, isRolloutName, outputText, patchFiles } from "./records.ts"
|
|
11
|
+
|
|
12
|
+
// Codex CLI sessions. Each thread is a rollout under ~/.codex/sessions/YYYY/MM/DD (archived ones under
|
|
13
|
+
// archived_sessions/), one JSON record per line: {timestamp, ordinal?, type, payload}, starting with
|
|
14
|
+
// session_meta. The model-visible history is the response_item stream; event_msg is what the UI showed
|
|
15
|
+
// and mostly repeats it (user_message/agent_message in legacy rollouts, item_completed in 0.147's
|
|
16
|
+
// paginated ones), so events become turns only when no response_item carries the same text. Cold
|
|
17
|
+
// rollouts may be zstd-compressed (.jsonl.zst); very old sessions are single .json files. Titles live
|
|
18
|
+
// only in the state_*.sqlite threads index, read through a snapshot.
|
|
19
|
+
|
|
20
|
+
type Row = Record<string, any>
|
|
21
|
+
|
|
22
|
+
const codexDir = (home: string) => join(home, ".codex")
|
|
23
|
+
|
|
24
|
+
/** Records that hold no conversation: bookkeeping Codex needs to resume, not content. */
|
|
25
|
+
const QUIET_RECORDS = new Set([
|
|
26
|
+
"world_state",
|
|
27
|
+
"inter_agent_communication_metadata",
|
|
28
|
+
"dynamic_tools_updated",
|
|
29
|
+
"token_usage_record",
|
|
30
|
+
"security_risk_score",
|
|
31
|
+
"retained_context",
|
|
32
|
+
"realtime_item",
|
|
33
|
+
])
|
|
34
|
+
|
|
35
|
+
/** Event types that repeat response items for the UI or carry no content (EventMsg in protocol.rs, plus older names). */
|
|
36
|
+
const KNOWN_EVENTS = new Set(
|
|
37
|
+
(
|
|
38
|
+
"error warning guardian_warning realtime_conversation_started realtime_conversation_realtime realtime_conversation_closed " +
|
|
39
|
+
"realtime_conversation_sdp realtime_conversation_list_voices_response model_reroute model_verification turn_moderation_metadata " +
|
|
40
|
+
"safety_buffering context_compacted thread_rolled_back task_started turn_started thread_settings_applied task_complete " +
|
|
41
|
+
"turn_complete token_count agent_message user_message agent_reasoning agent_reasoning_raw_content agent_reasoning_section_break " +
|
|
42
|
+
"session_configured environment_connected environment_disconnected thread_goal_updated mcp_startup_update mcp_startup_complete " +
|
|
43
|
+
"mcp_tool_call_begin mcp_tool_call_end web_search_begin web_search_end image_generation_begin image_generation_end " +
|
|
44
|
+
"exec_command_begin exec_command_output_delta terminal_interaction exec_command_end view_image_tool_call exec_approval_request " +
|
|
45
|
+
"request_permissions request_user_input dynamic_tool_call_request dynamic_tool_call_response elicitation_request " +
|
|
46
|
+
"apply_patch_approval_request guardian_assessment deprecation_notice stream_error patch_apply_begin patch_apply_updated " +
|
|
47
|
+
"patch_apply_end turn_diff plan_update turn_aborted shutdown_complete entered_review_mode exited_review_mode raw_response_item " +
|
|
48
|
+
"raw_response_completed item_started item_completed hook_started hook_completed agent_message_content_delta plan_delta " +
|
|
49
|
+
"reasoning_content_delta reasoning_raw_content_delta collab_agent_spawn_begin collab_agent_spawn_end " +
|
|
50
|
+
"collab_agent_interaction_begin collab_agent_interaction_end collab_waiting_begin collab_waiting_end collab_close_begin " +
|
|
51
|
+
"collab_close_end collab_resume_begin collab_resume_end sub_agent_activity background_event agent_message_delta " +
|
|
52
|
+
"agent_reasoning_delta agent_reasoning_raw_content_delta"
|
|
53
|
+
).split(" "),
|
|
54
|
+
)
|
|
55
|
+
|
|
56
|
+
const str = (v: unknown) => (typeof v === "string" && v ? v : undefined)
|
|
57
|
+
const norm = (s: string) => s.replace(/\s+/g, " ").trim()
|
|
58
|
+
|
|
59
|
+
/** How a user message is matched with its event copy: by its text, or, for a pasted image with no text, as such. */
|
|
60
|
+
const IMAGE_ONLY = "\u0000image-only message"
|
|
61
|
+
const userKey = (text: string | undefined, images: number) => (text?.trim() ? text : images ? IMAGE_ONLY : undefined)
|
|
62
|
+
|
|
63
|
+
/** The readable texts of a reasoning item: its summary parts and its raw content parts (open-weight models). */
|
|
64
|
+
function reasoningParts(p: Row): { summary: string[]; raw: string[] } {
|
|
65
|
+
const texts = (v: unknown) => (Array.isArray(v) ? v : []).map((s: Row) => s?.text).filter((s): s is string => typeof s === "string" && !!s.trim())
|
|
66
|
+
return { summary: texts(p.summary), raw: texts(p.content) }
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/** A multiset of texts: what the response items already said, so repeated events are skipped once each. */
|
|
70
|
+
function textBag() {
|
|
71
|
+
const m = new Map<string, number>()
|
|
72
|
+
return {
|
|
73
|
+
add(s: string) {
|
|
74
|
+
const k = norm(s)
|
|
75
|
+
if (k) m.set(k, (m.get(k) ?? 0) + 1)
|
|
76
|
+
},
|
|
77
|
+
take(s: string): boolean {
|
|
78
|
+
const k = norm(s)
|
|
79
|
+
const n = m.get(k) ?? 0
|
|
80
|
+
if (n > 0) m.set(k, n - 1)
|
|
81
|
+
return n > 0
|
|
82
|
+
},
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
function parseLines(text: string): { rows: Row[]; bad: number } {
|
|
87
|
+
const rows: Row[] = []
|
|
88
|
+
let bad = 0
|
|
89
|
+
for (const line of text.split("\n")) {
|
|
90
|
+
if (!line.trim()) continue
|
|
91
|
+
try {
|
|
92
|
+
const v = JSON.parse(line)
|
|
93
|
+
if (v && typeof v === "object") rows.push(v)
|
|
94
|
+
else bad++
|
|
95
|
+
} catch {
|
|
96
|
+
bad++
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
return { rows, bad }
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/** Text parts of a message's content, without Codex's context fragments and image labels. */
|
|
103
|
+
function messageParts(content: unknown): { text?: string; images: number; context: number; files: Attachment[] } {
|
|
104
|
+
const parts: Row[] = Array.isArray(content) ? content : typeof content === "string" ? [{ type: "input_text", text: content }] : []
|
|
105
|
+
const texts: string[] = []
|
|
106
|
+
let images = 0
|
|
107
|
+
let context = 0
|
|
108
|
+
const files: Attachment[] = []
|
|
109
|
+
for (const p of parts) {
|
|
110
|
+
if (p?.type === "input_image" || p?.type === "image" || p?.type === "local_image") {
|
|
111
|
+
images++
|
|
112
|
+
const d = parseDataUrl(p.image_url)
|
|
113
|
+
const kept = d ? keepImage(d.base64, d.mime) : undefined
|
|
114
|
+
if (kept) files.push(kept)
|
|
115
|
+
}
|
|
116
|
+
else if (typeof p?.text === "string") {
|
|
117
|
+
if (isContext(p.text)) context++
|
|
118
|
+
else if (!isImageLabel(p.text)) texts.push(p.text)
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
return { ...(texts.length ? { text: texts.join("\n") } : {}), images, context, files }
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
function parseArgs(args: unknown): unknown {
|
|
125
|
+
if (typeof args !== "string") return args ?? {}
|
|
126
|
+
try {
|
|
127
|
+
return JSON.parse(args)
|
|
128
|
+
} catch {
|
|
129
|
+
return args
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
function addUsage(a: Usage | undefined, b: Usage): Usage {
|
|
134
|
+
if (!a) return { ...b }
|
|
135
|
+
const out: Usage = {}
|
|
136
|
+
for (const k of ["input", "output", "cacheRead", "cacheWrite"] as const) {
|
|
137
|
+
const v = (a[k] ?? 0) + (b[k] ?? 0)
|
|
138
|
+
if (a[k] !== undefined || b[k] !== undefined) out[k] = v
|
|
139
|
+
}
|
|
140
|
+
return out
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/**
|
|
144
|
+
* Builds the portable turns from Codex items in order. Shared by rollouts and legacy .json sessions, which
|
|
145
|
+
* hold the same response items (or, in the oldest files, Chat-Completions-style messages).
|
|
146
|
+
*/
|
|
147
|
+
function builder(h: SessionHeader) {
|
|
148
|
+
const turns: Turn[] = []
|
|
149
|
+
const lost = lossCounter()
|
|
150
|
+
const said = { user: textBag(), assistant: textBag(), reasoning: textBag() }
|
|
151
|
+
let model: string | undefined
|
|
152
|
+
let generated = 0
|
|
153
|
+
/** Calls still waiting for a result, for outputs that carry no call id. */
|
|
154
|
+
const pending: Array<{ id: string; name: string }> = []
|
|
155
|
+
/** File edits read from an apply_patch, placed after the call's result. */
|
|
156
|
+
const edits = new Map<string, Array<Extract<Turn, { kind: "file_edit" }>>>()
|
|
157
|
+
/** Calls whose edits came from patch_apply_end events: several events of one call add up. */
|
|
158
|
+
const editsFromEvents = new Set<string>()
|
|
159
|
+
/** Calls that an event says failed or were declined: the outputs themselves carry only text. */
|
|
160
|
+
const failed = new Set<string>()
|
|
161
|
+
/** Assistant turns since the last token count, and usage counted before any assistant text. */
|
|
162
|
+
let lastAssistant = -1
|
|
163
|
+
let unassigned: Usage | undefined
|
|
164
|
+
let lastTotal: number | undefined
|
|
165
|
+
|
|
166
|
+
const at = (t: string | undefined) => (t ? { at: t } : {})
|
|
167
|
+
|
|
168
|
+
function user(text: string | undefined, when: string | undefined, images = 0, files: Attachment[] = []) {
|
|
169
|
+
if (!text?.trim() && !images) return
|
|
170
|
+
if (images > files.length) lost.add("images", images - files.length)
|
|
171
|
+
const attachments: Attachment[] = [...files, ...Array.from({ length: Math.max(0, images - files.length) }, () => ({ kind: "image" as const }))]
|
|
172
|
+
turns.push({ kind: "user", ...at(when), text: text ?? "", ...(attachments.length ? { attachments } : {}) })
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
function assistant(text: string | undefined, when: string | undefined) {
|
|
176
|
+
if (!text?.trim()) return
|
|
177
|
+
const t: Extract<Turn, { kind: "assistant" }> = { kind: "assistant", ...at(when), text, ...(model ? { model } : {}) }
|
|
178
|
+
if (unassigned) {
|
|
179
|
+
t.usage = unassigned
|
|
180
|
+
unassigned = undefined
|
|
181
|
+
}
|
|
182
|
+
turns.push(t)
|
|
183
|
+
lastAssistant = turns.length - 1
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
function reasoning(text: string | undefined, when: string | undefined) {
|
|
187
|
+
if (text?.trim()) turns.push({ kind: "reasoning", ...at(when), text })
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
function call(id: string | undefined, name: string, input: unknown, when: string | undefined, namespace?: string) {
|
|
191
|
+
const callId = id ?? `codex-call-${++generated}`
|
|
192
|
+
// A namespaced tool (spawn_agent in "collaboration", an MCP tool in "mcp__server") is known by its
|
|
193
|
+
// namespace when its own name says nothing. The portable record has no place for the namespace itself.
|
|
194
|
+
let toolKind = toolKindOf(name)
|
|
195
|
+
if (namespace) {
|
|
196
|
+
if (toolKind === "other") toolKind = toolKindOf(namespace)
|
|
197
|
+
lost.add("tool namespaces (not carried)")
|
|
198
|
+
}
|
|
199
|
+
turns.push({ kind: "tool_call", ...at(when), id: callId, name, toolKind, input })
|
|
200
|
+
pending.push({ id: callId, name })
|
|
201
|
+
// apply_patch is how Codex edits files: freeform input (custom tool) or {input} (function tool).
|
|
202
|
+
const patch = typeof input === "string" ? input : typeof (input as Row)?.input === "string" ? (input as Row).input : undefined
|
|
203
|
+
if (name === "apply_patch" && patch) {
|
|
204
|
+
edits.set(callId, patchFiles(patch).map((f) => ({ kind: "file_edit", ...at(when), path: f.path, patch: f.patch, callId })))
|
|
205
|
+
}
|
|
206
|
+
return callId
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
function result(id: string | undefined, raw: unknown, when: string | undefined, name?: string) {
|
|
210
|
+
let callId = id
|
|
211
|
+
if (!callId) {
|
|
212
|
+
// An output without an id answers the latest open call of its kind.
|
|
213
|
+
const open = [...pending].reverse().find((p) => !name || p.name === name)
|
|
214
|
+
callId = open?.id ?? `codex-result-${++generated}`
|
|
215
|
+
}
|
|
216
|
+
const i = pending.findIndex((p) => p.id === callId)
|
|
217
|
+
if (i >= 0) pending.splice(i, 1)
|
|
218
|
+
const out = outputText(raw)
|
|
219
|
+
const ok = out.ok && !failed.has(callId)
|
|
220
|
+
if (out.images) lost.add("images in tool results", out.images)
|
|
221
|
+
const c = clip(out.text)
|
|
222
|
+
turns.push({ kind: "tool_result", ...at(when), callId, ok, output: c.text, ...(c.truncated ? { truncated: true } : {}) })
|
|
223
|
+
const fe = edits.get(callId)
|
|
224
|
+
if (fe) {
|
|
225
|
+
// A patch that was rejected or did not verify changed nothing.
|
|
226
|
+
if (ok) turns.push(...fe)
|
|
227
|
+
edits.delete(callId)
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
/** The file edits in a patch_apply_end's `changes`, which is how Codex records what an apply_patch changed, whatever tool ran it. */
|
|
232
|
+
function changeEdits(changes: Row, callId: string, when: string | undefined): Array<Extract<Turn, { kind: "file_edit" }>> {
|
|
233
|
+
const out: Array<Extract<Turn, { kind: "file_edit" }>> = []
|
|
234
|
+
for (const [path, c] of Object.entries(changes)) {
|
|
235
|
+
const ch = (c ?? {}) as Row
|
|
236
|
+
let patch: string | undefined
|
|
237
|
+
if (ch.type === "update" && typeof ch.unified_diff === "string") patch = `--- a/${path}\n+++ b/${path}\n${ch.unified_diff.replace(/\n$/, "")}`
|
|
238
|
+
else if ((ch.type === "add" || ch.type === "delete") && typeof ch.content === "string") {
|
|
239
|
+
const ls = ch.content.replace(/\n$/, "").split("\n")
|
|
240
|
+
const mark = ch.type === "add" ? "+" : "-"
|
|
241
|
+
patch = `--- a/${path}\n+++ b/${path}\n@@ ${ch.type === "add" ? `-0,0 +1,${ls.length}` : `-1,${ls.length} +0,0`} @@\n${ls.map((l) => `${mark}${l}`).join("\n")}`
|
|
242
|
+
}
|
|
243
|
+
out.push({ kind: "file_edit", ...(when ? { at: when } : {}), path, ...(patch ? { patch } : {}), callId })
|
|
244
|
+
}
|
|
245
|
+
return out
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
/** Learns from the events that carry a status (patch_apply_end, mcp_tool_call_end, completed items) which calls failed. */
|
|
249
|
+
function status(p: Row, when?: string) {
|
|
250
|
+
const bad = (s: unknown) => s === "failed" || s === "declined"
|
|
251
|
+
if (p.type === "patch_apply_end" && str(p.call_id)) {
|
|
252
|
+
if (p.success === false || bad(p.status)) failed.add(p.call_id)
|
|
253
|
+
// A patch run through exec_command (Codex intercepts it) has no apply_patch call to read the edit from.
|
|
254
|
+
if (p.changes && typeof p.changes === "object" && (!edits.has(p.call_id) || editsFromEvents.has(p.call_id))) {
|
|
255
|
+
const fe = changeEdits(p.changes as Row, p.call_id, when)
|
|
256
|
+
if (fe.length) {
|
|
257
|
+
edits.set(p.call_id, [...(edits.get(p.call_id) ?? []), ...fe])
|
|
258
|
+
editsFromEvents.add(p.call_id)
|
|
259
|
+
}
|
|
260
|
+
}
|
|
261
|
+
}
|
|
262
|
+
if (p.type === "mcp_tool_call_end" && str(p.call_id)) {
|
|
263
|
+
const r = (p.result ?? {}) as Row
|
|
264
|
+
if (r.Err !== undefined || r.Ok?.isError === true || r.Ok?.is_error === true) failed.add(p.call_id)
|
|
265
|
+
}
|
|
266
|
+
if (p.type === "item_completed") {
|
|
267
|
+
const it = (p.item ?? {}) as Row
|
|
268
|
+
const tool = it.type === "FileChange" || it.type === "CommandExecution" || it.type === "McpToolCall"
|
|
269
|
+
if (tool && str(it.id) && (bad(it.status) || (typeof it.exit_code === "number" && it.exit_code !== 0))) failed.add(it.id)
|
|
270
|
+
}
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
/** One response item: the model-visible history. */
|
|
274
|
+
function item(p: Row, when: string | undefined) {
|
|
275
|
+
switch (p.type) {
|
|
276
|
+
case "message": {
|
|
277
|
+
const m = messageParts(p.content)
|
|
278
|
+
if (p.role === "user") {
|
|
279
|
+
// A message can hold Codex's context next to what the person typed; only the latter is carried.
|
|
280
|
+
if (m.context) lost.add("Codex context and instructions (not carried)")
|
|
281
|
+
if (m.text || m.images) user(m.text, when, m.images, m.files)
|
|
282
|
+
} else if (p.role === "assistant") {
|
|
283
|
+
assistant(m.text, when)
|
|
284
|
+
} else {
|
|
285
|
+
lost.add("Codex context and instructions (not carried)")
|
|
286
|
+
}
|
|
287
|
+
break
|
|
288
|
+
}
|
|
289
|
+
case "reasoning": {
|
|
290
|
+
const { summary, raw } = reasoningParts(p)
|
|
291
|
+
reasoning((summary.length ? summary : raw).join("\n\n"), when)
|
|
292
|
+
if (summary.length && raw.length) lost.add("raw reasoning text, its summary kept")
|
|
293
|
+
if (p.encrypted_content) lost.add("encrypted reasoning")
|
|
294
|
+
break
|
|
295
|
+
}
|
|
296
|
+
case "function_call":
|
|
297
|
+
call(str(p.call_id), str(p.name) ?? "unknown", parseArgs(p.arguments), when, str(p.namespace))
|
|
298
|
+
if (Array.isArray(p.encrypted_function_args) && p.encrypted_function_args.length) lost.add("encrypted tool arguments")
|
|
299
|
+
break
|
|
300
|
+
case "custom_tool_call":
|
|
301
|
+
call(str(p.call_id), str(p.name) ?? "unknown", p.input ?? "", when, str(p.namespace))
|
|
302
|
+
break
|
|
303
|
+
case "local_shell_call": {
|
|
304
|
+
const id = call(str(p.call_id), "local_shell", p.action ?? {}, when)
|
|
305
|
+
// Without a call id no output can answer it; close it so the pair stays whole.
|
|
306
|
+
if (!str(p.call_id)) result(id, `(local shell call ${str(p.status) ?? "recorded"}; no output was recorded)`, when)
|
|
307
|
+
break
|
|
308
|
+
}
|
|
309
|
+
case "tool_search_call":
|
|
310
|
+
call(str(p.call_id), "tool_search", p.arguments ?? {}, when)
|
|
311
|
+
break
|
|
312
|
+
case "function_call_output":
|
|
313
|
+
case "custom_tool_call_output":
|
|
314
|
+
result(str(p.call_id), p.output, when)
|
|
315
|
+
break
|
|
316
|
+
case "tool_search_output":
|
|
317
|
+
result(str(p.call_id), JSON.stringify(p.tools ?? []), when, "tool_search")
|
|
318
|
+
break
|
|
319
|
+
case "web_search_call": {
|
|
320
|
+
// The search results went into the model's context on OpenAI's side and are not in the rollout.
|
|
321
|
+
const id = call(str(p.id), "web_search", p.action ?? {}, when)
|
|
322
|
+
result(id, `(web search ${str(p.status) ?? "ran"}; its results are not recorded)`, when)
|
|
323
|
+
lost.add("web search results")
|
|
324
|
+
break
|
|
325
|
+
}
|
|
326
|
+
case "image_generation_call":
|
|
327
|
+
lost.add("generated images")
|
|
328
|
+
break
|
|
329
|
+
case "compaction":
|
|
330
|
+
case "compaction_summary":
|
|
331
|
+
case "context_compaction":
|
|
332
|
+
lost.add("encrypted compaction state")
|
|
333
|
+
break
|
|
334
|
+
case "agent_message":
|
|
335
|
+
lost.add("messages between agents")
|
|
336
|
+
break
|
|
337
|
+
case "additional_tools":
|
|
338
|
+
case "compaction_trigger":
|
|
339
|
+
break
|
|
340
|
+
default:
|
|
341
|
+
lost.add(`unknown response items: ${String(p.type)}`)
|
|
342
|
+
}
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
/** Learns what the response items say before the walk, so their event copies are recognised. */
|
|
346
|
+
function remember(p: Row) {
|
|
347
|
+
if (p.type === "message") {
|
|
348
|
+
const m = messageParts(p.content)
|
|
349
|
+
const key = userKey(m.text, m.images)
|
|
350
|
+
if (key && p.role === "user") said.user.add(key)
|
|
351
|
+
if (m.text && p.role === "assistant") said.assistant.add(m.text)
|
|
352
|
+
} else if (p.type === "reasoning") {
|
|
353
|
+
// Events repeat a reasoning item part by part (agent_reasoning, agent_reasoning_raw_content) or whole
|
|
354
|
+
// (item_completed), summary and raw content alike, so every form is matched.
|
|
355
|
+
const { summary, raw } = reasoningParts(p)
|
|
356
|
+
for (const parts of [summary, raw]) {
|
|
357
|
+
for (const s of parts) said.reasoning.add(s)
|
|
358
|
+
if (parts.length > 1) said.reasoning.add(parts.join("\n\n"))
|
|
359
|
+
}
|
|
360
|
+
}
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
function usage(info: Row | null | undefined) {
|
|
364
|
+
const u = info?.last_token_usage as Row | undefined
|
|
365
|
+
if (!u) return
|
|
366
|
+
// The same totals are sometimes reported twice (a rate-limit refresh); count each request once.
|
|
367
|
+
const total = (info?.total_token_usage as Row | undefined)?.total_tokens
|
|
368
|
+
if (typeof total === "number" && total === lastTotal) return
|
|
369
|
+
if (typeof total === "number") lastTotal = total
|
|
370
|
+
const n = (v: unknown) => (typeof v === "number" ? v : 0)
|
|
371
|
+
// OpenAI counts cached tokens inside input_tokens; the portable record keeps them apart.
|
|
372
|
+
const cached = n(u.cached_input_tokens)
|
|
373
|
+
const step: Usage = { input: Math.max(0, n(u.input_tokens) - cached), output: n(u.output_tokens), cacheRead: cached }
|
|
374
|
+
if (n(u.cache_write_input_tokens)) step.cacheWrite = n(u.cache_write_input_tokens)
|
|
375
|
+
const t = turns[lastAssistant]
|
|
376
|
+
if (lastAssistant >= 0 && t?.kind === "assistant") t.usage = addUsage(t.usage, step)
|
|
377
|
+
else unassigned = addUsage(unassigned, step)
|
|
378
|
+
lastAssistant = -1
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
/** One UI event: used only for content no response item carries. */
|
|
382
|
+
function event(p: Row, when: string | undefined) {
|
|
383
|
+
switch (p.type) {
|
|
384
|
+
case "user_message": {
|
|
385
|
+
const text = str(p.message)
|
|
386
|
+
const images = (Array.isArray(p.images) ? p.images.length : 0) + (Array.isArray(p.local_images) ? p.local_images.length : 0)
|
|
387
|
+
const key = userKey(text, images)
|
|
388
|
+
if (key && said.user.take(key)) break
|
|
389
|
+
if (text && isContext(text)) break
|
|
390
|
+
const files = (Array.isArray(p.images) ? p.images : []).flatMap((u: unknown) => {
|
|
391
|
+
const d = parseDataUrl(u)
|
|
392
|
+
const kept = d ? keepImage(d.base64, d.mime) : undefined
|
|
393
|
+
return kept ? [kept] : []
|
|
394
|
+
})
|
|
395
|
+
user(text, when, images, files)
|
|
396
|
+
break
|
|
397
|
+
}
|
|
398
|
+
case "agent_message": {
|
|
399
|
+
const text = str(p.message)
|
|
400
|
+
if (!text || text === IMPORT_MARKER || said.assistant.take(text)) break
|
|
401
|
+
assistant(text, when)
|
|
402
|
+
break
|
|
403
|
+
}
|
|
404
|
+
case "agent_reasoning":
|
|
405
|
+
case "agent_reasoning_raw_content": {
|
|
406
|
+
const text = str(p.text)
|
|
407
|
+
if (text && !said.reasoning.take(text)) reasoning(text, when)
|
|
408
|
+
break
|
|
409
|
+
}
|
|
410
|
+
case "item_completed": {
|
|
411
|
+
const it = (p.item ?? {}) as Row
|
|
412
|
+
if (it.type === "UserMessage") {
|
|
413
|
+
const m = messageParts(it.content)
|
|
414
|
+
const key = userKey(m.text, m.images)
|
|
415
|
+
if (key && said.user.take(key)) break
|
|
416
|
+
if (m.text && isContext(m.text)) break
|
|
417
|
+
user(m.text, when, m.images, m.files)
|
|
418
|
+
} else if (it.type === "AgentMessage") {
|
|
419
|
+
const m = messageParts(it.content)
|
|
420
|
+
if (m.text && m.text !== IMPORT_MARKER && !said.assistant.take(m.text)) assistant(m.text, when)
|
|
421
|
+
} else if (it.type === "Reasoning") {
|
|
422
|
+
const texts = (v: unknown) => (Array.isArray(v) ? v : []).filter((s): s is string => typeof s === "string" && !!s.trim())
|
|
423
|
+
const parts = texts(it.summary_text).length ? texts(it.summary_text) : texts(it.raw_content)
|
|
424
|
+
const text = parts.join("\n\n")
|
|
425
|
+
if (text && !said.reasoning.take(text) && !parts.every((s) => said.reasoning.take(s))) reasoning(text, when)
|
|
426
|
+
}
|
|
427
|
+
// Other completed items (commands, patches, searches) are UI projections of response items.
|
|
428
|
+
break
|
|
429
|
+
}
|
|
430
|
+
case "token_count":
|
|
431
|
+
usage(p.info)
|
|
432
|
+
break
|
|
433
|
+
case "thread_rolled_back": {
|
|
434
|
+
// Codex drops the last N user turns from the model's context; so does the portable record.
|
|
435
|
+
let n = typeof p.num_turns === "number" ? p.num_turns : 0
|
|
436
|
+
let cut = -1
|
|
437
|
+
for (let i = turns.length - 1; i >= 0 && n > 0; i--) {
|
|
438
|
+
if (turns[i]!.kind === "user") {
|
|
439
|
+
cut = i
|
|
440
|
+
n--
|
|
441
|
+
}
|
|
442
|
+
}
|
|
443
|
+
if (cut >= 0) {
|
|
444
|
+
lost.add("turns rolled back in Codex", turns.slice(cut).filter((t) => t.kind === "user").length)
|
|
445
|
+
turns.splice(cut)
|
|
446
|
+
lastAssistant = -1
|
|
447
|
+
}
|
|
448
|
+
break
|
|
449
|
+
}
|
|
450
|
+
default:
|
|
451
|
+
if (!KNOWN_EVENTS.has(p.type)) lost.add(`unknown event types: ${String(p.type)}`)
|
|
452
|
+
}
|
|
453
|
+
}
|
|
454
|
+
|
|
455
|
+
function setModel(m: unknown) {
|
|
456
|
+
if (typeof m === "string" && m) {
|
|
457
|
+
model = m
|
|
458
|
+
noteModel(h, m)
|
|
459
|
+
}
|
|
460
|
+
}
|
|
461
|
+
|
|
462
|
+
function finish(): Turn[] {
|
|
463
|
+
// Edits whose call never got a result still happened as far as the record shows, unless an event says
|
|
464
|
+
// the patch failed. They go right after their call, where they happened, not at the end of the session.
|
|
465
|
+
for (const [callId, fe] of edits) {
|
|
466
|
+
if (failed.has(callId)) continue
|
|
467
|
+
const i = turns.findIndex((t) => t.kind === "tool_call" && t.id === callId)
|
|
468
|
+
if (i >= 0) turns.splice(i + 1, 0, ...fe)
|
|
469
|
+
else turns.push(...fe)
|
|
470
|
+
}
|
|
471
|
+
edits.clear()
|
|
472
|
+
lost.flush(h)
|
|
473
|
+
return turns
|
|
474
|
+
}
|
|
475
|
+
|
|
476
|
+
return { turns, lost, item, remember, status, event, usage, setModel, user, assistant, call, result, finish }
|
|
477
|
+
}
|
|
478
|
+
|
|
479
|
+
/** The portable session for a rollout's text (JSONL). */
|
|
480
|
+
export function parseRollout(text: string, path?: string): Session {
|
|
481
|
+
const { rows, bad } = parseLines(text)
|
|
482
|
+
const metaRow = rows.find((r) => r.type === "session_meta")
|
|
483
|
+
const meta = (metaRow?.payload ?? {}) as Row
|
|
484
|
+
const id = str(meta.id) ?? (path ? idFromName(path) : undefined)
|
|
485
|
+
if (!id) throw new Error("not a Codex rollout: no session_meta id and no id in the file name")
|
|
486
|
+
const cwd = str(meta.cwd)
|
|
487
|
+
const git = meta.git as Row | undefined
|
|
488
|
+
const project: Project = { ...(cwd ? { cwd } : {}) }
|
|
489
|
+
if (git && (git.repository_url || git.branch || git.commit_hash)) {
|
|
490
|
+
if (str(git.repository_url)) project.gitRemote = git.repository_url
|
|
491
|
+
if (str(git.branch)) project.gitBranch = git.branch
|
|
492
|
+
if (str(git.commit_hash)) project.gitCommit = git.commit_hash
|
|
493
|
+
} else Object.assign(project, gitInfo(cwd))
|
|
494
|
+
const h = header(TOOL, id, {
|
|
495
|
+
...(path ? { sourcePath: path } : {}),
|
|
496
|
+
...(str(meta.cli_version) ? { toolVersion: meta.cli_version } : {}),
|
|
497
|
+
project,
|
|
498
|
+
})
|
|
499
|
+
const started = iso(meta.timestamp) ?? iso(rows[0]?.timestamp)
|
|
500
|
+
if (started) h.startedAt = started
|
|
501
|
+
const times = rows.map((r) => iso(r.timestamp)).filter((t): t is string => !!t)
|
|
502
|
+
if (times.length) h.updatedAt = times.reduce((a, b) => (b > a ? b : a))
|
|
503
|
+
if (str(meta.forked_from_id)) h.parent = { id: `${TOOL}:${meta.forked_from_id}`, kind: "fork" }
|
|
504
|
+
else if (str(meta.parent_thread_id)) h.parent = { id: `${TOOL}:${meta.parent_thread_id}`, kind: "subagent" }
|
|
505
|
+
|
|
506
|
+
const b = builder(h)
|
|
507
|
+
if (bad) b.lost.add("unreadable lines", bad)
|
|
508
|
+
// A paginated fork holds only its own turns; the inherited prefix stays in the parent's rollout.
|
|
509
|
+
if (meta.history_base) b.lost.add("history inherited from the parent thread (in the parent's file)")
|
|
510
|
+
for (const r of rows) {
|
|
511
|
+
if (r.type === "response_item" && r.payload) b.remember(r.payload)
|
|
512
|
+
else if (r.type === "event_msg" && r.payload) b.status(r.payload, iso(r.timestamp))
|
|
513
|
+
}
|
|
514
|
+
|
|
515
|
+
for (const r of rows) {
|
|
516
|
+
const when = iso(r.timestamp)
|
|
517
|
+
const p = (r.payload ?? {}) as Row
|
|
518
|
+
switch (r.type) {
|
|
519
|
+
case "session_meta":
|
|
520
|
+
// The first is the header; later ones were copied in from a forked parent.
|
|
521
|
+
break
|
|
522
|
+
case "response_item":
|
|
523
|
+
b.item(p, when)
|
|
524
|
+
break
|
|
525
|
+
case "event_msg":
|
|
526
|
+
if (p.type === "thread_settings_applied") b.setModel((p.thread_settings as Row | undefined)?.model)
|
|
527
|
+
b.event(p, when)
|
|
528
|
+
break
|
|
529
|
+
case "turn_context":
|
|
530
|
+
b.setModel(p.model)
|
|
531
|
+
if (!h.project.cwd && str(p.cwd)) h.project.cwd = p.cwd
|
|
532
|
+
break
|
|
533
|
+
case "compacted":
|
|
534
|
+
if (str(p.message)?.trim()) b.turns.push({ kind: "compaction", ...(when ? { at: when } : {}), summary: summaryBody(p.message) })
|
|
535
|
+
else b.lost.add("compactions without a readable summary")
|
|
536
|
+
if (Array.isArray(p.replacement_history) && p.replacement_history.some((i: Row) => /compaction/.test(String(i?.type))))
|
|
537
|
+
b.lost.add("encrypted compaction state")
|
|
538
|
+
break
|
|
539
|
+
case "inter_agent_communication":
|
|
540
|
+
b.lost.add("messages between agents")
|
|
541
|
+
break
|
|
542
|
+
default:
|
|
543
|
+
if (!QUIET_RECORDS.has(r.type)) b.lost.add(`unknown record types: ${String(r.type)}`)
|
|
544
|
+
}
|
|
545
|
+
}
|
|
546
|
+
return { header: h, turns: b.finish() }
|
|
547
|
+
}
|
|
548
|
+
|
|
549
|
+
/** The portable session for a legacy single-file session: {session {id, timestamp, instructions}, items[]}. */
|
|
550
|
+
export function parseLegacyJson(data: unknown, path?: string): Session {
|
|
551
|
+
const d = (data ?? {}) as Row
|
|
552
|
+
const s = (d.session ?? {}) as Row
|
|
553
|
+
const id = str(s.id) ?? (path ? idFromName(path) : undefined)
|
|
554
|
+
if (!id || !Array.isArray(d.items)) throw new Error("not a legacy Codex session: expected {session, items}")
|
|
555
|
+
const h = header(TOOL, id, { ...(path ? { sourcePath: path } : {}) })
|
|
556
|
+
const started = iso(s.timestamp)
|
|
557
|
+
if (started) h.startedAt = started
|
|
558
|
+
const b = builder(h)
|
|
559
|
+
if (str(s.instructions)) b.lost.add("Codex context and instructions (not carried)")
|
|
560
|
+
for (const it of d.items as Row[]) if (it?.type) b.remember(it)
|
|
561
|
+
for (const it of d.items as Row[]) {
|
|
562
|
+
if (!it || typeof it !== "object") continue
|
|
563
|
+
if (it.type) {
|
|
564
|
+
b.item(it, undefined)
|
|
565
|
+
continue
|
|
566
|
+
}
|
|
567
|
+
// The oldest files hold Chat-Completions-style messages: {role, content, tool_calls, tool_call_id}.
|
|
568
|
+
const m = messageParts(it.content)
|
|
569
|
+
switch (it.role) {
|
|
570
|
+
case "user":
|
|
571
|
+
if (m.context) b.lost.add("Codex context and instructions (not carried)")
|
|
572
|
+
if (m.text || m.images) b.user(m.text, undefined, m.images, m.files)
|
|
573
|
+
break
|
|
574
|
+
case "assistant":
|
|
575
|
+
b.assistant(m.text, undefined)
|
|
576
|
+
for (const tc of (it.tool_calls ?? []) as Row[]) b.call(str(tc?.id), str(tc?.function?.name) ?? "unknown", parseArgs(tc?.function?.arguments), undefined)
|
|
577
|
+
break
|
|
578
|
+
case "tool":
|
|
579
|
+
b.result(str(it.tool_call_id), it.content, undefined)
|
|
580
|
+
break
|
|
581
|
+
case "system":
|
|
582
|
+
case "developer":
|
|
583
|
+
b.lost.add("Codex context and instructions (not carried)")
|
|
584
|
+
break
|
|
585
|
+
default:
|
|
586
|
+
b.lost.add(`unknown item roles: ${String(it.role)}`)
|
|
587
|
+
}
|
|
588
|
+
}
|
|
589
|
+
return { header: h, turns: b.finish() }
|
|
590
|
+
}
|
|
591
|
+
|
|
592
|
+
/** Decompressed text of a .jsonl.zst rollout, or undefined when this runtime has no zstd. */
|
|
593
|
+
function unzstd(path: string): string | undefined {
|
|
594
|
+
if (typeof Bun.zstdDecompressSync !== "function") return undefined
|
|
595
|
+
return Bun.zstdDecompressSync(readFileSync(path)).toString("utf8")
|
|
596
|
+
}
|
|
597
|
+
|
|
598
|
+
/** The first line of a file, read in chunks: session_meta carries the base instructions, often tens of KB. */
|
|
599
|
+
function headLine(path: string, max = 8 << 20): string | undefined {
|
|
600
|
+
if (path.endsWith(".zst")) return unzstd(path)?.split("\n", 1)[0]
|
|
601
|
+
const fd = openSync(path, "r")
|
|
602
|
+
try {
|
|
603
|
+
const chunks: Buffer[] = []
|
|
604
|
+
const buf = Buffer.alloc(64 * 1024)
|
|
605
|
+
let pos = 0
|
|
606
|
+
while (pos < max) {
|
|
607
|
+
const n = readSync(fd, buf, 0, buf.length, pos)
|
|
608
|
+
if (n <= 0) break
|
|
609
|
+
const nl = buf.subarray(0, n).indexOf(10)
|
|
610
|
+
chunks.push(Buffer.from(buf.subarray(0, nl >= 0 ? nl : n)))
|
|
611
|
+
if (nl >= 0) break
|
|
612
|
+
pos += n
|
|
613
|
+
}
|
|
614
|
+
return chunks.length ? Buffer.concat(chunks).toString("utf8") : undefined
|
|
615
|
+
} finally {
|
|
616
|
+
closeSync(fd)
|
|
617
|
+
}
|
|
618
|
+
}
|
|
619
|
+
|
|
620
|
+
interface ThreadRow {
|
|
621
|
+
title?: string
|
|
622
|
+
updatedAt?: string
|
|
623
|
+
cwd?: string
|
|
624
|
+
}
|
|
625
|
+
|
|
626
|
+
const oneLine = (s: string) => {
|
|
627
|
+
const line = s.trim().split("\n", 1)[0]!.trim()
|
|
628
|
+
return line.length > 120 ? `${line.slice(0, 119)}…` : line
|
|
629
|
+
}
|
|
630
|
+
|
|
631
|
+
/** Titles and times from Codex's thread index (state_<n>.sqlite, newest schema number), by thread id. */
|
|
632
|
+
function threadIndex(home: string): Map<string, ThreadRow> {
|
|
633
|
+
const out = new Map<string, ThreadRow>()
|
|
634
|
+
const dbs = ls(codexDir(home))
|
|
635
|
+
.map((n) => ({ n, v: Number(n.match(/^state_(\d+)\.sqlite$/)?.[1]) }))
|
|
636
|
+
.filter((x) => Number.isFinite(x.v))
|
|
637
|
+
.sort((a, b) => b.v - a.v)
|
|
638
|
+
if (!dbs.length) return out
|
|
639
|
+
try {
|
|
640
|
+
withSnapshot(join(codexDir(home), dbs[0]!.n), (db) => {
|
|
641
|
+
for (const r of db.query("SELECT * FROM threads").all() as Row[]) {
|
|
642
|
+
const id = str(r.id)
|
|
643
|
+
if (!id) continue
|
|
644
|
+
// A name the person gave wins over Codex's title, which is the first message.
|
|
645
|
+
const title = str(r.name) ?? str(r.title) ?? str(r.first_user_message)
|
|
646
|
+
const updated = iso(r.updated_at_ms) ?? iso(r.updated_at)
|
|
647
|
+
out.set(id, { ...(title ? { title: oneLine(title) } : {}), ...(updated ? { updatedAt: updated } : {}), ...(str(r.cwd) ? { cwd: r.cwd } : {}) })
|
|
648
|
+
}
|
|
649
|
+
})
|
|
650
|
+
} catch {
|
|
651
|
+
// An index that cannot be read (locked, newer schema) only costs titles; the rollouts are the record.
|
|
652
|
+
}
|
|
653
|
+
return out
|
|
654
|
+
}
|
|
655
|
+
|
|
656
|
+
function listOne(path: string, archived: boolean, index: Map<string, ThreadRow>): SessionRef | undefined {
|
|
657
|
+
const format = path.endsWith(".zst") ? "jsonl.zst" : path.endsWith(".json") ? "json" : "jsonl"
|
|
658
|
+
let id = idFromName(path)
|
|
659
|
+
let cwd: string | undefined
|
|
660
|
+
let version: string | undefined
|
|
661
|
+
let title: string | undefined
|
|
662
|
+
if (format === "json") {
|
|
663
|
+
// Legacy sessions are small single files and the index never had them, so read the first request here.
|
|
664
|
+
const d = readJson<Row>(path)
|
|
665
|
+
id = str(d?.session?.id) ?? id
|
|
666
|
+
const first = ((d?.items ?? []) as Row[]).find((i) => i?.role === "user" && messageParts(i.content).text)
|
|
667
|
+
const text = first && messageParts(first.content).text
|
|
668
|
+
if (text) title = oneLine(text)
|
|
669
|
+
} else {
|
|
670
|
+
try {
|
|
671
|
+
const line = headLine(path)
|
|
672
|
+
const meta = line ? (JSON.parse(line) as Row) : undefined
|
|
673
|
+
if (meta?.type === "session_meta") {
|
|
674
|
+
id = str(meta.payload?.id) ?? id
|
|
675
|
+
cwd = str(meta.payload?.cwd)
|
|
676
|
+
version = str(meta.payload?.cli_version)
|
|
677
|
+
}
|
|
678
|
+
} catch {
|
|
679
|
+
// Listed from its file name; read() says what is wrong with it.
|
|
680
|
+
}
|
|
681
|
+
}
|
|
682
|
+
if (!id) return undefined
|
|
683
|
+
const row = index.get(id)
|
|
684
|
+
title = row?.title ?? title
|
|
685
|
+
cwd = cwd ?? row?.cwd
|
|
686
|
+
const updatedAt = row?.updatedAt ?? mtime(path)
|
|
687
|
+
return {
|
|
688
|
+
tool: TOOL,
|
|
689
|
+
sourceId: id,
|
|
690
|
+
path,
|
|
691
|
+
...(title ? { title } : {}),
|
|
692
|
+
...(cwd ? { cwd } : {}),
|
|
693
|
+
...(updatedAt ? { updatedAt } : {}),
|
|
694
|
+
extra: { format, archived, ...(version ? { version } : {}) },
|
|
695
|
+
}
|
|
696
|
+
}
|
|
697
|
+
|
|
698
|
+
export const codexReader: Reader = {
|
|
699
|
+
tool: TOOL,
|
|
700
|
+
label: LABEL,
|
|
701
|
+
|
|
702
|
+
async list(home) {
|
|
703
|
+
const roots = [
|
|
704
|
+
{ dir: join(codexDir(home), "sessions"), archived: false },
|
|
705
|
+
{ dir: join(codexDir(home), "archived_sessions"), archived: true },
|
|
706
|
+
]
|
|
707
|
+
const files = roots.flatMap((r) => findFiles(r.dir, isRolloutName).map((p) => ({ p, archived: r.archived })))
|
|
708
|
+
if (!files.length) return []
|
|
709
|
+
const index = threadIndex(home)
|
|
710
|
+
const refs: SessionRef[] = []
|
|
711
|
+
for (const f of files) {
|
|
712
|
+
const ref = listOne(f.p, f.archived, index)
|
|
713
|
+
if (ref) refs.push(ref)
|
|
714
|
+
}
|
|
715
|
+
return refs.sort((a, b) => (b.updatedAt ?? "").localeCompare(a.updatedAt ?? ""))
|
|
716
|
+
},
|
|
717
|
+
|
|
718
|
+
async read(ref) {
|
|
719
|
+
const path = ref.path
|
|
720
|
+
if (!exists(path)) throw new Error(`the Codex session file is gone: ${path}`)
|
|
721
|
+
let s: Session
|
|
722
|
+
if (path.endsWith(".json")) {
|
|
723
|
+
const d = readJson(path)
|
|
724
|
+
if (d === undefined) throw new Error(`the Codex session file is not JSON: ${path}`)
|
|
725
|
+
s = parseLegacyJson(d, path)
|
|
726
|
+
} else if (path.endsWith(".zst")) {
|
|
727
|
+
const text = unzstd(path)
|
|
728
|
+
if (text === undefined) {
|
|
729
|
+
// Nothing to decompress with: say so rather than fail the whole listing.
|
|
730
|
+
s = { header: header(TOOL, ref.sourceId, { sourcePath: path, lost: ["compressed rollout not read, no zstd in this runtime (1)"] }), turns: [] }
|
|
731
|
+
} else s = parseRollout(text, path)
|
|
732
|
+
} else {
|
|
733
|
+
s = parseRollout(readFileSync(path, "utf8"), path)
|
|
734
|
+
}
|
|
735
|
+
if (!s.header.title && ref.title) s.header.title = ref.title
|
|
736
|
+
if (!s.header.title) {
|
|
737
|
+
const first = s.turns.find((t) => t.kind === "user" && t.text.trim()) as Extract<Turn, { kind: "user" }> | undefined
|
|
738
|
+
if (first) s.header.title = oneLine(first.text)
|
|
739
|
+
}
|
|
740
|
+
if (!s.header.updatedAt) {
|
|
741
|
+
const m = mtime(path)
|
|
742
|
+
if (m) s.header.updatedAt = m
|
|
743
|
+
}
|
|
744
|
+
if (!s.header.project.cwd && ref.cwd) s.header.project.cwd = ref.cwd
|
|
745
|
+
return s
|
|
746
|
+
},
|
|
747
|
+
}
|