@100xprompt/brain 0.0.0-stage → 0.1.1
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 +235 -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 +351 -0
- package/src/dashboard/cli.ts +148 -0
- package/src/dashboard/conversions.ts +170 -0
- package/src/dashboard/report.ts +261 -0
- package/src/dashboard/send.ts +213 -0
- package/src/dashboard/state.ts +95 -0
- package/src/fsutil.ts +133 -0
- package/src/gateway/common.ts +232 -0
- package/src/gateway/connect.ts +847 -0
- package/src/gateway/connectors/aider.ts +356 -0
- package/src/gateway/connectors/amazon-q-cli.ts +6 -0
- package/src/gateway/connectors/amazon-q-ide.ts +6 -0
- package/src/gateway/connectors/antigravity.ts +6 -0
- package/src/gateway/connectors/auggie.ts +6 -0
- package/src/gateway/connectors/cline-sdk.ts +85 -0
- package/src/gateway/connectors/cline.ts +34 -0
- package/src/gateway/connectors/codebuff.ts +6 -0
- package/src/gateway/connectors/continue.ts +579 -0
- package/src/gateway/connectors/copilot-cli.ts +138 -0
- package/src/gateway/connectors/crush.ts +313 -0
- package/src/gateway/connectors/cursor-cli.ts +6 -0
- package/src/gateway/connectors/cursor.ts +6 -0
- package/src/gateway/connectors/devin.ts +6 -0
- package/src/gateway/connectors/factory-droid.ts +308 -0
- package/src/gateway/connectors/goose.ts +79 -0
- package/src/gateway/connectors/grok-cli.ts +82 -0
- package/src/gateway/connectors/hermes.ts +230 -0
- package/src/gateway/connectors/iflow-cli.ts +50 -0
- package/src/gateway/connectors/index.ts +86 -0
- package/src/gateway/connectors/kilo-cli.ts +434 -0
- package/src/gateway/connectors/kilo-code.ts +32 -0
- package/src/gateway/connectors/kimi-code.ts +241 -0
- package/src/gateway/connectors/kiro-cli.ts +6 -0
- package/src/gateway/connectors/kiro-ide.ts +6 -0
- package/src/gateway/connectors/letta-code.ts +170 -0
- package/src/gateway/connectors/mastracode.ts +91 -0
- package/src/gateway/connectors/mistral-vibe.ts +495 -0
- package/src/gateway/connectors/muse.ts +27 -0
- package/src/gateway/connectors/omp.ts +279 -0
- package/src/gateway/connectors/openhands.ts +296 -0
- package/src/gateway/connectors/pearai.ts +52 -0
- package/src/gateway/connectors/pi.ts +357 -0
- package/src/gateway/connectors/qoder-cli.ts +6 -0
- package/src/gateway/connectors/qwen-code.ts +411 -0
- package/src/gateway/connectors/roo.ts +43 -0
- package/src/gateway/connectors/tabnine-cli.ts +6 -0
- package/src/gateway/connectors/void.ts +6 -0
- package/src/gateway/connectors/vscode-copilot.ts +286 -0
- package/src/gateway/connectors/windsurf.ts +6 -0
- package/src/gateway/connectors/zed.ts +29 -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,383 @@
|
|
|
1
|
+
import { randomBytes, randomUUID } from "node:crypto"
|
|
2
|
+
import { mkdirSync, readFileSync, realpathSync, renameSync, rmSync, statSync, writeFileSync } from "node:fs"
|
|
3
|
+
import { basename, extname, isAbsolute, join, relative, resolve } from "node:path"
|
|
4
|
+
import { toolLabel } from "../../brief.ts"
|
|
5
|
+
import { exists, isDir } from "../../fsutil.ts"
|
|
6
|
+
import type { Session, Turn } from "../../session/types.ts"
|
|
7
|
+
import { countTurns, unpaired } from "../../session/util.ts"
|
|
8
|
+
import type { Readiness, WriteOptions, WriteResult, Writer } from "../types.ts"
|
|
9
|
+
import { fileContext } from "./project.ts"
|
|
10
|
+
import { readChat } from "./reader.ts"
|
|
11
|
+
import { CHAT_MESSAGES, CHAT_META, LABEL, RUN_STATE, TESTED_VERSIONS, TOOL, chatId, chatsDir, continuedNote, installed, summaryMessageText, withSystemTags } from "./records.ts"
|
|
12
|
+
|
|
13
|
+
// Continues a portable session in Codebuff / Freebuff as a new chat of the project: a folder
|
|
14
|
+
// ~/.config/manicode/projects/<project folder name>/chats/<chat id>/ holding the three files the CLI saves
|
|
15
|
+
// (run-storage saveChatState): run-state.json, chat-messages.json and chat-meta.json, written in that order
|
|
16
|
+
// and each through a temp file and a rename, as the CLI writes them. `freebuff --continue <chat id>` run in the
|
|
17
|
+
// project loads them (loadMostRecentChatState): the screen gets chat-messages.json, the model gets the run
|
|
18
|
+
// state's messageHistory on the next prompt, and /history lists the chat from chat-meta.json.
|
|
19
|
+
//
|
|
20
|
+
// The model's history is written the way the runtime appends to it: the person's prompts as user messages
|
|
21
|
+
// tagged USER_PROMPT, each assistant text, reasoning part and tool call as its own assistant message, and each
|
|
22
|
+
// result as a tool message with a JSON result, right after the calls it answers. Shell, read, write and edit
|
|
23
|
+
// calls become Codebuff's own run_terminal_command, read_files, write_file and str_replace, with results in
|
|
24
|
+
// those tools' shapes; other calls keep their name. A compaction is the runtime's own <conversation_summary>
|
|
25
|
+
// message: the model gets the summary and what follows, the screen keeps every turn. The note saying where the
|
|
26
|
+
// chat came from is a <system> user message without USER_PROMPT, how the runtime adds its own notes, and a
|
|
27
|
+
// notice on screen. The fileContext is a snapshot of the project made as for a new chat (project.ts).
|
|
28
|
+
|
|
29
|
+
type Json = Record<string, unknown>
|
|
30
|
+
type Call = Extract<Turn, { kind: "tool_call" }>
|
|
31
|
+
type Result = Extract<Turn, { kind: "tool_result" }>
|
|
32
|
+
|
|
33
|
+
const shellQuote = (s: string) => (/^[\w./@%+=:,-]+$/.test(s) ? s : `'${s.replace(/'/g, `'\\''`)}'`)
|
|
34
|
+
const IMAGE_TYPES: Record<string, string> = { ".png": "image/png", ".jpg": "image/jpeg", ".jpeg": "image/jpeg", ".gif": "image/gif", ".webp": "image/webp" }
|
|
35
|
+
const IMAGE_LIMIT = 5 * 1024 * 1024
|
|
36
|
+
/** What the agent runtime starts every agent with (session-state.ts MAX_AGENT_STEPS_DEFAULT). */
|
|
37
|
+
const MAX_AGENT_STEPS = 600
|
|
38
|
+
|
|
39
|
+
/** The exit code a failed command's output states ("Exit code 2", "exited with code 2"), if it states one. */
|
|
40
|
+
const exitCodeOf = (out: string) => {
|
|
41
|
+
const m = out.match(/exit(?:ed with)?(?: status| code)?:?\s*(-?\d+)\b/i)
|
|
42
|
+
return m && Number(m[1]) !== 0 ? Number(m[1]) : undefined
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
const pick = (o: Json, keys: string[]) => {
|
|
46
|
+
for (const k of keys) if (typeof o[k] === "string") return o[k] as string
|
|
47
|
+
return undefined
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/** The time of day as the CLI stamps messages (helpers.ts formatTimestamp). */
|
|
51
|
+
const clockTime = (ms: number) => {
|
|
52
|
+
try {
|
|
53
|
+
return new Intl.DateTimeFormat(undefined, { hour: "2-digit", minute: "2-digit" }).format(new Date(ms))
|
|
54
|
+
} catch {
|
|
55
|
+
return new Date(ms).toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" })
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* The call as Codebuff's own tool where one fits: its name and input, and its result as that tool's JSON.
|
|
61
|
+
* Paths inside the project become project-relative, as Codebuff's tools take them.
|
|
62
|
+
*/
|
|
63
|
+
export function mapCall(call: Call, roots: string[]): { name: string; input: Json; result: (ok: boolean, out: string) => unknown; native: boolean } {
|
|
64
|
+
const input = call.input && typeof call.input === "object" && !Array.isArray(call.input) ? (call.input as Json) : undefined
|
|
65
|
+
// The project folder as given and as resolved (/tmp and /private/tmp): paths in the session may use either.
|
|
66
|
+
const rel = (p: string) => {
|
|
67
|
+
const root = isAbsolute(p) ? roots.find((r) => !relative(r, p).startsWith("..")) : undefined
|
|
68
|
+
return root ? relative(root, p) || "." : p
|
|
69
|
+
}
|
|
70
|
+
if (input) {
|
|
71
|
+
const command = pick(input, ["command", "cmd"]) ?? (Array.isArray(input.command) ? (input.command as unknown[]).map(String).join(" ") : undefined)
|
|
72
|
+
if (call.toolKind === "shell" && command)
|
|
73
|
+
// A failure takes the exit code its output states, else 1 (the writer lists how many it guessed).
|
|
74
|
+
return { name: "run_terminal_command", input: { command }, result: (ok, out) => ({ command, stdout: out, exitCode: ok ? 0 : (exitCodeOf(out) ?? 1) }), native: true }
|
|
75
|
+
const raw = pick(input, ["file_path", "path", "filePath", "absolute_path", "target_file", "file"])
|
|
76
|
+
if (raw) {
|
|
77
|
+
const path = rel(raw)
|
|
78
|
+
if (call.toolKind === "read") return { name: "read_files", input: { paths: [path] }, result: (ok, out) => (ok ? [{ path, content: out }] : { errorMessage: out }), native: true }
|
|
79
|
+
const fileResult = (ok: boolean, out: string) => (ok ? { file: path, message: out } : { file: path, errorMessage: out })
|
|
80
|
+
const content = pick(input, ["content", "file_text", "contents"])
|
|
81
|
+
if (call.toolKind === "write" && content !== undefined) return { name: "write_file", input: { path, content }, result: fileResult, native: true }
|
|
82
|
+
const oldString = pick(input, ["old_string", "old_str", "oldText", "old_text", "oldString"])
|
|
83
|
+
const newString = pick(input, ["new_string", "new_str", "newText", "new_text", "newString"])
|
|
84
|
+
if (call.toolKind === "edit" && oldString !== undefined && newString !== undefined)
|
|
85
|
+
return { name: "str_replace", input: { path, replacements: [{ oldString, newString, allowMultiple: input.replace_all === true }] }, result: fileResult, native: true }
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
// Names the model APIs accept: letters, digits, _ and -, at most 64.
|
|
89
|
+
const name = call.name.replace(/[^a-zA-Z0-9_-]/g, "_").slice(0, 64) || "tool"
|
|
90
|
+
return { name, input: input ?? { input: call.input ?? null }, result: (ok, out) => (ok ? out : { errorMessage: out }), native: false }
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
export interface ChatPlan {
|
|
94
|
+
runState: Json
|
|
95
|
+
/** chat-messages.json: what the CLI shows. */
|
|
96
|
+
messages: Json[]
|
|
97
|
+
lost: string[]
|
|
98
|
+
/** What a read-back of the run state must find. */
|
|
99
|
+
expect: { users: number; calls: number }
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/** The files of a new Codebuff chat continuing `session`. Pure apart from reading attached images. */
|
|
103
|
+
export function planChat(session: Session, opts: { now: Date; cwd: string; given?: string; fileContext: Json }): ChatPlan {
|
|
104
|
+
const drops = new Map<string, number>()
|
|
105
|
+
const drop = (what: string, n = 1) => drops.set(what, (drops.get(what) ?? 0) + n)
|
|
106
|
+
const label = toolLabel(session.header.tool)
|
|
107
|
+
const results = new Map<string, Result>()
|
|
108
|
+
const callIds = new Set<string>()
|
|
109
|
+
for (const t of session.turns) {
|
|
110
|
+
if (t.kind === "tool_result" && !results.has(t.callId)) results.set(t.callId, t)
|
|
111
|
+
if (t.kind === "tool_call") callIds.add(t.id)
|
|
112
|
+
}
|
|
113
|
+
// The model's history starts at the last compaction; the screen keeps everything.
|
|
114
|
+
let start = -1
|
|
115
|
+
session.turns.forEach((t, i) => {
|
|
116
|
+
if (t.kind === "compaction") start = i
|
|
117
|
+
})
|
|
118
|
+
|
|
119
|
+
// Times that never go backwards: the turn's own where it has one.
|
|
120
|
+
const known = session.turns.map((t) => (t.at ? Date.parse(t.at) : Number.NaN)).find(Number.isFinite)
|
|
121
|
+
let clock = Math.floor(known ?? opts.now.getTime()) - 1
|
|
122
|
+
const tick = (at?: string) => {
|
|
123
|
+
const t = at ? Date.parse(at) : Number.NaN
|
|
124
|
+
clock = Number.isFinite(t) && t > clock ? Math.floor(t) : clock + 1
|
|
125
|
+
return clock
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
const ids = new Map<string, string>()
|
|
129
|
+
const usedIds = new Set<string>()
|
|
130
|
+
let remapped = 0
|
|
131
|
+
const toolCallId = (id: string) => {
|
|
132
|
+
let v = ids.get(id)
|
|
133
|
+
if (v) return v
|
|
134
|
+
const base = /^[A-Za-z0-9_-]{1,64}$/.test(id) ? id : `call_${id.replace(/[^A-Za-z0-9]/g, "").slice(-40) || "x"}`
|
|
135
|
+
v = base
|
|
136
|
+
for (let n = 2; usedIds.has(v); n++) v = `${base}_${n}`
|
|
137
|
+
ids.set(id, v)
|
|
138
|
+
usedIds.add(v)
|
|
139
|
+
if (v !== id) remapped++
|
|
140
|
+
return v
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
const history: Json[] = []
|
|
144
|
+
const screen: Json[] = []
|
|
145
|
+
const expect = { users: 0, calls: 0 }
|
|
146
|
+
let renamed = 0
|
|
147
|
+
let thinking = 0
|
|
148
|
+
let ai: { at: number; blocks: Json[] } | undefined
|
|
149
|
+
const aiMessage = (at?: string) => (ai ??= { at: tick(at), blocks: [] })
|
|
150
|
+
const flushAi = () => {
|
|
151
|
+
if (ai?.blocks.length) screen.push({ id: `ai-${ai.at}-${randomBytes(7).toString("hex").slice(0, 13)}`, variant: "ai", content: "", blocks: ai.blocks, timestamp: clockTime(ai.at), metadata: {}, isComplete: true })
|
|
152
|
+
ai = undefined
|
|
153
|
+
}
|
|
154
|
+
const notice = (text: string, at?: string) => {
|
|
155
|
+
const ms = tick(at)
|
|
156
|
+
screen.push({ id: `sys-${ms}`, variant: "ai", content: text, timestamp: clockTime(ms) })
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
// Calls since the last result; their results are written together right after them, as the runtime does.
|
|
160
|
+
let batch: Array<{ call: Call; id: string; mapped: ReturnType<typeof mapCall>; block: Json; model: boolean }> = []
|
|
161
|
+
const flushCalls = () => {
|
|
162
|
+
for (const b of batch) {
|
|
163
|
+
const r = results.get(b.call.id)
|
|
164
|
+
let out = r?.output ?? "No result was recorded for this call; the session moved on before it finished."
|
|
165
|
+
if (r?.fullPath) out += `\n\n[Full output: ${r.fullPath}]`
|
|
166
|
+
if (r && !out.trim()) out = "(no output)"
|
|
167
|
+
if (!r) drop("tool calls without a result (closed as errors)")
|
|
168
|
+
else if (!r.ok && b.mapped.name === "run_terminal_command" && exitCodeOf(out) === undefined) drop("exit codes of failed shell commands, recorded as 1")
|
|
169
|
+
b.block.output = out
|
|
170
|
+
if (b.model) history.push({ role: "tool", toolName: b.mapped.name, toolCallId: b.id, content: [{ type: "json", value: b.mapped.result(!!r?.ok, out) }] })
|
|
171
|
+
}
|
|
172
|
+
batch = []
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
history.push({ role: "user", content: [{ type: "text", text: withSystemTags(continuedNote(label, session.header.id, opts.now.toISOString().slice(0, 10))) }], sentAt: tick() })
|
|
176
|
+
notice(`Continued from ${label} session ${session.header.sourceId}${session.header.title ? ` (${session.header.title})` : ""}.`)
|
|
177
|
+
|
|
178
|
+
const seen = new Set<string>()
|
|
179
|
+
session.turns.forEach((t, i) => {
|
|
180
|
+
const model = i > start
|
|
181
|
+
switch (t.kind) {
|
|
182
|
+
case "user": {
|
|
183
|
+
flushCalls()
|
|
184
|
+
flushAi()
|
|
185
|
+
const content: Json[] = [{ type: "text", text: t.text }]
|
|
186
|
+
const attachments: Json[] = []
|
|
187
|
+
let body = t.text
|
|
188
|
+
for (const a of t.attachments ?? []) {
|
|
189
|
+
if (!a.path) continue
|
|
190
|
+
if (a.kind === "file") {
|
|
191
|
+
body += `\n\n[Attached file: ${a.path}]`
|
|
192
|
+
continue
|
|
193
|
+
}
|
|
194
|
+
const mediaType = a.mime ?? IMAGE_TYPES[extname(a.path).toLowerCase()]
|
|
195
|
+
let size = Number.POSITIVE_INFINITY
|
|
196
|
+
try {
|
|
197
|
+
size = statSync(a.path).size
|
|
198
|
+
} catch {}
|
|
199
|
+
if (mediaType && Object.values(IMAGE_TYPES).includes(mediaType) && size <= IMAGE_LIMIT) {
|
|
200
|
+
content.push({ type: "image", image: readFileSync(a.path).toString("base64"), mediaType })
|
|
201
|
+
attachments.push({ filename: basename(a.path), path: a.path, size })
|
|
202
|
+
} else drop("images that could not be attached")
|
|
203
|
+
}
|
|
204
|
+
content[0] = { type: "text", text: body }
|
|
205
|
+
if (!body.trim() && content.length === 1) break
|
|
206
|
+
const ms = tick(t.at)
|
|
207
|
+
screen.push({ id: `user-${ms}`, variant: "user", content: body, timestamp: clockTime(ms), ...(attachments.length ? { attachments } : {}) })
|
|
208
|
+
if (model) {
|
|
209
|
+
history.push({ role: "user", content, sentAt: ms, tags: ["USER_PROMPT"], keepDuringTruncation: true })
|
|
210
|
+
expect.users++
|
|
211
|
+
}
|
|
212
|
+
break
|
|
213
|
+
}
|
|
214
|
+
case "assistant":
|
|
215
|
+
flushCalls()
|
|
216
|
+
if (!t.text.trim()) break
|
|
217
|
+
aiMessage(t.at).blocks.push({ type: "text", content: t.text, textType: "text" })
|
|
218
|
+
if (model) history.push({ role: "assistant", content: [{ type: "text", text: t.text }], sentAt: tick(t.at) })
|
|
219
|
+
break
|
|
220
|
+
case "reasoning":
|
|
221
|
+
flushCalls()
|
|
222
|
+
if (!t.text.trim()) break
|
|
223
|
+
aiMessage(t.at).blocks.push({ type: "text", content: t.text, textType: "reasoning", thinkingCollapseState: "preview", thinkingId: `thinking-${++thinking}`, thinkingOpen: false })
|
|
224
|
+
if (model) history.push({ role: "assistant", content: [{ type: "reasoning", text: t.text }], sentAt: tick(t.at) })
|
|
225
|
+
break
|
|
226
|
+
case "tool_call": {
|
|
227
|
+
if (seen.has(t.id)) break
|
|
228
|
+
seen.add(t.id)
|
|
229
|
+
const mapped = mapCall(t, [opts.cwd, ...(opts.given && opts.given !== opts.cwd ? [opts.given] : [])])
|
|
230
|
+
if (!mapped.native && mapped.name !== t.name) renamed++
|
|
231
|
+
const id = toolCallId(t.id)
|
|
232
|
+
const block: Json = { type: "tool", toolCallId: id, toolName: mapped.name, input: mapped.input }
|
|
233
|
+
aiMessage(t.at).blocks.push(block)
|
|
234
|
+
if (model) {
|
|
235
|
+
history.push({ role: "assistant", content: [{ toolCallId: id, toolName: mapped.name, input: mapped.input, type: "tool-call" }], sentAt: tick(t.at) })
|
|
236
|
+
expect.calls++
|
|
237
|
+
}
|
|
238
|
+
batch.push({ call: t, id, mapped, block, model })
|
|
239
|
+
break
|
|
240
|
+
}
|
|
241
|
+
case "tool_result":
|
|
242
|
+
if (!callIds.has(t.callId)) drop("tool results without a call")
|
|
243
|
+
else if (batch.some((b) => b.call.id === t.callId)) flushCalls()
|
|
244
|
+
break
|
|
245
|
+
case "compaction":
|
|
246
|
+
flushCalls()
|
|
247
|
+
flushAi()
|
|
248
|
+
notice("Earlier messages were summarized; the model continues from the summary.", t.at)
|
|
249
|
+
if (i === start) history.push({ role: "user", content: [{ type: "text", text: summaryMessageText(t.summary) }], sentAt: tick(t.at) })
|
|
250
|
+
break
|
|
251
|
+
case "file_edit":
|
|
252
|
+
if (t.callId && callIds.has(t.callId)) break
|
|
253
|
+
flushCalls()
|
|
254
|
+
aiMessage(t.at).blocks.push({ type: "text", content: `[Edited ${t.path}]${t.patch ? `\n\n\`\`\`diff\n${t.patch}\n\`\`\`` : ""}`, textType: "text" })
|
|
255
|
+
if (model) history.push({ role: "assistant", content: [{ type: "text", text: `[Edited ${t.path}]${t.patch ? `\n\n\`\`\`diff\n${t.patch}\n\`\`\`` : ""}` }], sentAt: tick(t.at) })
|
|
256
|
+
drop("file edits outside a tool call, kept as text")
|
|
257
|
+
break
|
|
258
|
+
case "subagent":
|
|
259
|
+
drop("subagent sessions (their results stay in the parent's tool results)")
|
|
260
|
+
break
|
|
261
|
+
}
|
|
262
|
+
})
|
|
263
|
+
flushCalls()
|
|
264
|
+
flushAi()
|
|
265
|
+
if (!screen.some((m) => m.variant === "user")) throw new Error("nothing Codebuff can show: the session has no user messages")
|
|
266
|
+
|
|
267
|
+
// A finished run's output: the messages after the last prompt (output mode last_message).
|
|
268
|
+
let lastPrompt = -1
|
|
269
|
+
history.forEach((m, i) => {
|
|
270
|
+
if (m.role === "user" && (m.tags as string[] | undefined)?.includes("USER_PROMPT")) lastPrompt = i
|
|
271
|
+
})
|
|
272
|
+
const runState = {
|
|
273
|
+
sessionState: {
|
|
274
|
+
mainAgentState: {
|
|
275
|
+
agentId: "main-agent",
|
|
276
|
+
agentType: null,
|
|
277
|
+
agentContext: {},
|
|
278
|
+
ancestorRunIds: [],
|
|
279
|
+
subagents: [],
|
|
280
|
+
childRunIds: [],
|
|
281
|
+
messageHistory: history,
|
|
282
|
+
stepsRemaining: MAX_AGENT_STEPS,
|
|
283
|
+
creditsUsed: 0,
|
|
284
|
+
directCreditsUsed: 0,
|
|
285
|
+
systemPrompt: "",
|
|
286
|
+
toolDefinitions: {},
|
|
287
|
+
contextTokenCount: 0,
|
|
288
|
+
},
|
|
289
|
+
fileContext: opts.fileContext,
|
|
290
|
+
},
|
|
291
|
+
output: { type: "lastMessage", value: history.slice(lastPrompt + 1) },
|
|
292
|
+
traceSessionId: randomUUID(),
|
|
293
|
+
inference: { source: "codebuff" },
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
const lost = [...drops].map(([what, n]) => `${what} (${n})`)
|
|
297
|
+
if (remapped) lost.push(`tool call ids, remapped to ids model APIs accept (${remapped})`)
|
|
298
|
+
if (renamed) lost.push(`tool names changed to fit model APIs (${renamed})`)
|
|
299
|
+
lost.push("Freebuff's skill list and symbol index in this chat's project snapshot (the skill and search tools still read the disk)")
|
|
300
|
+
return { runState, messages: screen, lost, expect }
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
/** chat-meta.json for `messages`, bound to the messages file as written (chat-meta.ts writeChatMeta). */
|
|
304
|
+
function chatMeta(dir: string, messages: Json[]) {
|
|
305
|
+
const first = messages.find((m) => m.variant === "user" && m.content)
|
|
306
|
+
const text = typeof first?.content === "string" ? first.content.trim() : ""
|
|
307
|
+
const st = statSync(join(dir, CHAT_MESSAGES))
|
|
308
|
+
return { messageCount: messages.length, firstPrompt: text ? (text.length > 100 ? `${text.slice(0, 97)}...` : text) : "(empty chat)", messagesSize: st.size, messagesMtimeMs: st.mtimeMs }
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
/** As write-file-atomic.ts: a private temp file renamed over the target. */
|
|
312
|
+
function writeAtomic(path: string, text: string) {
|
|
313
|
+
const tmp = `${path}.${process.pid}.${randomUUID()}.tmp`
|
|
314
|
+
writeFileSync(tmp, text, { mode: 0o600, flag: "wx" })
|
|
315
|
+
renameSync(tmp, path)
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
/** The resolved folder (/private/tmp, not /tmp): what process.cwd() gives the CLI there. */
|
|
319
|
+
function realCwd(cwd: string): string {
|
|
320
|
+
try {
|
|
321
|
+
return realpathSync(cwd)
|
|
322
|
+
} catch {
|
|
323
|
+
return cwd
|
|
324
|
+
}
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
/** The Codebuff writer; `bin` and `version` are for tests, which must not depend on an installed CLI. */
|
|
328
|
+
export function codebuffWriter(opts: { bin?: string; version?: string } = {}): Writer {
|
|
329
|
+
const cli = () => (opts.version ? { bin: opts.bin ?? "freebuff", version: opts.version } : installed())
|
|
330
|
+
return {
|
|
331
|
+
tool: TOOL,
|
|
332
|
+
label: LABEL,
|
|
333
|
+
way: "native",
|
|
334
|
+
|
|
335
|
+
async ready(w: WriteOptions): Promise<Readiness> {
|
|
336
|
+
const c = cli()
|
|
337
|
+
if (!c) return { ok: false, why: "neither Freebuff nor Codebuff is installed (no `freebuff` or `codebuff` npm package on PATH)" }
|
|
338
|
+
if (!TESTED_VERSIONS.includes(c.version)) return { ok: false, why: `${c.bin} ${c.version} is not a version this writer was checked against (${TESTED_VERSIONS.join(", ")})` }
|
|
339
|
+
if (!isDir(w.cwd)) return { ok: false, why: `${w.cwd} is not a folder; Codebuff keeps chats per project folder` }
|
|
340
|
+
return { ok: true }
|
|
341
|
+
},
|
|
342
|
+
|
|
343
|
+
async write(session: Session, w: WriteOptions): Promise<WriteResult> {
|
|
344
|
+
const cwd = realCwd(w.cwd)
|
|
345
|
+
const bin = cli()?.bin ?? "freebuff"
|
|
346
|
+
const now = new Date()
|
|
347
|
+
const root = chatsDir(w.home, cwd)
|
|
348
|
+
let id = chatId(now)
|
|
349
|
+
for (let ms = now.getTime() + 1; exists(join(root, id)); ms++) id = chatId(new Date(ms))
|
|
350
|
+
const dir = join(root, id)
|
|
351
|
+
// A dry run never walks the project: the snapshot does not change which files are written.
|
|
352
|
+
const plan = planChat(session, { now, cwd, given: resolve(w.cwd), fileContext: w.dryRun ? {} : fileContext(cwd, w.home) })
|
|
353
|
+
const files = [RUN_STATE, CHAT_MESSAGES, CHAT_META].map((f) => join(dir, f))
|
|
354
|
+
if (!w.dryRun) {
|
|
355
|
+
mkdirSync(dir, { recursive: true, mode: 0o700 })
|
|
356
|
+
try {
|
|
357
|
+
writeAtomic(files[0]!, JSON.stringify(plan.runState))
|
|
358
|
+
writeAtomic(files[1]!, JSON.stringify(plan.messages))
|
|
359
|
+
writeAtomic(files[2]!, JSON.stringify(chatMeta(dir, plan.messages)))
|
|
360
|
+
const back = readChat(dir, id)
|
|
361
|
+
const n = countTurns(back)
|
|
362
|
+
const pairs = unpaired(back)
|
|
363
|
+
if (n.user !== plan.expect.users || n.tool_call !== plan.expect.calls || pairs.calls.length || pairs.results.length)
|
|
364
|
+
throw new Error(`the Codebuff chat did not read back as written (users ${n.user}/${plan.expect.users}, tool calls ${n.tool_call}/${plan.expect.calls}, unpaired ${pairs.calls.length + pairs.results.length}); nothing was kept`)
|
|
365
|
+
} catch (e) {
|
|
366
|
+
rmSync(dir, { recursive: true, force: true })
|
|
367
|
+
throw e
|
|
368
|
+
}
|
|
369
|
+
}
|
|
370
|
+
return {
|
|
371
|
+
tool: TOOL,
|
|
372
|
+
way: "native",
|
|
373
|
+
sessionId: id,
|
|
374
|
+
written: files,
|
|
375
|
+
open: `cd ${shellQuote(cwd)} && ${bin} --continue ${id}`,
|
|
376
|
+
lost: plan.lost,
|
|
377
|
+
dryRun: !!w.dryRun,
|
|
378
|
+
}
|
|
379
|
+
},
|
|
380
|
+
}
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
export const codebuffNativeWriter = codebuffWriter()
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
import type { Reader, Writer } from "../types.ts"
|
|
2
|
+
import { codexReader } from "./reader.ts"
|
|
3
|
+
import { codexWriter } from "./writer.ts"
|
|
4
|
+
|
|
5
|
+
// OpenAI Codex CLI: reads rollouts (and legacy .json sessions), and continues any session as a new
|
|
6
|
+
// native Codex thread.
|
|
7
|
+
export const readers: Reader[] = [codexReader]
|
|
8
|
+
export const writers: Writer[] = [codexWriter]
|
|
@@ -0,0 +1,239 @@
|
|
|
1
|
+
import { createHash } from "node:crypto"
|
|
2
|
+
import { isContext, SUMMARY_PREFIX } from "./records.ts"
|
|
3
|
+
|
|
4
|
+
// The shapes Codex itself writes, which a converted session must match so Codex treats it as its own:
|
|
5
|
+
// how a compaction is recorded and rebuilt, how a shell call's output and an apply_patch are written, and
|
|
6
|
+
// how the model's context is sized. Checked against codex-rs at rust-v0.147.0 and against rollouts Codex wrote
|
|
7
|
+
// against a local stand-in server (core/src/compact.rs, rollout_reconstruction.rs, unified_exec, apply_patch).
|
|
8
|
+
|
|
9
|
+
/** Codex keeps this many tokens of earlier user messages across a compaction (compact.rs COMPACT_USER_MESSAGE_MAX_TOKENS). */
|
|
10
|
+
export const COMPACT_USER_BUDGET = 20_000
|
|
11
|
+
|
|
12
|
+
const bytesOf = (s: string) => Buffer.byteLength(s)
|
|
13
|
+
|
|
14
|
+
/** Codex's rough token count of a text: about four bytes a token, rounded up (approx_token_count). */
|
|
15
|
+
export const approxTokens = (text: string) => (bytesOf(text) + 3) >> 2
|
|
16
|
+
|
|
17
|
+
/** Codex's estimate of one history item: its serialized JSON over four, rounded up (history.rs estimate_item_token_count). */
|
|
18
|
+
export const itemTokens = (item: unknown) => Math.ceil(bytesOf(JSON.stringify(item) ?? "") / 4)
|
|
19
|
+
|
|
20
|
+
/** The text cut to about `tokens` tokens, on a character boundary, with a note of what was left out. */
|
|
21
|
+
export function cutToTokens(text: string, tokens: number): string {
|
|
22
|
+
const max = Math.max(0, tokens) * 4
|
|
23
|
+
const buf = Buffer.from(text)
|
|
24
|
+
if (buf.length <= max) return text
|
|
25
|
+
let end = max
|
|
26
|
+
while (end > 0 && (buf[end]! & 0xc0) === 0x80) end--
|
|
27
|
+
return `${buf.subarray(0, end).toString("utf8")}…[${approxTokens(text) - (end >> 2)} tokens truncated]…`
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/** A compaction's summary as Codex records and replays it: its prefix, a newline, then the summary. */
|
|
31
|
+
export const compactedMessage = (summary: string) => `${SUMMARY_PREFIX}\n${summary.trim() ? summary : "(no summary available)"}`
|
|
32
|
+
|
|
33
|
+
/** Whether a user message is a compaction summary Codex wrote (or this writer did): Codex never keeps those as user prompts. */
|
|
34
|
+
export const isSummaryMessage = (text: string) => text.startsWith(`${SUMMARY_PREFIX}\n`)
|
|
35
|
+
|
|
36
|
+
/** The summary's own text, without Codex's prefix. */
|
|
37
|
+
export const summaryBody = (message: string) => (isSummaryMessage(message) ? message.slice(SUMMARY_PREFIX.length + 1) : message)
|
|
38
|
+
|
|
39
|
+
const userItem = (text: string) => ({ type: "message", role: "user", content: [{ type: "input_text", text }] })
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* The history Codex rebuilds at a compaction (build_compacted_history): the person's earlier messages, newest first
|
|
43
|
+
* until the budget is used, in their original order, then the summary. Context Codex injects and earlier summaries
|
|
44
|
+
* are not kept. A rollout whose compacted record lacks this makes Codex fall back to its legacy replay, which adds the
|
|
45
|
+
* developer message and environment again on every resume.
|
|
46
|
+
*/
|
|
47
|
+
export function replacementHistory(userTexts: string[], message: string): Array<ReturnType<typeof userItem>> {
|
|
48
|
+
const kept: string[] = []
|
|
49
|
+
let left = COMPACT_USER_BUDGET
|
|
50
|
+
for (let i = userTexts.length - 1; i >= 0; i--) {
|
|
51
|
+
const text = userTexts[i]!
|
|
52
|
+
const n = approxTokens(text)
|
|
53
|
+
if (n <= left) {
|
|
54
|
+
kept.push(text)
|
|
55
|
+
left -= n
|
|
56
|
+
} else {
|
|
57
|
+
if (left > 0) kept.push(cutToTokens(text, left))
|
|
58
|
+
break
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
kept.reverse()
|
|
62
|
+
return [...kept, message].map(userItem)
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/** The texts of the person's messages in a history: user-role items that are neither Codex's context nor a summary. */
|
|
66
|
+
export function userTextsOf(history: Array<Record<string, any>>): string[] {
|
|
67
|
+
const out: string[] = []
|
|
68
|
+
for (const h of history) {
|
|
69
|
+
if (h.type !== "message" || h.role !== "user" || !Array.isArray(h.content)) continue
|
|
70
|
+
const text = h.content.map((c: any) => (typeof c?.text === "string" ? c.text : "")).join("")
|
|
71
|
+
if (text && !isContext(text) && !isSummaryMessage(text)) out.push(text)
|
|
72
|
+
}
|
|
73
|
+
return out
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/** A shell call's output as Codex's unified exec writes it, which is also where a failed command's exit code lives. */
|
|
77
|
+
export function execOutput(text: string, ok: boolean): string {
|
|
78
|
+
const body = text.replace(/^Exit code -?\d+\n?/, "")
|
|
79
|
+
const code = ok ? 0 : Number(text.match(/^Exit code (-?\d+)/)?.[1] ?? 1) || 1
|
|
80
|
+
const chunk = createHash("sha1").update(body).digest("hex").slice(0, 6)
|
|
81
|
+
return `Chunk ID: ${chunk}\nWall time: 0.0000 seconds\nProcess exited with code ${code}\nOriginal token count: ${approxTokens(body)}\nOutput:\n${body}`
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
export interface PatchFile {
|
|
85
|
+
/** Path as the source tool gave it. */
|
|
86
|
+
path: string
|
|
87
|
+
/** A unified diff: `--- a/…`, `+++ b/…`, then hunks. */
|
|
88
|
+
patch: string
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
export interface CodexPatch {
|
|
92
|
+
/** The patch in Codex's apply_patch grammar. */
|
|
93
|
+
text: string
|
|
94
|
+
/** What Codex shows for it (patch_apply_end.changes), keyed by absolute path. */
|
|
95
|
+
changes: Record<string, { type: "add"; content: string } | { type: "update"; unified_diff: string; move_path: null }>
|
|
96
|
+
/** "M path" / "A path" lines, as apply_patch reports them. */
|
|
97
|
+
summary: string[]
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* Unified diffs of the files one call changed, as Codex's apply_patch text and the display record Codex keeps for it.
|
|
102
|
+
* Undefined when a diff was cut short or does not parse, so the call stays as it was written.
|
|
103
|
+
*/
|
|
104
|
+
export function codexPatch(files: PatchFile[], cwd: string): CodexPatch | undefined {
|
|
105
|
+
if (!files.length) return undefined
|
|
106
|
+
const sections: string[] = []
|
|
107
|
+
const changes: CodexPatch["changes"] = {}
|
|
108
|
+
const summary: string[] = []
|
|
109
|
+
for (const f of files) {
|
|
110
|
+
if (/\[\.\.\. \d+ characters cut \.\.\.\]/.test(f.patch)) return undefined
|
|
111
|
+
const lines = f.patch.split("\n")
|
|
112
|
+
const first = lines.findIndex((l) => l.startsWith("@@"))
|
|
113
|
+
if (first < 0) return undefined
|
|
114
|
+
const abs = f.path.startsWith("/") ? f.path : `${cwd}/${f.path}`
|
|
115
|
+
const rel = abs.startsWith(`${cwd}/`) ? abs.slice(cwd.length + 1) : abs
|
|
116
|
+
const hunks: Array<{ header: string; body: string[] }> = []
|
|
117
|
+
for (const l of lines.slice(first)) {
|
|
118
|
+
if (l.startsWith("@@")) hunks.push({ header: l, body: [] })
|
|
119
|
+
else if (hunks.length && (l[0] === " " || l[0] === "+" || l[0] === "-")) hunks.at(-1)!.body.push(l)
|
|
120
|
+
else if (hunks.length && l === "") hunks.at(-1)!.body.push(" ")
|
|
121
|
+
}
|
|
122
|
+
if (!hunks.length) return undefined
|
|
123
|
+
const created = hunks.length === 1 && /^@@ -0,0 /.test(hunks[0]!.header)
|
|
124
|
+
if (created) {
|
|
125
|
+
const content = `${hunks[0]!.body.map((l) => l.slice(1)).join("\n")}\n`
|
|
126
|
+
sections.push(`*** Add File: ${rel}\n${hunks[0]!.body.join("\n")}`)
|
|
127
|
+
changes[abs] = { type: "add", content }
|
|
128
|
+
summary.push(`A ${rel}`)
|
|
129
|
+
} else {
|
|
130
|
+
sections.push(`*** Update File: ${rel}\n${hunks.map((h) => `@@\n${h.body.join("\n")}`).join("\n")}`)
|
|
131
|
+
changes[abs] = { type: "update", unified_diff: `${hunks.map((h) => `${h.header}\n${h.body.join("\n")}`).join("\n")}\n`, move_path: null }
|
|
132
|
+
summary.push(`M ${rel}`)
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
return { text: `*** Begin Patch\n${sections.join("\n")}\n*** End Patch`, changes, summary }
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/**
|
|
139
|
+
* The display record of one edit that cannot be written as an apply_patch (its diff was cut short, or the call is not
|
|
140
|
+
* one Codex could have made): a new file as its content, anything else as the hunks it has. Undefined when there is none.
|
|
141
|
+
*/
|
|
142
|
+
export function displayChange(patch: string, cwd: string, path: string): { path: string; change: CodexPatch["changes"][string] } | undefined {
|
|
143
|
+
const lines = patch.split("\n")
|
|
144
|
+
const first = lines.findIndex((l) => l.startsWith("@@"))
|
|
145
|
+
if (first < 0) return undefined
|
|
146
|
+
const abs = path.startsWith("/") ? path : `${cwd}/${path}`
|
|
147
|
+
if (/^@@ -0,0 /.test(lines[first]!)) {
|
|
148
|
+
const body = lines.slice(first + 1).filter((l) => l.startsWith("+")).map((l) => l.slice(1))
|
|
149
|
+
return { path: abs, change: { type: "add", content: `${body.join("\n")}\n` } }
|
|
150
|
+
}
|
|
151
|
+
return { path: abs, change: { type: "update", unified_diff: `${lines.slice(first).join("\n").replace(/\n$/, "")}\n`, move_path: null } }
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
/** The command Codex's exec_command runs for an apply_patch it intercepts. */
|
|
155
|
+
export const patchCommand = (patch: string) => `apply_patch <<'EOF'\n${patch}\nEOF\n`
|
|
156
|
+
|
|
157
|
+
/** The output of that command, and what apply_patch printed. */
|
|
158
|
+
export const patchStdout = (summary: string[]) => `Success. Updated the following files:\n${summary.join("\n")}\n`
|
|
159
|
+
export const patchOutput = (summary: string[]) => `Wall time: 0.0000 seconds\nOutput:\nExit code: 0\nWall time: 0.1 seconds\nOutput:\n${patchStdout(summary)}`
|
|
160
|
+
|
|
161
|
+
/** `mcp__server__tool` as Codex's MCP event names it: the server and the tool. */
|
|
162
|
+
export function mcpParts(name: string): { server: string; tool: string } | undefined {
|
|
163
|
+
const m = name.match(/^mcp__(.+?)__(.+)$/)
|
|
164
|
+
return m ? { server: m[1]!, tool: m[2]! } : undefined
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
/**
|
|
168
|
+
* A one-line title for a thread: the session's own title, or its first request, without the tags Claude Code wraps
|
|
169
|
+
* around pasted text or injects into messages, cut to about 60 characters at a word.
|
|
170
|
+
*/
|
|
171
|
+
export function plainTitle(text: string): string {
|
|
172
|
+
const t = text
|
|
173
|
+
.replace(/<(system-reminder|task-notification|ci-monitor-event|command-name|command-message|command-args|local-command-[a-z-]+)\b[^>]*>[\s\S]*?<\/\1>/g, " ")
|
|
174
|
+
.replace(/<\/?pasted_content\b[^>]*>/g, " ")
|
|
175
|
+
.replace(/\s+/g, " ")
|
|
176
|
+
.trim()
|
|
177
|
+
if (t.length <= 60) return t || "Session"
|
|
178
|
+
const cut = t.slice(0, 59)
|
|
179
|
+
const space = cut.lastIndexOf(" ")
|
|
180
|
+
return `${cut.slice(0, space > 30 ? space : 59)}…`
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
/** What registering a thread in Codex's own index came to; `named` says whether its title was set too. */
|
|
184
|
+
export type Registration = { ok: true; named?: boolean } | { ok: false; why: string }
|
|
185
|
+
|
|
186
|
+
/**
|
|
187
|
+
* Has Codex index a rollout it did not write, through its own read-repair: `thread/read` on its app server finds the
|
|
188
|
+
* file and adds the thread to state_5.sqlite, as it does for any rollout it meets. No model is called. Without it
|
|
189
|
+
* the thread is missing from the `codex resume` picker and `--last` resumes another thread instead.
|
|
190
|
+
*/
|
|
191
|
+
export async function registerThread(codexHome: string, threadId: string, cwd: string, name?: string, timeoutMs = 20_000): Promise<Registration> {
|
|
192
|
+
let proc: ReturnType<typeof Bun.spawn> | undefined
|
|
193
|
+
try {
|
|
194
|
+
proc = Bun.spawn(["codex", "app-server"], { cwd, env: { ...process.env, CODEX_HOME: codexHome }, stdin: "pipe", stdout: "pipe", stderr: "ignore" })
|
|
195
|
+
const send = (m: unknown) => (proc!.stdin as { write(s: string): void; flush(): void }).write(`${JSON.stringify(m)}\n`)
|
|
196
|
+
const reader = (proc.stdout as ReadableStream<Uint8Array>).getReader()
|
|
197
|
+
const decoder = new TextDecoder()
|
|
198
|
+
let buffer = ""
|
|
199
|
+
const response = async (id: number): Promise<any> => {
|
|
200
|
+
for (;;) {
|
|
201
|
+
const nl = buffer.indexOf("\n")
|
|
202
|
+
if (nl >= 0) {
|
|
203
|
+
const line = buffer.slice(0, nl)
|
|
204
|
+
buffer = buffer.slice(nl + 1)
|
|
205
|
+
try {
|
|
206
|
+
const msg = JSON.parse(line)
|
|
207
|
+
if (msg.id === id) return msg
|
|
208
|
+
} catch {}
|
|
209
|
+
continue
|
|
210
|
+
}
|
|
211
|
+
const { value, done } = await reader.read()
|
|
212
|
+
if (done) throw new Error("Codex's app server closed before it answered")
|
|
213
|
+
buffer += decoder.decode(value, { stream: true })
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
const run = (async () => {
|
|
217
|
+
send({ id: 1, method: "initialize", params: { clientInfo: { name: "brain", title: "brain", version: "0.1.0" }, capabilities: { experimentalApi: true } } })
|
|
218
|
+
const init = await response(1)
|
|
219
|
+
if (init.error) throw new Error(`initialize: ${init.error.message ?? "refused"}`)
|
|
220
|
+
send({ method: "initialized" })
|
|
221
|
+
send({ id: 2, method: "thread/read", params: { threadId, includeTurns: false } })
|
|
222
|
+
const read = await response(2)
|
|
223
|
+
if (read.error) throw new Error(`thread/read: ${read.error.message ?? "refused"}`)
|
|
224
|
+
if (!name) return undefined
|
|
225
|
+
// Codex's own way to title a thread (what /rename does), rather than leaving it to the first message's text.
|
|
226
|
+
send({ id: 3, method: "thread/name/set", params: { threadId, name } })
|
|
227
|
+
const named = await response(3)
|
|
228
|
+
return !named.error
|
|
229
|
+
})()
|
|
230
|
+
const named = await Promise.race([run, new Promise<never>((_, no) => setTimeout(() => no(new Error("timed out")), timeoutMs))])
|
|
231
|
+
return { ok: true, ...(named === undefined ? {} : { named }) }
|
|
232
|
+
} catch (e) {
|
|
233
|
+
return { ok: false, why: e instanceof Error ? e.message : String(e) }
|
|
234
|
+
} finally {
|
|
235
|
+
try {
|
|
236
|
+
proc?.kill()
|
|
237
|
+
} catch {}
|
|
238
|
+
}
|
|
239
|
+
}
|