@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.
Files changed (213) hide show
  1. package/LICENSE +48 -0
  2. package/README.md +172 -2
  3. package/package.json +48 -4
  4. package/src/adapters/aider/index.ts +8 -0
  5. package/src/adapters/aider/reader.ts +129 -0
  6. package/src/adapters/aider/records.ts +220 -0
  7. package/src/adapters/aider/writer.ts +269 -0
  8. package/src/adapters/amazon-q-cli/index.ts +8 -0
  9. package/src/adapters/amazon-q-cli/reader.ts +84 -0
  10. package/src/adapters/amazon-q-cli/records.ts +191 -0
  11. package/src/adapters/amazon-q-cli/writer.ts +370 -0
  12. package/src/adapters/amazon-q-ide/index.ts +9 -0
  13. package/src/adapters/amazon-q-ide/reader.ts +131 -0
  14. package/src/adapters/amazon-q-ide/records.ts +141 -0
  15. package/src/adapters/amazon-q-ide/writer.ts +325 -0
  16. package/src/adapters/antigravity/index.ts +9 -0
  17. package/src/adapters/antigravity/proto.ts +194 -0
  18. package/src/adapters/antigravity/reader.ts +424 -0
  19. package/src/adapters/antigravity/records.ts +236 -0
  20. package/src/adapters/antigravity/writer.ts +463 -0
  21. package/src/adapters/auggie/index.ts +36 -0
  22. package/src/adapters/claude-code/index.ts +8 -0
  23. package/src/adapters/claude-code/reader.ts +639 -0
  24. package/src/adapters/claude-code/records.ts +88 -0
  25. package/src/adapters/claude-code/writer.ts +329 -0
  26. package/src/adapters/cline/index.ts +12 -0
  27. package/src/adapters/cline/reader.ts +432 -0
  28. package/src/adapters/cline/records.ts +151 -0
  29. package/src/adapters/cline/vscdb.ts +56 -0
  30. package/src/adapters/cline/writer.ts +656 -0
  31. package/src/adapters/cline/xml.ts +177 -0
  32. package/src/adapters/cline4/index.ts +9 -0
  33. package/src/adapters/cline4/reader.ts +179 -0
  34. package/src/adapters/cline4/records.ts +92 -0
  35. package/src/adapters/cline4/writer.ts +361 -0
  36. package/src/adapters/codebuff/index.ts +8 -0
  37. package/src/adapters/codebuff/project.ts +207 -0
  38. package/src/adapters/codebuff/reader.ts +215 -0
  39. package/src/adapters/codebuff/records.ts +173 -0
  40. package/src/adapters/codebuff/writer.ts +383 -0
  41. package/src/adapters/codex/index.ts +8 -0
  42. package/src/adapters/codex/native.ts +239 -0
  43. package/src/adapters/codex/reader.ts +747 -0
  44. package/src/adapters/codex/records.ts +114 -0
  45. package/src/adapters/codex/writer.ts +479 -0
  46. package/src/adapters/continue/index.ts +9 -0
  47. package/src/adapters/continue/reader.ts +68 -0
  48. package/src/adapters/continue/records.ts +418 -0
  49. package/src/adapters/continue/writer.ts +162 -0
  50. package/src/adapters/copilot-cli/index.ts +9 -0
  51. package/src/adapters/copilot-cli/reader.ts +186 -0
  52. package/src/adapters/copilot-cli/records.ts +241 -0
  53. package/src/adapters/copilot-cli/writer.ts +353 -0
  54. package/src/adapters/crush/index.ts +8 -0
  55. package/src/adapters/crush/reader.ts +180 -0
  56. package/src/adapters/crush/records.ts +169 -0
  57. package/src/adapters/crush/writer.ts +333 -0
  58. package/src/adapters/cursor-cli/index.ts +405 -0
  59. package/src/adapters/cursor-ide/agentkv.ts +95 -0
  60. package/src/adapters/cursor-ide/index.ts +501 -0
  61. package/src/adapters/cursor-ide/transcripts.ts +122 -0
  62. package/src/adapters/cursor-ide/workspaces.ts +119 -0
  63. package/src/adapters/cursor-ide/writer.ts +170 -0
  64. package/src/adapters/devin/index.ts +8 -0
  65. package/src/adapters/devin/reader.ts +305 -0
  66. package/src/adapters/devin/records.ts +277 -0
  67. package/src/adapters/devin/writer.ts +523 -0
  68. package/src/adapters/factory-droid/index.ts +12 -0
  69. package/src/adapters/factory-droid/reader.ts +184 -0
  70. package/src/adapters/factory-droid/records.ts +79 -0
  71. package/src/adapters/factory-droid/writer.ts +350 -0
  72. package/src/adapters/gemini-cli/exchanges.ts +228 -0
  73. package/src/adapters/gemini-cli/index.ts +396 -0
  74. package/src/adapters/gemini-cli/records.ts +521 -0
  75. package/src/adapters/gemini-cli/writer.ts +354 -0
  76. package/src/adapters/goose/index.ts +8 -0
  77. package/src/adapters/goose/reader.ts +138 -0
  78. package/src/adapters/goose/records.ts +199 -0
  79. package/src/adapters/goose/writer.ts +340 -0
  80. package/src/adapters/grok-cli/image.ts +44 -0
  81. package/src/adapters/grok-cli/index.ts +8 -0
  82. package/src/adapters/grok-cli/reader.ts +302 -0
  83. package/src/adapters/grok-cli/records.ts +172 -0
  84. package/src/adapters/grok-cli/writer.ts +329 -0
  85. package/src/adapters/hermes/index.ts +8 -0
  86. package/src/adapters/hermes/reader.ts +249 -0
  87. package/src/adapters/hermes/records.ts +285 -0
  88. package/src/adapters/hermes/writer.ts +452 -0
  89. package/src/adapters/iflow-cli/index.ts +72 -0
  90. package/src/adapters/iflow-cli/records.ts +215 -0
  91. package/src/adapters/iflow-cli/writer.ts +204 -0
  92. package/src/adapters/index.ts +112 -0
  93. package/src/adapters/kilo-cli/index.ts +21 -0
  94. package/src/adapters/kilo-code/index.ts +11 -0
  95. package/src/adapters/kilo-code/writer.ts +92 -0
  96. package/src/adapters/kimi/index.ts +9 -0
  97. package/src/adapters/kimi/reader.ts +92 -0
  98. package/src/adapters/kimi/records.ts +221 -0
  99. package/src/adapters/kimi/writer.ts +367 -0
  100. package/src/adapters/kiro-cli/index.ts +8 -0
  101. package/src/adapters/kiro-cli/reader.ts +87 -0
  102. package/src/adapters/kiro-cli/records.ts +260 -0
  103. package/src/adapters/kiro-cli/writer.ts +305 -0
  104. package/src/adapters/kiro-ide/index.ts +8 -0
  105. package/src/adapters/kiro-ide/writer.ts +52 -0
  106. package/src/adapters/letta/index.ts +8 -0
  107. package/src/adapters/letta/reader.ts +177 -0
  108. package/src/adapters/letta/records.ts +119 -0
  109. package/src/adapters/letta/writer.ts +413 -0
  110. package/src/adapters/mastracode/index.ts +8 -0
  111. package/src/adapters/mastracode/reader.ts +323 -0
  112. package/src/adapters/mastracode/records.ts +336 -0
  113. package/src/adapters/mastracode/writer.ts +385 -0
  114. package/src/adapters/mistral-vibe/index.ts +9 -0
  115. package/src/adapters/mistral-vibe/reader.ts +62 -0
  116. package/src/adapters/mistral-vibe/records.ts +104 -0
  117. package/src/adapters/mistral-vibe/writer.ts +359 -0
  118. package/src/adapters/muse/index.ts +10 -0
  119. package/src/adapters/muse/reader.ts +332 -0
  120. package/src/adapters/muse/records.ts +104 -0
  121. package/src/adapters/muse/writer.ts +387 -0
  122. package/src/adapters/omp/index.ts +13 -0
  123. package/src/adapters/omp/reader.ts +314 -0
  124. package/src/adapters/omp/records.ts +153 -0
  125. package/src/adapters/omp/writer.ts +216 -0
  126. package/src/adapters/opencode/ids.ts +52 -0
  127. package/src/adapters/opencode/index.ts +31 -0
  128. package/src/adapters/opencode/note.ts +8 -0
  129. package/src/adapters/opencode/reader.ts +452 -0
  130. package/src/adapters/opencode/tools.ts +174 -0
  131. package/src/adapters/opencode/v2.ts +367 -0
  132. package/src/adapters/opencode/writer.ts +379 -0
  133. package/src/adapters/openhands/index.ts +8 -0
  134. package/src/adapters/openhands/reader.ts +222 -0
  135. package/src/adapters/openhands/records.ts +136 -0
  136. package/src/adapters/openhands/writer.ts +404 -0
  137. package/src/adapters/pearai/index.ts +10 -0
  138. package/src/adapters/pi/index.ts +28 -0
  139. package/src/adapters/pi/reader.ts +261 -0
  140. package/src/adapters/pi/records.ts +99 -0
  141. package/src/adapters/pi/writer.ts +362 -0
  142. package/src/adapters/qoder-cli/index.ts +8 -0
  143. package/src/adapters/qoder-cli/reader.ts +83 -0
  144. package/src/adapters/qoder-cli/records.ts +45 -0
  145. package/src/adapters/qoder-cli/writer.ts +72 -0
  146. package/src/adapters/qwen-code/index.ts +84 -0
  147. package/src/adapters/qwen-code/records.ts +194 -0
  148. package/src/adapters/qwen-code/writer.ts +224 -0
  149. package/src/adapters/tabnine-cli/index.ts +8 -0
  150. package/src/adapters/tabnine-cli/reader.ts +106 -0
  151. package/src/adapters/tabnine-cli/records.ts +33 -0
  152. package/src/adapters/tabnine-cli/writer.ts +156 -0
  153. package/src/adapters/types.ts +73 -0
  154. package/src/adapters/void/index.ts +9 -0
  155. package/src/adapters/void/reader.ts +48 -0
  156. package/src/adapters/void/records.ts +285 -0
  157. package/src/adapters/void/writer.ts +93 -0
  158. package/src/adapters/vscode-copilot/index.ts +9 -0
  159. package/src/adapters/vscode-copilot/reader.ts +212 -0
  160. package/src/adapters/vscode-copilot/records.ts +388 -0
  161. package/src/adapters/vscode-copilot/shared.ts +68 -0
  162. package/src/adapters/vscode-copilot/writer.ts +546 -0
  163. package/src/adapters/zed/index.ts +8 -0
  164. package/src/adapters/zed/reader.ts +198 -0
  165. package/src/adapters/zed/records.ts +87 -0
  166. package/src/adapters/zed/writer.ts +341 -0
  167. package/src/brief-writers.ts +71 -0
  168. package/src/brief.ts +238 -0
  169. package/src/cli.ts +326 -0
  170. package/src/fsutil.ts +133 -0
  171. package/src/gateway/connect.ts +845 -0
  172. package/src/gateway/signin.ts +323 -0
  173. package/src/handoff.ts +104 -0
  174. package/src/hooks.ts +455 -0
  175. package/src/memory/audit.ts +514 -0
  176. package/src/memory/blobs.ts +94 -0
  177. package/src/memory/cli.ts +117 -0
  178. package/src/memory/distill.ts +381 -0
  179. package/src/memory/externalize.ts +435 -0
  180. package/src/memory/lookups.ts +70 -0
  181. package/src/memory/mcp-install.ts +401 -0
  182. package/src/memory/mcp-server.ts +296 -0
  183. package/src/memory/prepare.ts +179 -0
  184. package/src/memory/preview.ts +154 -0
  185. package/src/memory/project.ts +35 -0
  186. package/src/memory/redact.ts +72 -0
  187. package/src/memory/relevance.ts +187 -0
  188. package/src/memory/repomap.ts +516 -0
  189. package/src/memory/store.ts +270 -0
  190. package/src/memory/sync.ts +166 -0
  191. package/src/memory/tidy.ts +193 -0
  192. package/src/memory/types.ts +118 -0
  193. package/src/org/admin-ops.ts +96 -0
  194. package/src/org/auth.ts +112 -0
  195. package/src/org/backup.ts +115 -0
  196. package/src/org/cli.ts +438 -0
  197. package/src/org/client.ts +404 -0
  198. package/src/org/config.ts +44 -0
  199. package/src/org/crypto.ts +537 -0
  200. package/src/org/db.ts +435 -0
  201. package/src/org/graph.ts +318 -0
  202. package/src/org/oidc.ts +435 -0
  203. package/src/org/policy.ts +398 -0
  204. package/src/org/server.ts +920 -0
  205. package/src/org/sso.ts +316 -0
  206. package/src/org/tls.ts +66 -0
  207. package/src/org/types.ts +193 -0
  208. package/src/self.ts +68 -0
  209. package/src/session/attachments.ts +53 -0
  210. package/src/session/peekcache.ts +50 -0
  211. package/src/session/types.ts +104 -0
  212. package/src/session/util.ts +130 -0
  213. 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
+ }