@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,114 @@
|
|
|
1
|
+
// Facts about Codex rollout records shared by the reader and the writer. Checked against codex-rs at
|
|
2
|
+
// rust-v0.147.0 (the version installed here) and the rollouts it wrote on this Mac; see
|
|
3
|
+
// docs/sessions/2026-11-08-session-formats.md, entries 3 and 69.
|
|
4
|
+
|
|
5
|
+
export const TOOL = "codex"
|
|
6
|
+
export const LABEL = "Codex"
|
|
7
|
+
|
|
8
|
+
/** Codex versions whose rollout format the writer was checked against. */
|
|
9
|
+
export const TESTED_VERSIONS = ["0.147.0"]
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* What Codex puts before the summary it writes at a compaction (core/src/compact.rs, templates/compact/summary_prefix.md at
|
|
13
|
+
* rust-v0.147.0, 399 bytes). Codex tells its own summaries apart by this text plus a newline, so the writer must use it exactly.
|
|
14
|
+
*/
|
|
15
|
+
export const SUMMARY_PREFIX = "Another language model started to solve this problem and produced a summary of its thinking process. You also have access to the state of the tools that were used by that language model. Use this to build on the work that has already been done and avoid duplicating work. Here is the summary produced by the other language model, use the information in this summary to assist with your own analysis:"
|
|
16
|
+
|
|
17
|
+
/** The UI-only line Codex's own /import appends after imported history (external-agent-migration export.rs). */
|
|
18
|
+
export const IMPORT_MARKER = "<EXTERNAL SESSION IMPORTED>"
|
|
19
|
+
|
|
20
|
+
// Codex puts its own context into the history as user-role messages: AGENTS.md, the environment, aborted
|
|
21
|
+
// turns, skills, hook prompts. Its TUI hides them (core/src/context/contextual_user_message.rs); so do we.
|
|
22
|
+
// The list is Codex's own, not "anything in angle brackets", so a Claude Code message such as
|
|
23
|
+
// <command-name>/clear</command-name> carried into Codex is still read back as the person's message.
|
|
24
|
+
const CONTEXT_TAGS =
|
|
25
|
+
/^<(environment_context|environments?|user_instructions|turn_aborted|user_shell_command|subagent_notification|codex_internal_context|goal_context|recommended_plugins|skill|hook_prompt|external_[\w-]+)\b/
|
|
26
|
+
const CONTEXT_PREFIXES = [
|
|
27
|
+
"# AGENTS.md instructions",
|
|
28
|
+
"Warning: apply_patch was requested via ",
|
|
29
|
+
"Warning: Your account was flagged for potentially high-risk cyber activity",
|
|
30
|
+
"Warning: The maximum number of unified exec processes you can keep open is",
|
|
31
|
+
]
|
|
32
|
+
|
|
33
|
+
/** Whether a user-role text part is Codex's injected context rather than something the person typed. */
|
|
34
|
+
export function isContext(text: string): boolean {
|
|
35
|
+
const t = text.trim()
|
|
36
|
+
return CONTEXT_TAGS.test(t) || CONTEXT_PREFIXES.some((p) => t.startsWith(p))
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/** The `<image name=[Image #1]>` / `</image>` labels Codex wraps around pasted images. */
|
|
40
|
+
export const isImageLabel = (text: string) => /^<\/?image\b[^>]*>$/.test(text.trim())
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* What Codex answers a call with when it was refused, failed before running, or was cut off: plain text
|
|
44
|
+
* with no exit code (core/src/tools/events.rs, tools/handlers/apply_patch.rs, context_manager/normalize.rs
|
|
45
|
+
* at rust-v0.147.0). Without this a rejected or unverified patch would read as a success.
|
|
46
|
+
*/
|
|
47
|
+
const FAILURE_TEXT = /^(?:aborted$|patch rejected|exec command rejected|apply_patch verification failed|apply_patch handler received|execution error:|failed to parse function arguments)/
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* The text of a tool output and whether it succeeded. Codex serialises outputs as a string or a list of
|
|
51
|
+
* content items (FunctionCallOutputPayload); older rollouts wrote {content, success}. The exit status is
|
|
52
|
+
* only in the text: "Exit code: N" (shell), "Process exited with code N" (unified exec), or a JSON
|
|
53
|
+
* {output, metadata: {exit_code}} (older exec); refusals and errors are known by their wording.
|
|
54
|
+
*/
|
|
55
|
+
export function outputText(raw: unknown): { text: string; ok: boolean; images: number } {
|
|
56
|
+
if (raw && typeof raw === "object" && !Array.isArray(raw) && "content" in raw) {
|
|
57
|
+
const o = raw as { content: unknown; success?: unknown }
|
|
58
|
+
const inner = outputText(o.content)
|
|
59
|
+
return { ...inner, ok: o.success === false ? false : inner.ok }
|
|
60
|
+
}
|
|
61
|
+
let images = 0
|
|
62
|
+
let text: string
|
|
63
|
+
if (Array.isArray(raw)) {
|
|
64
|
+
const parts: string[] = []
|
|
65
|
+
for (const item of raw as Array<Record<string, unknown>>) {
|
|
66
|
+
if (typeof item?.text === "string") parts.push(item.text)
|
|
67
|
+
else if (item?.type === "input_image") images++
|
|
68
|
+
}
|
|
69
|
+
text = parts.join("\n")
|
|
70
|
+
} else if (typeof raw === "string") {
|
|
71
|
+
text = raw
|
|
72
|
+
} else {
|
|
73
|
+
text = raw === undefined || raw === null ? "" : JSON.stringify(raw)
|
|
74
|
+
}
|
|
75
|
+
let ok = true
|
|
76
|
+
if (text.startsWith("{") && text.includes('"exit_code"')) {
|
|
77
|
+
try {
|
|
78
|
+
const j = JSON.parse(text) as { output?: unknown; metadata?: { exit_code?: unknown } }
|
|
79
|
+
if (typeof j.metadata?.exit_code === "number") ok = j.metadata.exit_code === 0
|
|
80
|
+
if (typeof j.output === "string") text = j.output
|
|
81
|
+
} catch {
|
|
82
|
+
// not the JSON exec format after all; keep the text as it is
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
const code = text.slice(0, 400).match(/^(?:Exit code:|Process exited with code) (-?\d+)/m)
|
|
86
|
+
if (code) ok = code[1] === "0"
|
|
87
|
+
else if (FAILURE_TEXT.test(text.trim())) ok = false
|
|
88
|
+
return { text, ok, images }
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/** The files an apply_patch touches, each with its own section of the patch. */
|
|
92
|
+
export function patchFiles(patch: string): Array<{ path: string; patch: string }> {
|
|
93
|
+
const out: Array<{ path: string; lines: string[] }> = []
|
|
94
|
+
let cur: { path: string; lines: string[] } | undefined
|
|
95
|
+
for (const line of patch.split("\n")) {
|
|
96
|
+
const m = line.match(/^\*\*\* (?:Add|Update|Delete) File: (.+)$/)
|
|
97
|
+
if (m) {
|
|
98
|
+
cur = { path: m[1]!.trim(), lines: [line] }
|
|
99
|
+
out.push(cur)
|
|
100
|
+
} else if (/^\*\*\* (Begin|End) Patch/.test(line)) {
|
|
101
|
+
cur = undefined
|
|
102
|
+
} else if (cur) {
|
|
103
|
+
cur.lines.push(line)
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
return out.map((f) => ({ path: f.path, patch: f.lines.join("\n") }))
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/** The thread id in a rollout file name: rollout-YYYY-MM-DDThh-mm-ss-<uuid>.jsonl[.zst] or legacy rollout-<date>-<uuid>.json. */
|
|
110
|
+
export function idFromName(path: string): string | undefined {
|
|
111
|
+
return path.match(/([0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})\.json(?:l(?:\.zst)?)?$/i)?.[1]
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
export const isRolloutName = (name: string) => name.startsWith("rollout-") && /\.(jsonl|jsonl\.zst|json)$/.test(name)
|
|
@@ -0,0 +1,479 @@
|
|
|
1
|
+
import { mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs"
|
|
2
|
+
import { dirname, join, resolve } from "node:path"
|
|
3
|
+
import { exists, gitInfo, versionOf } from "../../fsutil.ts"
|
|
4
|
+
import type { Project, Session, Turn } from "../../session/types.ts"
|
|
5
|
+
import { toolLabel } from "../../brief.ts"
|
|
6
|
+
import { imageDataUrl } from "../../session/attachments.ts"
|
|
7
|
+
import { countTurns, firstUserText, isInjected, unpaired } from "../../session/util.ts"
|
|
8
|
+
import type { Readiness, WriteOptions, WriteResult, Writer } from "../types.ts"
|
|
9
|
+
import { LABEL, TESTED_VERSIONS, TOOL, isContext, outputText } from "./records.ts"
|
|
10
|
+
import { parseRollout } from "./reader.ts"
|
|
11
|
+
import {
|
|
12
|
+
type Registration,
|
|
13
|
+
codexPatch,
|
|
14
|
+
compactedMessage,
|
|
15
|
+
displayChange,
|
|
16
|
+
execOutput,
|
|
17
|
+
itemTokens,
|
|
18
|
+
mcpParts,
|
|
19
|
+
patchCommand,
|
|
20
|
+
plainTitle,
|
|
21
|
+
patchOutput,
|
|
22
|
+
patchStdout,
|
|
23
|
+
registerThread,
|
|
24
|
+
replacementHistory,
|
|
25
|
+
userTextsOf,
|
|
26
|
+
} from "./native.ts"
|
|
27
|
+
|
|
28
|
+
// Continues a portable session in Codex as a new thread: a legacy-mode rollout written where Codex keeps
|
|
29
|
+
// its own, shaped like the one Codex's /import builds (external-agent-migration rollout_items_from_messages,
|
|
30
|
+
// and the session_meta that ThreadStore.create_thread writes with history_mode "legacy"). Unlike /import,
|
|
31
|
+
// tool calls stay structural: function_call / function_call_output pairs (custom_tool_call for freeform
|
|
32
|
+
// input such as apply_patch), with call ids remapped to Codex's call_ style.
|
|
33
|
+
//
|
|
34
|
+
// What the model is shown matches what a session Codex made itself would show it: shell calls are exec_command with
|
|
35
|
+
// unified-exec output (which is also where a failed command's exit code lives), file edits are apply_patch run through
|
|
36
|
+
// exec_command with the display events Codex keeps for them, a compaction carries the rebuilt history and Codex's
|
|
37
|
+
// summary prefix so Codex does not replay it the legacy way, and the token count is Codex's own estimate of the
|
|
38
|
+
// context rather than the size of everything ever said.
|
|
39
|
+
//
|
|
40
|
+
// No SQLite is written by this file. Once the rollout reads back, Codex's own app server is asked to read the thread
|
|
41
|
+
// (`thread/read`), and Codex's read-repair adds it to state_5.sqlite, which is what the `codex resume` picker and
|
|
42
|
+
// `--last` read first (tui/src/resume_picker.rs). If that step fails the thread still resumes by id.
|
|
43
|
+
|
|
44
|
+
/** The installed Codex version ("codex-cli 0.147.0" → "0.147.0"), or undefined when Codex is missing. */
|
|
45
|
+
export function codexVersion(): string | undefined {
|
|
46
|
+
return versionOf("codex")?.match(/(\d+\.\d+\.\d+(?:-[\w.]+)?)/)?.[1]
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** The provider Codex will run with: the top-level model_provider in config.toml, else OpenAI. */
|
|
50
|
+
function configuredProvider(home: string): string {
|
|
51
|
+
let text = ""
|
|
52
|
+
try {
|
|
53
|
+
text = readFileSync(join(home, ".codex", "config.toml"), "utf8")
|
|
54
|
+
} catch {
|
|
55
|
+
return "openai"
|
|
56
|
+
}
|
|
57
|
+
const top = text.split(/^\s*\[/m)[0] ?? ""
|
|
58
|
+
return top.match(/^\s*model_provider\s*=\s*["']([^"']+)["']/m)?.[1] ?? "openai"
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
const pad = (n: number) => String(n).padStart(2, "0")
|
|
62
|
+
|
|
63
|
+
/** Where Codex would put a rollout started at `now`: local date folders and a local-time file name. */
|
|
64
|
+
export function rolloutPath(home: string, id: string, now: Date): string {
|
|
65
|
+
const y = String(now.getFullYear())
|
|
66
|
+
const mo = pad(now.getMonth() + 1)
|
|
67
|
+
const d = pad(now.getDate())
|
|
68
|
+
const stamp = `${y}-${mo}-${d}T${pad(now.getHours())}-${pad(now.getMinutes())}-${pad(now.getSeconds())}`
|
|
69
|
+
return join(home, ".codex", "sessions", y, mo, d, `rollout-${stamp}-${id}.jsonl`)
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
const shellQuote = (s: string) => (/^[\w./@%+=:,-]+$/.test(s) ? s : `'${s.replace(/'/g, `'\\''`)}'`)
|
|
73
|
+
|
|
74
|
+
const seconds = (at: string | undefined) => {
|
|
75
|
+
const t = at ? Date.parse(at) : Number.NaN
|
|
76
|
+
return Number.isFinite(t) ? Math.floor(t / 1000) : undefined
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
export interface RolloutPlan {
|
|
80
|
+
/** Every line of the rollout, in order. */
|
|
81
|
+
lines: Array<{ timestamp: string; type: string; payload: Record<string, unknown> }>
|
|
82
|
+
lost: string[]
|
|
83
|
+
/** What a read-back must find, to check the file. */
|
|
84
|
+
expect: { users: number; assistants: number; calls: number; failed: number }
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
/** The rollout lines for `session` as a new Codex thread `id`. Pure: writes nothing. */
|
|
89
|
+
export function planRollout(
|
|
90
|
+
session: Session,
|
|
91
|
+
opts: { id: string; now: Date; cwd: string; cliVersion: string; modelProvider: string; git?: Omit<Project, "cwd">; newId?: () => string },
|
|
92
|
+
): RolloutPlan {
|
|
93
|
+
const newId = opts.newId ?? (() => Bun.randomUUIDv7())
|
|
94
|
+
const lines: RolloutPlan["lines"] = []
|
|
95
|
+
// Each record is stamped with when the turn really happened (never going backwards); what has no time of its own
|
|
96
|
+
// takes the latest one. The thread itself is created now, like any fork.
|
|
97
|
+
const known = session.turns.map((t) => (t.at ? Date.parse(t.at) : Number.NaN)).find(Number.isFinite)
|
|
98
|
+
let clock = known ?? opts.now.getTime()
|
|
99
|
+
const stamp = (at?: string) => {
|
|
100
|
+
const t = at ? Date.parse(at) : Number.NaN
|
|
101
|
+
if (Number.isFinite(t) && t > clock) clock = t
|
|
102
|
+
return new Date(clock).toISOString()
|
|
103
|
+
}
|
|
104
|
+
const push = (type: string, payload: Record<string, unknown>, at?: string) => lines.push({ timestamp: stamp(at), type, payload })
|
|
105
|
+
const drops = new Map<string, number>()
|
|
106
|
+
const drop = (what: string, n = 1) => drops.set(what, (drops.get(what) ?? 0) + n)
|
|
107
|
+
const expect = { users: 0, assistants: 0, calls: 0, failed: 0 }
|
|
108
|
+
// Messages and calls written: reasoning and bookkeeping alone are not a conversation Codex can replay.
|
|
109
|
+
let replayed = 0
|
|
110
|
+
const cwd = opts.cwd
|
|
111
|
+
|
|
112
|
+
const git = opts.git ?? {}
|
|
113
|
+
// A compaction opens a new context window, as Codex's own do. The first window's id is in session_meta, as
|
|
114
|
+
// a real 0.144.4 capture has it, so a compaction's first_window_id points at something the file names.
|
|
115
|
+
const firstWindow = newId()
|
|
116
|
+
push("session_meta", {
|
|
117
|
+
id: opts.id,
|
|
118
|
+
session_id: opts.id,
|
|
119
|
+
timestamp: opts.now.toISOString(),
|
|
120
|
+
cwd,
|
|
121
|
+
originator: "codex-tui",
|
|
122
|
+
cli_version: opts.cliVersion,
|
|
123
|
+
source: "cli",
|
|
124
|
+
thread_source: "user",
|
|
125
|
+
model_provider: opts.modelProvider,
|
|
126
|
+
// None: Codex renders the instructions of whatever model resumes the thread.
|
|
127
|
+
base_instructions: null,
|
|
128
|
+
history_mode: "legacy",
|
|
129
|
+
context_window: { window_id: firstWindow },
|
|
130
|
+
...(git.gitRemote || git.gitBranch || git.gitCommit
|
|
131
|
+
? {
|
|
132
|
+
git: {
|
|
133
|
+
...(git.gitCommit ? { commit_hash: git.gitCommit } : {}),
|
|
134
|
+
...(git.gitBranch ? { branch: git.gitBranch } : {}),
|
|
135
|
+
...(git.gitRemote ? { repository_url: git.gitRemote } : {}),
|
|
136
|
+
},
|
|
137
|
+
}
|
|
138
|
+
: {}),
|
|
139
|
+
})
|
|
140
|
+
|
|
141
|
+
// What the model will be shown, as Codex rebuilds it: for the token count, and for the history a compaction keeps.
|
|
142
|
+
let hist: Array<Record<string, any>> = []
|
|
143
|
+
const item = (payload: Record<string, any>, at?: string) => {
|
|
144
|
+
hist.push(payload)
|
|
145
|
+
push("response_item", payload, at)
|
|
146
|
+
}
|
|
147
|
+
let window = { number: 0, id: firstWindow }
|
|
148
|
+
|
|
149
|
+
// Codex turns open on a user message, as /import does; a session that starts mid-turn opens one silently.
|
|
150
|
+
let turnNo = 0
|
|
151
|
+
let open: { id: string; started?: number; last?: number } | undefined
|
|
152
|
+
const startTurn = (at: string | undefined) => {
|
|
153
|
+
closeTurn()
|
|
154
|
+
turnNo++
|
|
155
|
+
open = { id: `brain-import-turn-${turnNo}`, started: seconds(at) }
|
|
156
|
+
push("event_msg", { type: "task_started", turn_id: open.id, ...(open.started ? { started_at: open.started } : {}), model_context_window: null, collaboration_mode_kind: "default" }, at)
|
|
157
|
+
}
|
|
158
|
+
const ensureTurn = (at: string | undefined) => {
|
|
159
|
+
if (!open) startTurn(at)
|
|
160
|
+
const s = seconds(at)
|
|
161
|
+
if (s && open) open.last = s
|
|
162
|
+
}
|
|
163
|
+
const tokenCount = () => {
|
|
164
|
+
if (!open) return
|
|
165
|
+
const total = hist.reduce((n, h) => n + itemTokens(h), 0)
|
|
166
|
+
const usage = { input_tokens: 0, cached_input_tokens: 0, output_tokens: 0, reasoning_output_tokens: 0, total_tokens: total }
|
|
167
|
+
push("event_msg", { type: "token_count", info: { total_token_usage: usage, last_token_usage: usage, model_context_window: null }, rate_limits: null })
|
|
168
|
+
}
|
|
169
|
+
function closeTurn() {
|
|
170
|
+
if (!open) return
|
|
171
|
+
push("event_msg", {
|
|
172
|
+
type: "task_complete",
|
|
173
|
+
turn_id: open.id,
|
|
174
|
+
last_agent_message: null,
|
|
175
|
+
...(open.started ? { started_at: open.started } : {}),
|
|
176
|
+
...(open.last ? { completed_at: open.last } : {}),
|
|
177
|
+
})
|
|
178
|
+
open = undefined
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
// Call ids: Codex's are call_<alnum>. Others are mapped once, the same way for the call and its result.
|
|
182
|
+
const ids = new Map<string, string>()
|
|
183
|
+
const used = new Set<string>()
|
|
184
|
+
let remapped = 0
|
|
185
|
+
const callId = (id: string) => {
|
|
186
|
+
let v = ids.get(id)
|
|
187
|
+
if (v) return v
|
|
188
|
+
const base = /^call_[A-Za-z0-9_-]{1,60}$/.test(id) ? id : `call_${id.replace(/^[a-z]+_/, "").replace(/[^A-Za-z0-9]/g, "").slice(-40) || "x"}`
|
|
189
|
+
v = base
|
|
190
|
+
for (let n = 2; used.has(v); n++) v = `${base}_${n}`
|
|
191
|
+
ids.set(id, v)
|
|
192
|
+
used.add(v)
|
|
193
|
+
if (v !== id) remapped++
|
|
194
|
+
return v
|
|
195
|
+
}
|
|
196
|
+
// Function names must fit the Responses API's ^[A-Za-z0-9_-]{1,64}$.
|
|
197
|
+
let renamed = 0
|
|
198
|
+
const toolName = (name: string) => {
|
|
199
|
+
const v = name.replace(/[^A-Za-z0-9_-]/g, "_").slice(0, 64) || "tool"
|
|
200
|
+
if (v !== name) renamed++
|
|
201
|
+
return v
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
// Which calls get a result later on, so the ones that never do can be closed where they stand; and the file
|
|
205
|
+
// edits each call made, which Codex records beside the call.
|
|
206
|
+
const answeredLater = new Set<number>()
|
|
207
|
+
const resultsAhead = new Set<string>()
|
|
208
|
+
const editsOf = new Map<string, Array<Extract<Turn, { kind: "file_edit" }>>>()
|
|
209
|
+
for (let i = session.turns.length - 1; i >= 0; i--) {
|
|
210
|
+
const t = session.turns[i]!
|
|
211
|
+
if (t.kind === "tool_result") resultsAhead.add(t.callId)
|
|
212
|
+
else if (t.kind === "tool_call" && resultsAhead.has(t.id)) answeredLater.add(i)
|
|
213
|
+
else if (t.kind === "file_edit" && t.callId && t.patch) editsOf.set(t.callId, [t, ...(editsOf.get(t.callId) ?? [])])
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
// How each written call is answered: a shell call with unified-exec output, an edit as the apply_patch it was,
|
|
217
|
+
// an MCP or web call with the event Codex shows for it, anything else as it came.
|
|
218
|
+
type Kind =
|
|
219
|
+
| { how: "shell" }
|
|
220
|
+
| { how: "patch"; patch: NonNullable<ReturnType<typeof codexPatch>> }
|
|
221
|
+
| { how: "mcp"; server: string; tool: string; args: unknown }
|
|
222
|
+
| { how: "web"; query: string }
|
|
223
|
+
| { how: "plain" }
|
|
224
|
+
const written = new Map<string, "function" | "custom">()
|
|
225
|
+
const kinds = new Map<string, Kind>()
|
|
226
|
+
const answered = new Set<string>()
|
|
227
|
+
const failedCalls = new Set<string>()
|
|
228
|
+
const output = (id: string, text: string, ok: boolean, at?: string) => {
|
|
229
|
+
const kind = kinds.get(id) ?? { how: "plain" }
|
|
230
|
+
let body = text
|
|
231
|
+
if (kind.how === "shell") body = execOutput(text, ok)
|
|
232
|
+
else if (kind.how === "patch") {
|
|
233
|
+
// The display record comes first, as Codex writes it.
|
|
234
|
+
push(
|
|
235
|
+
"event_msg",
|
|
236
|
+
{
|
|
237
|
+
type: "patch_apply_end",
|
|
238
|
+
call_id: callId(id),
|
|
239
|
+
turn_id: open?.id,
|
|
240
|
+
stdout: ok ? patchStdout(kind.patch.summary) : "",
|
|
241
|
+
stderr: ok ? "" : text,
|
|
242
|
+
success: ok,
|
|
243
|
+
changes: kind.patch.changes,
|
|
244
|
+
status: ok ? "completed" : "failed",
|
|
245
|
+
},
|
|
246
|
+
at,
|
|
247
|
+
)
|
|
248
|
+
body = ok ? patchOutput(kind.patch.summary) : `apply_patch verification failed: ${text}`
|
|
249
|
+
} else if (!ok && outputText(text).ok) {
|
|
250
|
+
// Codex stores no success flag, so a failure must say so in the text it reads back.
|
|
251
|
+
body = `execution error: ${text}`
|
|
252
|
+
}
|
|
253
|
+
if (!ok) {
|
|
254
|
+
failedCalls.add(id)
|
|
255
|
+
expect.failed++
|
|
256
|
+
}
|
|
257
|
+
item({ type: written.get(id) === "custom" ? "custom_tool_call_output" : "function_call_output", call_id: callId(id), output: body }, at)
|
|
258
|
+
if (kind.how === "mcp") {
|
|
259
|
+
push(
|
|
260
|
+
"event_msg",
|
|
261
|
+
{
|
|
262
|
+
type: "mcp_tool_call_end",
|
|
263
|
+
call_id: callId(id),
|
|
264
|
+
invocation: { server: kind.server, tool: kind.tool, arguments: kind.args },
|
|
265
|
+
duration: { secs: 0, nanos: 0 },
|
|
266
|
+
result: ok ? { Ok: { content: [{ type: "text", text }], isError: false } } : { Err: text },
|
|
267
|
+
},
|
|
268
|
+
at,
|
|
269
|
+
)
|
|
270
|
+
} else if (kind.how === "web") {
|
|
271
|
+
push("event_msg", { type: "web_search_end", call_id: callId(id), query: kind.query, action: { type: "search", query: kind.query } }, at)
|
|
272
|
+
}
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
for (const [i, t] of session.turns.entries()) {
|
|
276
|
+
switch (t.kind) {
|
|
277
|
+
case "user": {
|
|
278
|
+
const images: string[] = []
|
|
279
|
+
for (const a of t.attachments ?? []) {
|
|
280
|
+
const url = imageDataUrl(a)
|
|
281
|
+
if (url) images.push(url)
|
|
282
|
+
else drop("attachments")
|
|
283
|
+
}
|
|
284
|
+
if (!t.text.trim() && !images.length) break
|
|
285
|
+
startTurn(t.at)
|
|
286
|
+
replayed++
|
|
287
|
+
// Injected notices reach the model but are not the person's words: no history event for them.
|
|
288
|
+
if (!isInjected(t.text)) push("event_msg", { type: "user_message", message: t.text, images, local_images: [], local_audio: [], text_elements: [] }, t.at)
|
|
289
|
+
item({ type: "message", role: "user", content: [...(t.text.trim() ? [{ type: "input_text", text: t.text }] : []), ...images.map((image_url) => ({ type: "input_image", image_url }))] }, t.at)
|
|
290
|
+
if (!isContext(t.text)) expect.users++
|
|
291
|
+
break
|
|
292
|
+
}
|
|
293
|
+
case "assistant": {
|
|
294
|
+
if (!t.text.trim()) break
|
|
295
|
+
ensureTurn(t.at)
|
|
296
|
+
replayed++
|
|
297
|
+
push("event_msg", { type: "agent_message", message: t.text, phase: null, memory_citation: null }, t.at)
|
|
298
|
+
item({ type: "message", role: "assistant", content: [{ type: "output_text", text: t.text }] }, t.at)
|
|
299
|
+
expect.assistants++
|
|
300
|
+
break
|
|
301
|
+
}
|
|
302
|
+
case "tool_call": {
|
|
303
|
+
if (written.has(t.id)) {
|
|
304
|
+
drop("repeated tool call ids")
|
|
305
|
+
break
|
|
306
|
+
}
|
|
307
|
+
ensureTurn(t.at)
|
|
308
|
+
const input = t.input && typeof t.input === "object" && !Array.isArray(t.input) ? (t.input as Record<string, unknown>) : undefined
|
|
309
|
+
const command = typeof input?.command === "string" ? input.command : typeof input?.cmd === "string" ? input.cmd : undefined
|
|
310
|
+
const edited = editsOf.get(t.id)
|
|
311
|
+
const patch = (t.toolKind === "edit" || t.toolKind === "write") && edited ? codexPatch(edited.map((e) => ({ path: e.path, patch: e.patch! })), cwd) : undefined
|
|
312
|
+
let name = toolName(t.name)
|
|
313
|
+
let args: string | undefined
|
|
314
|
+
// A call already in Codex's own form (exec_command with cmd) is written as it was.
|
|
315
|
+
if (t.toolKind === "shell" && command !== undefined && t.name !== "exec_command") {
|
|
316
|
+
// What Codex's own model calls to run a command.
|
|
317
|
+
name = "exec_command"
|
|
318
|
+
args = JSON.stringify({ cmd: command, workdir: cwd })
|
|
319
|
+
kinds.set(t.id, { how: "shell" })
|
|
320
|
+
} else if (patch) {
|
|
321
|
+
// An edit is the apply_patch Codex intercepts when the model runs it through exec_command.
|
|
322
|
+
name = "exec_command"
|
|
323
|
+
args = JSON.stringify({ cmd: patchCommand(patch.text), workdir: cwd })
|
|
324
|
+
kinds.set(t.id, { how: "patch", patch })
|
|
325
|
+
} else if (mcpParts(t.name) && input) {
|
|
326
|
+
kinds.set(t.id, { how: "mcp", ...mcpParts(t.name)!, args: input })
|
|
327
|
+
} else if (/^web_?search$/i.test(t.name) && typeof input?.query === "string") {
|
|
328
|
+
kinds.set(t.id, { how: "web", query: input.query })
|
|
329
|
+
}
|
|
330
|
+
// Freeform input (Codex's apply_patch and other custom tools) stays freeform; anything else is JSON.
|
|
331
|
+
if (typeof t.input === "string") {
|
|
332
|
+
written.set(t.id, "custom")
|
|
333
|
+
item({ type: "custom_tool_call", status: "completed", call_id: callId(t.id), name, input: t.input }, t.at)
|
|
334
|
+
} else {
|
|
335
|
+
written.set(t.id, "function")
|
|
336
|
+
item({ type: "function_call", name, arguments: args ?? (JSON.stringify(t.input ?? {}) ?? "{}"), call_id: callId(t.id) }, t.at)
|
|
337
|
+
}
|
|
338
|
+
expect.calls++
|
|
339
|
+
replayed++
|
|
340
|
+
// The Responses API rejects a call with no output, so an unanswered call is closed here.
|
|
341
|
+
if (!answeredLater.has(i)) {
|
|
342
|
+
output(t.id, "(no result was recorded for this call)", true, t.at)
|
|
343
|
+
answered.add(t.id)
|
|
344
|
+
drop("tool calls without a result, closed with a placeholder")
|
|
345
|
+
}
|
|
346
|
+
break
|
|
347
|
+
}
|
|
348
|
+
case "tool_result": {
|
|
349
|
+
if (!written.has(t.callId)) {
|
|
350
|
+
drop("tool results without a call")
|
|
351
|
+
break
|
|
352
|
+
}
|
|
353
|
+
if (answered.has(t.callId)) {
|
|
354
|
+
drop("repeated tool results")
|
|
355
|
+
break
|
|
356
|
+
}
|
|
357
|
+
ensureTurn(t.at)
|
|
358
|
+
output(t.callId, t.output, t.ok, t.at)
|
|
359
|
+
answered.add(t.callId)
|
|
360
|
+
break
|
|
361
|
+
}
|
|
362
|
+
case "reasoning":
|
|
363
|
+
// Codex replays reasoning to the model only as its own (encrypted) items, so this is shown, not sent.
|
|
364
|
+
ensureTurn(t.at)
|
|
365
|
+
push("event_msg", { type: "agent_reasoning", text: t.text }, t.at)
|
|
366
|
+
break
|
|
367
|
+
case "compaction": {
|
|
368
|
+
ensureTurn(t.at)
|
|
369
|
+
const message = compactedMessage(t.summary)
|
|
370
|
+
const replacement = replacementHistory(userTextsOf(hist), message)
|
|
371
|
+
const previous = window.id
|
|
372
|
+
window = { number: window.number + 1, id: newId() }
|
|
373
|
+
push("compacted", { message, replacement_history: replacement, window_number: window.number, first_window_id: firstWindow, previous_window_id: previous, window_id: window.id }, t.at)
|
|
374
|
+
push("event_msg", { type: "context_compacted" }, t.at)
|
|
375
|
+
hist = [...replacement]
|
|
376
|
+
break
|
|
377
|
+
}
|
|
378
|
+
case "file_edit": {
|
|
379
|
+
// With a call id the edit travels as that call. One that could be written as an apply_patch already was, with
|
|
380
|
+
// its call; any other (a diff that was cut short, say) is kept as the record Codex shows for a change.
|
|
381
|
+
if (!t.callId) {
|
|
382
|
+
drop("file edits not made by a tool call")
|
|
383
|
+
break
|
|
384
|
+
}
|
|
385
|
+
if (kinds.get(t.callId)?.how === "patch") break
|
|
386
|
+
const shown = written.has(t.callId) && t.patch ? displayChange(t.patch, cwd, t.path) : undefined
|
|
387
|
+
if (!shown) {
|
|
388
|
+
drop("file edits Codex cannot show")
|
|
389
|
+
break
|
|
390
|
+
}
|
|
391
|
+
ensureTurn(t.at)
|
|
392
|
+
push("event_msg", { type: "patch_apply_end", call_id: callId(t.callId), turn_id: open?.id, stdout: "", stderr: "", success: true, changes: { [shown.path]: shown.change }, status: "completed" }, t.at)
|
|
393
|
+
break
|
|
394
|
+
}
|
|
395
|
+
case "subagent":
|
|
396
|
+
drop("subagent sessions")
|
|
397
|
+
break
|
|
398
|
+
}
|
|
399
|
+
}
|
|
400
|
+
if (!replayed) throw new Error("nothing Codex can replay: the session has no messages or tool calls")
|
|
401
|
+
// The importer closes with a token count so Codex knows how full the context is: Codex's own estimate of what it will send.
|
|
402
|
+
tokenCount()
|
|
403
|
+
closeTurn()
|
|
404
|
+
|
|
405
|
+
const lost = [...drops].map(([what, n]) => `${what} (${n})`)
|
|
406
|
+
if (remapped) lost.push(`tool call ids, remapped to Codex call_ ids (${remapped})`)
|
|
407
|
+
if (renamed) lost.push(`tool names changed to fit Codex (${renamed})`)
|
|
408
|
+
return { lines, lost, expect }
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
/** How a thread gets into Codex's own index once its rollout is written; replaceable so tests never start Codex. */
|
|
412
|
+
export type Register = (codexHome: string, threadId: string, cwd: string, name?: string) => Promise<Registration>
|
|
413
|
+
|
|
414
|
+
/** The title the thread gets in Codex's picker: the session's own, marked with where it came from. */
|
|
415
|
+
export const threadName = (session: Session) => `${plainTitle(session.header.title ?? firstUserText(session) ?? "Session")} (from ${toolLabel(session.header.tool)})`
|
|
416
|
+
|
|
417
|
+
export function makeCodexWriter(register: Register = registerThread): Writer {
|
|
418
|
+
return {
|
|
419
|
+
tool: TOOL,
|
|
420
|
+
label: LABEL,
|
|
421
|
+
way: "native",
|
|
422
|
+
|
|
423
|
+
async ready(opts: WriteOptions): Promise<Readiness> {
|
|
424
|
+
const v = codexVersion()
|
|
425
|
+
if (!v) return { ok: false, why: "Codex is not installed here (`codex --version` failed)" }
|
|
426
|
+
if (!TESTED_VERSIONS.includes(v))
|
|
427
|
+
return { ok: false, why: `Codex ${v} is not a version this writer was checked against (${TESTED_VERSIONS.join(", ")})` }
|
|
428
|
+
if (!exists(opts.cwd)) return { ok: false, why: `the folder to continue in does not exist: ${opts.cwd}` }
|
|
429
|
+
return { ok: true }
|
|
430
|
+
},
|
|
431
|
+
|
|
432
|
+
async write(session: Session, opts: WriteOptions): Promise<WriteResult> {
|
|
433
|
+
const now = new Date()
|
|
434
|
+
const id = Bun.randomUUIDv7()
|
|
435
|
+
const cwd = resolve(opts.cwd)
|
|
436
|
+
const plan = planRollout(session, {
|
|
437
|
+
id,
|
|
438
|
+
now,
|
|
439
|
+
cwd,
|
|
440
|
+
cliVersion: codexVersion() ?? TESTED_VERSIONS[0]!,
|
|
441
|
+
modelProvider: configuredProvider(opts.home),
|
|
442
|
+
git: gitInfo(cwd),
|
|
443
|
+
})
|
|
444
|
+
const path = rolloutPath(opts.home, id, now)
|
|
445
|
+
const lost = [...plan.lost]
|
|
446
|
+
if (!opts.dryRun) {
|
|
447
|
+
mkdirSync(dirname(path), { recursive: true })
|
|
448
|
+
// "wx": a new thread never replaces a file, even in the impossible case of an id clash.
|
|
449
|
+
writeFileSync(path, `${plan.lines.map((l) => JSON.stringify(l)).join("\n")}\n`, { flag: "wx" })
|
|
450
|
+
// Read it back as Codex's own rollouts are read; a file that does not round-trip is removed.
|
|
451
|
+
const back = parseRollout(readFileSync(path, "utf8"), path)
|
|
452
|
+
const n = countTurns(back)
|
|
453
|
+
const pairs = unpaired(back)
|
|
454
|
+
const failed = back.turns.filter((t) => t.kind === "tool_result" && !t.ok).length
|
|
455
|
+
if (n.user !== plan.expect.users || n.assistant !== plan.expect.assistants || n.tool_call !== plan.expect.calls || failed !== plan.expect.failed || pairs.calls.length || pairs.results.length) {
|
|
456
|
+
rmSync(path, { force: true })
|
|
457
|
+
throw new Error(
|
|
458
|
+
`the Codex rollout did not read back as written (users ${n.user}/${plan.expect.users}, replies ${n.assistant}/${plan.expect.assistants}, tool calls ${n.tool_call}/${plan.expect.calls}, failed results ${failed}/${plan.expect.failed}, unpaired ${pairs.calls.length + pairs.results.length}); nothing was kept`,
|
|
459
|
+
)
|
|
460
|
+
}
|
|
461
|
+
// Have Codex index the thread, so the picker and `--last` find it. If that fails the thread still resumes by id.
|
|
462
|
+
const registered = await register(join(opts.home, ".codex"), id, cwd, threadName(session))
|
|
463
|
+
if (!registered.ok) lost.push(`not in Codex's thread index (${registered.why}); it resumes by id, and appears in \`codex resume\` once opened`)
|
|
464
|
+
else if (registered.named === false) lost.push("thread title (Codex refused the name; the picker shows the first message instead)")
|
|
465
|
+
}
|
|
466
|
+
return {
|
|
467
|
+
tool: TOOL,
|
|
468
|
+
way: "native",
|
|
469
|
+
sessionId: id,
|
|
470
|
+
written: [path],
|
|
471
|
+
open: `cd ${shellQuote(cwd)} && codex resume ${id}`,
|
|
472
|
+
lost,
|
|
473
|
+
dryRun: !!opts.dryRun,
|
|
474
|
+
}
|
|
475
|
+
},
|
|
476
|
+
}
|
|
477
|
+
}
|
|
478
|
+
|
|
479
|
+
export const codexWriter: Writer = makeCodexWriter()
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import type { Reader, Writer } from "../types.ts"
|
|
2
|
+
import { reader } from "./reader.ts"
|
|
3
|
+
import { continueNativeWriter } from "./writer.ts"
|
|
4
|
+
|
|
5
|
+
// Continue (VS Code and JetBrains extensions, and the cn CLI, which share ~/.continue/sessions): reads its
|
|
6
|
+
// sessions, and continues any session as a new Continue chat in its history.
|
|
7
|
+
export { reader }
|
|
8
|
+
export const readers: Reader[] = [reader]
|
|
9
|
+
export const writers: Writer[] = [continueNativeWriter]
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
import { readFileSync } from "node:fs"
|
|
2
|
+
import { join } from "node:path"
|
|
3
|
+
import { exists, mtime } from "../../fsutil.ts"
|
|
4
|
+
import type { Session } from "../../session/types.ts"
|
|
5
|
+
import type { Reader, SessionRef } from "../types.ts"
|
|
6
|
+
import { CONTINUE, type Flavor, listPath, sessionsDir, toPortable, workspacePath } from "./records.ts"
|
|
7
|
+
|
|
8
|
+
type Entry = { sessionId?: unknown; session_id?: unknown; title?: unknown; dateCreated?: unknown; workspaceDirectory?: unknown }
|
|
9
|
+
|
|
10
|
+
/** The sessions.json list as Continue's HistoryManager.list reads it: an array, old-format entries skipped. */
|
|
11
|
+
export function readList(home: string, f: Flavor): Entry[] {
|
|
12
|
+
const p = listPath(home, f)
|
|
13
|
+
if (!exists(p)) return []
|
|
14
|
+
let v: unknown
|
|
15
|
+
try {
|
|
16
|
+
v = JSON.parse(readFileSync(p, "utf8"))
|
|
17
|
+
} catch {
|
|
18
|
+
return []
|
|
19
|
+
}
|
|
20
|
+
return Array.isArray(v) ? v.filter((e): e is Entry => typeof e === "object" && e !== null && typeof (e as Entry).session_id !== "string") : []
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/** A reader for Continue's session store, or PearAI's copy of it. */
|
|
24
|
+
export function continueReader(f: Flavor): Reader {
|
|
25
|
+
return {
|
|
26
|
+
tool: f.tool,
|
|
27
|
+
label: f.label,
|
|
28
|
+
|
|
29
|
+
async list(home: string): Promise<SessionRef[]> {
|
|
30
|
+
const refs: SessionRef[] = []
|
|
31
|
+
for (const e of readList(home, f)) {
|
|
32
|
+
if (typeof e.sessionId !== "string" || !e.sessionId) continue
|
|
33
|
+
const path = join(sessionsDir(home, f), `${e.sessionId}.json`)
|
|
34
|
+
if (!exists(path)) continue
|
|
35
|
+
const cwd = workspacePath(e.workspaceDirectory)
|
|
36
|
+
const updatedAt = mtime(path)
|
|
37
|
+
refs.push({
|
|
38
|
+
tool: f.tool,
|
|
39
|
+
sourceId: e.sessionId,
|
|
40
|
+
path,
|
|
41
|
+
...(typeof e.title === "string" ? { title: e.title } : {}),
|
|
42
|
+
...(cwd ? { cwd } : {}),
|
|
43
|
+
...(updatedAt ? { updatedAt } : {}),
|
|
44
|
+
...(typeof e.dateCreated === "string" ? { extra: { dateCreated: e.dateCreated } } : {}),
|
|
45
|
+
})
|
|
46
|
+
}
|
|
47
|
+
return refs.sort((a, b) => (b.updatedAt ?? "").localeCompare(a.updatedAt ?? ""))
|
|
48
|
+
},
|
|
49
|
+
|
|
50
|
+
async read(ref: SessionRef, home: string): Promise<Session> {
|
|
51
|
+
const path = ref.path || join(sessionsDir(home, f), `${ref.sourceId}.json`)
|
|
52
|
+
let raw: unknown
|
|
53
|
+
try {
|
|
54
|
+
raw = JSON.parse(readFileSync(path, "utf8"))
|
|
55
|
+
} catch (e) {
|
|
56
|
+
throw new Error(`cannot read ${f.label} session ${path}: ${(e as Error).message}`)
|
|
57
|
+
}
|
|
58
|
+
if (typeof raw !== "object" || raw === null || !Array.isArray((raw as { history?: unknown }).history)) throw new Error(`${path} is not a ${f.label} session (no history)`)
|
|
59
|
+
// HistoryManager.load trusts the file name over the id inside it.
|
|
60
|
+
const data = { ...(raw as Record<string, unknown>), sessionId: ref.sourceId }
|
|
61
|
+
const dateCreated = typeof ref.extra?.dateCreated === "string" ? ref.extra.dateCreated : undefined
|
|
62
|
+
const updated = mtime(path)
|
|
63
|
+
return toPortable(data, f, path, { ...(dateCreated ? { dateCreated } : {}), ...(updated ? { mtime: updated } : {}) })
|
|
64
|
+
},
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
export const reader = continueReader(CONTINUE)
|