@mmmjk/context-bridge 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/CHANGELOG.md +11 -0
- package/LICENSE +21 -0
- package/README.md +275 -0
- package/README.zh-CN.md +275 -0
- package/dist/src/adapters/claude-code/index.js +2 -0
- package/dist/src/adapters/claude-code/ingest.js +142 -0
- package/dist/src/adapters/claude-code/render.js +95 -0
- package/dist/src/adapters/claude-code/tool-map.js +52 -0
- package/dist/src/adapters/codex/apply-patch-parser.js +61 -0
- package/dist/src/adapters/codex/apply-patch.js +163 -0
- package/dist/src/adapters/codex/index.js +3 -0
- package/dist/src/adapters/codex/ingest.js +167 -0
- package/dist/src/adapters/codex/manifest.js +21 -0
- package/dist/src/adapters/codex/paths.js +15 -0
- package/dist/src/adapters/codex/render.js +90 -0
- package/dist/src/adapters/codex/tool-map.js +16 -0
- package/dist/src/canonical/ids.js +44 -0
- package/dist/src/canonical/schema.js +28 -0
- package/dist/src/canonical/tools.js +21 -0
- package/dist/src/cli.js +442 -0
- package/dist/src/mcp-server.js +75 -0
- package/dist/src/pair-map.js +20 -0
- package/dist/src/session-index.js +369 -0
- package/dist/src/sync-state.js +34 -0
- package/dist/src/sync.js +174 -0
- package/dist/src/title-sync.js +67 -0
- package/dist/src/translator.js +24 -0
- package/dist/src/utils/jsonl.js +30 -0
- package/dist/src/utils/path.js +12 -0
- package/package.json +52 -0
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
import { existsSync, readdirSync, readFileSync } from "node:fs";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import { isoNowZ, uuid7Str } from "../../canonical/ids.js";
|
|
4
|
+
import { createSession } from "../../canonical/schema.js";
|
|
5
|
+
import { ccToolToCanonical, translateArgs } from "./tool-map.js";
|
|
6
|
+
import { readJsonl } from "../../utils/jsonl.js";
|
|
7
|
+
const DROPPED_TYPES = new Set(["permission-mode", "file-history-snapshot", "last-prompt", "ai-title", "custom-title", "agent-name", "queue-operation"]);
|
|
8
|
+
export function ingest(jsonlPath, opts = {}) {
|
|
9
|
+
const rows = readJsonl(jsonlPath);
|
|
10
|
+
if (!rows.length)
|
|
11
|
+
throw new Error(`Empty or unreadable CC session: ${jsonlPath}`);
|
|
12
|
+
const header = extractHeader(rows);
|
|
13
|
+
const moments = rows.flatMap((row) => translateLine(row, jsonlPath)).sort((a, b) => a.ts.localeCompare(b.ts));
|
|
14
|
+
inferAssistantPhase(moments);
|
|
15
|
+
const subagent_transcripts = {};
|
|
16
|
+
const subagent_meta = {};
|
|
17
|
+
if (opts.follow_subagents !== false) {
|
|
18
|
+
const sessDir = path.join(path.dirname(jsonlPath), path.basename(jsonlPath, ".jsonl"), "subagents");
|
|
19
|
+
if (existsSync(sessDir)) {
|
|
20
|
+
for (const file of readdirSync(sessDir).filter((f) => f.startsWith("agent-") && f.endsWith(".jsonl"))) {
|
|
21
|
+
const agentId = file.replace(/^agent-/, "").replace(/\.jsonl$/, "");
|
|
22
|
+
const full = path.join(sessDir, file);
|
|
23
|
+
const sub = readJsonl(full).flatMap((row) => translateLine(row, full).map((m) => ({ ...m, agent_scope: `subagent:${agentId}` }))).sort((a, b) => a.ts.localeCompare(b.ts));
|
|
24
|
+
inferAssistantPhase(sub);
|
|
25
|
+
subagent_transcripts[agentId] = sub;
|
|
26
|
+
const metaPath = full.replace(/\.jsonl$/, ".meta.json");
|
|
27
|
+
if (existsSync(metaPath)) {
|
|
28
|
+
try {
|
|
29
|
+
subagent_meta[agentId] = JSON.parse(readFileSync(metaPath, "utf8"));
|
|
30
|
+
}
|
|
31
|
+
catch {
|
|
32
|
+
subagent_meta[agentId] = {};
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
return createSession({
|
|
39
|
+
id: uuid7Str(),
|
|
40
|
+
source_harness: "claude-code",
|
|
41
|
+
source_session_id: String(header.session_id ?? path.basename(jsonlPath, ".jsonl")),
|
|
42
|
+
source_session_path: jsonlPath,
|
|
43
|
+
cwd: String(header.cwd ?? process.cwd()),
|
|
44
|
+
git: { branch: header.git_branch, commit: null, repo_url: null },
|
|
45
|
+
model_hint: { provider: "anthropic", name: header.model, reasoning_effort: null },
|
|
46
|
+
started_at: String(header.started_at),
|
|
47
|
+
ended_at: header.ended_at ? String(header.ended_at) : null,
|
|
48
|
+
moments,
|
|
49
|
+
subagent_transcripts,
|
|
50
|
+
source_metadata: { claude_code: { version: header.version, userType: header.user_type, entrypoint: header.entrypoint, custom_title: header.custom_title, agent_name: header.agent_name, subagent_meta } },
|
|
51
|
+
});
|
|
52
|
+
}
|
|
53
|
+
function extractHeader(rows) {
|
|
54
|
+
const h = {};
|
|
55
|
+
for (const row of rows) {
|
|
56
|
+
if (row.sessionId && !h.session_id)
|
|
57
|
+
h.session_id = row.sessionId;
|
|
58
|
+
if (row.cwd && !h.cwd)
|
|
59
|
+
h.cwd = row.cwd;
|
|
60
|
+
if (row.gitBranch && !h.git_branch)
|
|
61
|
+
h.git_branch = row.gitBranch;
|
|
62
|
+
if (row.version && !h.version)
|
|
63
|
+
h.version = row.version;
|
|
64
|
+
if (row.userType && !h.user_type)
|
|
65
|
+
h.user_type = row.userType;
|
|
66
|
+
if (row.entrypoint && !h.entrypoint)
|
|
67
|
+
h.entrypoint = row.entrypoint;
|
|
68
|
+
if (row.type === "custom-title" && row.customTitle)
|
|
69
|
+
h.custom_title = row.customTitle;
|
|
70
|
+
if (row.type === "agent-name" && row.agentName)
|
|
71
|
+
h.agent_name = row.agentName;
|
|
72
|
+
const msg = row.message;
|
|
73
|
+
if (msg?.model && !h.model)
|
|
74
|
+
h.model = msg.model;
|
|
75
|
+
}
|
|
76
|
+
const timestamps = rows.map((r) => r.timestamp).filter(Boolean).map(String);
|
|
77
|
+
h.started_at = timestamps.length ? timestamps.sort()[0] : isoNowZ();
|
|
78
|
+
h.ended_at = timestamps.length ? timestamps.sort()[timestamps.length - 1] : null;
|
|
79
|
+
return h;
|
|
80
|
+
}
|
|
81
|
+
function translateLine(row, srcFile) {
|
|
82
|
+
const type = row.type;
|
|
83
|
+
if (DROPPED_TYPES.has(String(type)))
|
|
84
|
+
return [];
|
|
85
|
+
const ts = String(row.timestamp ?? isoNowZ());
|
|
86
|
+
const source_ref = { file: srcFile, uuid: row.uuid };
|
|
87
|
+
if (type === "user")
|
|
88
|
+
return translateUser(row, ts, source_ref);
|
|
89
|
+
if (type === "assistant")
|
|
90
|
+
return translateAssistant(row, ts, source_ref);
|
|
91
|
+
if (type === "attachment")
|
|
92
|
+
return [{ kind: "attachment", ts, source_ref, subtype: String(row.attachment?.type ?? "unknown"), data: row.attachment }];
|
|
93
|
+
return [];
|
|
94
|
+
}
|
|
95
|
+
function translateUser(row, ts, source_ref) {
|
|
96
|
+
const msg = row.message;
|
|
97
|
+
const content = msg?.content;
|
|
98
|
+
const prompt_id = row.promptId ? String(row.promptId) : null;
|
|
99
|
+
if (typeof content === "string")
|
|
100
|
+
return [{ kind: "user_text", ts, source_ref, text: content, prompt_id }];
|
|
101
|
+
if (Array.isArray(content)) {
|
|
102
|
+
const out = [];
|
|
103
|
+
for (const block of content) {
|
|
104
|
+
if (block.type === "text")
|
|
105
|
+
out.push({ kind: "user_text", ts, source_ref, text: String(block.text ?? ""), prompt_id });
|
|
106
|
+
if (block.type === "tool_result")
|
|
107
|
+
out.push({ kind: "tool_result", ts, source_ref, call_id: String(block.tool_use_id ?? ""), output_text: typeof block.content === "string" ? block.content : JSON.stringify(block.content ?? ""), is_error: Boolean(block.is_error) });
|
|
108
|
+
}
|
|
109
|
+
return out;
|
|
110
|
+
}
|
|
111
|
+
return [];
|
|
112
|
+
}
|
|
113
|
+
function translateAssistant(row, ts, source_ref) {
|
|
114
|
+
const msg = row.message;
|
|
115
|
+
const content = msg?.content;
|
|
116
|
+
const out = [];
|
|
117
|
+
if (!Array.isArray(content))
|
|
118
|
+
return out;
|
|
119
|
+
for (const block of content) {
|
|
120
|
+
if (block.type === "text")
|
|
121
|
+
out.push({ kind: "assistant_text", ts, source_ref, text: String(block.text ?? ""), phase: null });
|
|
122
|
+
if (block.type === "tool_use") {
|
|
123
|
+
const tool = ccToolToCanonical(String(block.name ?? ""));
|
|
124
|
+
out.push({ kind: "tool_call", ts, source_ref, tool, call_id: String(block.id ?? ""), args: translateArgs(tool, block.input ?? {}), wire_native: { harness: "claude-code", name: block.name, input: block.input } });
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
return out;
|
|
128
|
+
}
|
|
129
|
+
function inferAssistantPhase(moments) {
|
|
130
|
+
for (let i = 0; i < moments.length; i++) {
|
|
131
|
+
const m = moments[i];
|
|
132
|
+
if (m.kind !== "assistant_text")
|
|
133
|
+
continue;
|
|
134
|
+
let end = moments.length;
|
|
135
|
+
for (let j = i + 1; j < moments.length; j++)
|
|
136
|
+
if (moments[j].kind === "user_text") {
|
|
137
|
+
end = j;
|
|
138
|
+
break;
|
|
139
|
+
}
|
|
140
|
+
m.phase = moments.slice(i + 1, end).some((x) => x.kind === "tool_call") ? "commentary" : "final_answer";
|
|
141
|
+
}
|
|
142
|
+
}
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
import { existsSync, mkdirSync, readFileSync, utimesSync } from "node:fs";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import { homedir } from "node:os";
|
|
4
|
+
import { uuid4Str } from "../../canonical/ids.js";
|
|
5
|
+
import { writeJsonl } from "../../utils/jsonl.js";
|
|
6
|
+
export let CC_PROJECTS = path.join(homedir(), ".claude", "projects");
|
|
7
|
+
export function setCcProjects(p) { CC_PROJECTS = p; }
|
|
8
|
+
export function encodeCwd(cwd) {
|
|
9
|
+
let resolved = path.resolve(cwd).normalize("NFC");
|
|
10
|
+
return resolved.replace(/\//g, "-");
|
|
11
|
+
}
|
|
12
|
+
export function render(session, opts = {}) {
|
|
13
|
+
const sessId = opts.session_id ?? uuid4Str();
|
|
14
|
+
const root = opts.target_dir ?? CC_PROJECTS;
|
|
15
|
+
const outDir = path.join(root, encodeCwd(session.cwd));
|
|
16
|
+
const outPath = path.join(outDir, `${sessId}.jsonl`);
|
|
17
|
+
if (existsSync(outPath) && !isAgentBridgeCc(outPath))
|
|
18
|
+
throw new Error(`Refusing to overwrite real Claude Code session: ${outPath}`);
|
|
19
|
+
const lines = [];
|
|
20
|
+
if (!opts.copy_mode)
|
|
21
|
+
lines.push({ type: "context-bridge-meta", sessionId: sessId, source_harness: session.source_harness, originator: "context-bridge" });
|
|
22
|
+
const title = makeTitle(session, opts.title_prefix);
|
|
23
|
+
if (title)
|
|
24
|
+
lines.push({ type: "custom-title", customTitle: title, sessionId: sessId });
|
|
25
|
+
let parent = null;
|
|
26
|
+
for (const moment of session.moments) {
|
|
27
|
+
for (const row of renderMoment(moment, session, sessId, parent, opts.cc_version ?? "2.1.107")) {
|
|
28
|
+
lines.push(row);
|
|
29
|
+
parent = String(row.uuid ?? parent);
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
mkdirSync(outDir, { recursive: true });
|
|
33
|
+
writeJsonl(outPath, lines);
|
|
34
|
+
backdateMtime(outPath, session.ended_at ?? session.started_at);
|
|
35
|
+
return { session_id: sessId, primary_path: outPath, resume_command: `claude --resume ${sessId} -p "<your prompt>"`, warnings: [] };
|
|
36
|
+
}
|
|
37
|
+
function renderMoment(moment, session, sessId, parent, version) {
|
|
38
|
+
const base = { parentUuid: parent, isSidechain: false, uuid: uuid4Str(), timestamp: moment.ts, userType: "external", entrypoint: "cli", cwd: session.cwd, sessionId: sessId, version, gitBranch: session.git?.branch ?? "HEAD" };
|
|
39
|
+
if (moment.kind === "user_text")
|
|
40
|
+
return [{ ...base, type: "user", message: { role: "user", content: moment.text } }];
|
|
41
|
+
if (moment.kind === "assistant_text")
|
|
42
|
+
return [{ ...base, type: "assistant", message: { content: [{ text: moment.text, type: "text" }], id: `msg_${uuid4Str().replaceAll("-", "").slice(0, 32)}`, role: "assistant", stop_reason: "end_turn", type: "message" } }];
|
|
43
|
+
if (moment.kind === "tool_call") {
|
|
44
|
+
const [name, input] = canonicalToCcTool(moment);
|
|
45
|
+
return [{ ...base, type: "assistant", message: { content: [{ id: moment.call_id, input, name, type: "tool_use" }], id: `msg_${uuid4Str().replaceAll("-", "").slice(0, 32)}`, role: "assistant", stop_reason: "tool_use", type: "message" } }];
|
|
46
|
+
}
|
|
47
|
+
if (moment.kind === "tool_result")
|
|
48
|
+
return [{ ...base, type: "user", message: { role: "user", content: [{ tool_use_id: moment.call_id, type: "tool_result", content: moment.output_text ?? "", is_error: moment.is_error ?? false }] } }];
|
|
49
|
+
if (moment.kind === "attachment")
|
|
50
|
+
return [{ ...base, type: "attachment", attachment: { type: moment.subtype, ...(moment.data ?? {}) } }];
|
|
51
|
+
return [];
|
|
52
|
+
}
|
|
53
|
+
function canonicalToCcTool(call) {
|
|
54
|
+
const args = call.args ?? {};
|
|
55
|
+
if (call.wire_native?.harness === "claude-code")
|
|
56
|
+
return [String(call.wire_native.name), call.wire_native.input ?? args];
|
|
57
|
+
if (call.tool === "shell")
|
|
58
|
+
return ["Bash", { command: args.command ?? "" }];
|
|
59
|
+
if (call.tool === "read_file")
|
|
60
|
+
return ["Read", { file_path: args.path ?? "" }];
|
|
61
|
+
if (call.tool === "write_file")
|
|
62
|
+
return ["Write", { file_path: args.path ?? "", content: args.content ?? "" }];
|
|
63
|
+
if (call.tool === "edit_file")
|
|
64
|
+
return ["Edit", { file_path: args.path ?? "", old_string: args.old ?? "", new_string: args.new ?? "", replace_all: args.replace_all ?? false }];
|
|
65
|
+
if (call.tool === "multi_edit_file")
|
|
66
|
+
return ["MultiEdit", { file_path: args.path ?? "", edits: args.edits ?? [] }];
|
|
67
|
+
if (call.tool === "delete_file")
|
|
68
|
+
return ["Delete", { file_path: args.path ?? "" }];
|
|
69
|
+
if (call.tool === "move_file")
|
|
70
|
+
return ["Move", { source: args.source ?? args.from ?? "", destination: args.destination ?? args.to ?? "" }];
|
|
71
|
+
if (call.tool === "search_text")
|
|
72
|
+
return ["Grep", { pattern: args.pattern ?? "", path: args.path ?? "." }];
|
|
73
|
+
if (call.tool === "find_files")
|
|
74
|
+
return ["Glob", { pattern: args.pattern ?? "*", path: args.path ?? "." }];
|
|
75
|
+
return ["Bash", { command: `echo '(translated unknown tool: ${call.tool})'` }];
|
|
76
|
+
}
|
|
77
|
+
function makeTitle(session, prefix) {
|
|
78
|
+
const custom = session.source_metadata?.codex?.thread_name;
|
|
79
|
+
const body = typeof custom === "string" && custom ? custom : session.moments.find((m) => m.kind === "user_text")?.text.slice(0, 80);
|
|
80
|
+
return body ? `${prefix ?? ""}${body}` : null;
|
|
81
|
+
}
|
|
82
|
+
function isAgentBridgeCc(p) {
|
|
83
|
+
try {
|
|
84
|
+
const head = readFileSync(p, "utf8").slice(0, 4096);
|
|
85
|
+
return head.includes('"customTitle":"[from ') || head.includes('"customTitle": "[from ');
|
|
86
|
+
}
|
|
87
|
+
catch {
|
|
88
|
+
return false;
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
function backdateMtime(p, iso) {
|
|
92
|
+
const d = new Date(iso);
|
|
93
|
+
if (!Number.isNaN(d.getTime()))
|
|
94
|
+
utimesSync(p, d, d);
|
|
95
|
+
}
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
export const CC_TO_CANONICAL = {
|
|
2
|
+
Bash: "shell",
|
|
3
|
+
Read: "read_file",
|
|
4
|
+
Write: "write_file",
|
|
5
|
+
Edit: "edit_file",
|
|
6
|
+
MultiEdit: "multi_edit_file",
|
|
7
|
+
Delete: "delete_file",
|
|
8
|
+
Move: "move_file",
|
|
9
|
+
Glob: "find_files",
|
|
10
|
+
Grep: "search_text",
|
|
11
|
+
WebSearch: "web_search",
|
|
12
|
+
Task: "subagent_dispatch",
|
|
13
|
+
TodoWrite: "update_plan",
|
|
14
|
+
};
|
|
15
|
+
export function ccToolToCanonical(name) {
|
|
16
|
+
return CC_TO_CANONICAL[name] ?? name;
|
|
17
|
+
}
|
|
18
|
+
export function translateArgs(canonicalName, ccInput) {
|
|
19
|
+
switch (canonicalName) {
|
|
20
|
+
case "shell":
|
|
21
|
+
return { command: ccInput.command ?? "" };
|
|
22
|
+
case "read_file":
|
|
23
|
+
return { path: ccInput.file_path ?? ccInput.path ?? "" };
|
|
24
|
+
case "write_file":
|
|
25
|
+
return { path: ccInput.file_path ?? ccInput.path ?? "", content: ccInput.content ?? "" };
|
|
26
|
+
case "edit_file":
|
|
27
|
+
return {
|
|
28
|
+
path: ccInput.file_path ?? ccInput.path ?? "",
|
|
29
|
+
old: ccInput.old_string ?? ccInput.old ?? "",
|
|
30
|
+
new: ccInput.new_string ?? ccInput.new ?? "",
|
|
31
|
+
replace_all: ccInput.replace_all ?? false,
|
|
32
|
+
};
|
|
33
|
+
case "multi_edit_file":
|
|
34
|
+
return { path: ccInput.file_path ?? ccInput.path ?? "", edits: ccInput.edits ?? [] };
|
|
35
|
+
case "delete_file":
|
|
36
|
+
return { path: ccInput.file_path ?? ccInput.path ?? "" };
|
|
37
|
+
case "move_file":
|
|
38
|
+
return { source: ccInput.source ?? ccInput.old_path ?? "", destination: ccInput.destination ?? ccInput.new_path ?? "" };
|
|
39
|
+
case "find_files":
|
|
40
|
+
return { pattern: ccInput.pattern ?? "*", path: ccInput.path ?? "." };
|
|
41
|
+
case "search_text":
|
|
42
|
+
return { pattern: ccInput.pattern ?? "", path: ccInput.path ?? "." };
|
|
43
|
+
case "web_search":
|
|
44
|
+
return { query: ccInput.query ?? "" };
|
|
45
|
+
case "subagent_dispatch":
|
|
46
|
+
return { agent_type: ccInput.subagent_type ?? "general-purpose", task: ccInput.description ?? "", prompt: ccInput.prompt ?? "" };
|
|
47
|
+
case "update_plan":
|
|
48
|
+
return { items: ccInput.todos ?? [] };
|
|
49
|
+
default:
|
|
50
|
+
return { ...ccInput };
|
|
51
|
+
}
|
|
52
|
+
}
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
export class ApplyPatchParseError extends Error {
|
|
2
|
+
}
|
|
3
|
+
export function parseApplyPatch(patchText) {
|
|
4
|
+
const lines = patchText.split(/\r?\n/);
|
|
5
|
+
const start = lines.findIndex((l) => l.trim() === "*** Begin Patch");
|
|
6
|
+
const end = lines.findIndex((l) => l.trim() === "*** End Patch");
|
|
7
|
+
if (start < 0 || end < 0)
|
|
8
|
+
throw new ApplyPatchParseError("missing *** Begin Patch / *** End Patch envelope");
|
|
9
|
+
const body = lines.slice(start + 1, end);
|
|
10
|
+
const ops = [];
|
|
11
|
+
let i = 0;
|
|
12
|
+
while (i < body.length) {
|
|
13
|
+
const line = body[i];
|
|
14
|
+
if (line.startsWith("*** Add File: ")) {
|
|
15
|
+
const filePath = line.slice("*** Add File: ".length);
|
|
16
|
+
const add_lines = [];
|
|
17
|
+
i++;
|
|
18
|
+
while (i < body.length && !body[i].startsWith("*** ")) {
|
|
19
|
+
if (body[i].startsWith("+"))
|
|
20
|
+
add_lines.push(body[i].slice(1));
|
|
21
|
+
i++;
|
|
22
|
+
}
|
|
23
|
+
ops.push({ kind: "add", path: filePath, add_lines });
|
|
24
|
+
}
|
|
25
|
+
else if (line.startsWith("*** Delete File: ")) {
|
|
26
|
+
ops.push({ kind: "delete", path: line.slice("*** Delete File: ".length) });
|
|
27
|
+
i++;
|
|
28
|
+
}
|
|
29
|
+
else if (line.startsWith("*** Update File: ")) {
|
|
30
|
+
const filePath = line.slice("*** Update File: ".length);
|
|
31
|
+
let move_to;
|
|
32
|
+
i++;
|
|
33
|
+
if (i < body.length && body[i].startsWith("*** Move to: ")) {
|
|
34
|
+
move_to = body[i].slice("*** Move to: ".length);
|
|
35
|
+
i++;
|
|
36
|
+
}
|
|
37
|
+
const hunks = [];
|
|
38
|
+
let cur;
|
|
39
|
+
while (i < body.length && !body[i].startsWith("*** ")) {
|
|
40
|
+
const l = body[i];
|
|
41
|
+
if (l.startsWith("@@")) {
|
|
42
|
+
if (cur)
|
|
43
|
+
hunks.push(cur);
|
|
44
|
+
cur = { header: l.slice(2).trim() || undefined, lines: [] };
|
|
45
|
+
}
|
|
46
|
+
else if (l && [" ", "+", "-"].includes(l[0])) {
|
|
47
|
+
cur ??= { lines: [] };
|
|
48
|
+
cur.lines.push({ op: l[0], text: l.slice(1) });
|
|
49
|
+
}
|
|
50
|
+
i++;
|
|
51
|
+
}
|
|
52
|
+
if (cur)
|
|
53
|
+
hunks.push(cur);
|
|
54
|
+
ops.push({ kind: "update", path: filePath, move_to, hunks });
|
|
55
|
+
}
|
|
56
|
+
else {
|
|
57
|
+
i++;
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
return ops;
|
|
61
|
+
}
|
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
import path from "node:path";
|
|
2
|
+
export class FileStateCache {
|
|
3
|
+
contents = new Map();
|
|
4
|
+
get(filePath) {
|
|
5
|
+
return this.contents.get(filePath);
|
|
6
|
+
}
|
|
7
|
+
set(filePath, content) {
|
|
8
|
+
this.contents.set(filePath, content);
|
|
9
|
+
}
|
|
10
|
+
drop(filePath) {
|
|
11
|
+
this.contents.delete(filePath);
|
|
12
|
+
}
|
|
13
|
+
rename(oldPath, newPath) {
|
|
14
|
+
if (this.contents.has(oldPath)) {
|
|
15
|
+
this.contents.set(newPath, this.contents.get(oldPath) ?? "");
|
|
16
|
+
this.contents.delete(oldPath);
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
clone() {
|
|
20
|
+
const c = new FileStateCache();
|
|
21
|
+
c.contents = new Map(this.contents);
|
|
22
|
+
return c;
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
export function stripCatN(text) {
|
|
26
|
+
const suffix = text.endsWith("\n") ? "\n" : "";
|
|
27
|
+
return text
|
|
28
|
+
.split(/\r?\n/)
|
|
29
|
+
.filter((_, i, arr) => !(i === arr.length - 1 && arr[i] === ""))
|
|
30
|
+
.map((line) => line.replace(/^\s*\d+\t/, ""))
|
|
31
|
+
.join("\n") + suffix;
|
|
32
|
+
}
|
|
33
|
+
export function buildFileState(moments) {
|
|
34
|
+
const snapshots = {};
|
|
35
|
+
const cache = new FileStateCache();
|
|
36
|
+
const pendingReads = new Map();
|
|
37
|
+
for (const m of moments) {
|
|
38
|
+
if (m.kind === "tool_call") {
|
|
39
|
+
const args = m.args ?? {};
|
|
40
|
+
if (m.tool === "read_file")
|
|
41
|
+
pendingReads.set(m.call_id, String(args.path ?? ""));
|
|
42
|
+
if (["write_file", "edit_file", "multi_edit_file", "delete_file", "move_file"].includes(m.tool)) {
|
|
43
|
+
snapshots[m.call_id] = cache.clone();
|
|
44
|
+
applyEditToCache(cache, m);
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
else if (m.kind === "tool_result" && pendingReads.has(m.call_id)) {
|
|
48
|
+
cache.set(pendingReads.get(m.call_id) ?? "", stripCatN(m.output_text ?? ""));
|
|
49
|
+
pendingReads.delete(m.call_id);
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
return snapshots;
|
|
53
|
+
}
|
|
54
|
+
function applyEditToCache(cache, call) {
|
|
55
|
+
const args = call.args ?? {};
|
|
56
|
+
const filePath = String(args.path ?? "");
|
|
57
|
+
if (!filePath)
|
|
58
|
+
return;
|
|
59
|
+
if (call.tool === "write_file")
|
|
60
|
+
cache.set(filePath, String(args.content ?? ""));
|
|
61
|
+
if (call.tool === "edit_file") {
|
|
62
|
+
const cur = cache.get(filePath);
|
|
63
|
+
const oldText = String(args.old ?? "");
|
|
64
|
+
const newText = String(args.new ?? "");
|
|
65
|
+
if (cur !== undefined && oldText) {
|
|
66
|
+
cache.set(filePath, args.replace_all ? cur.split(oldText).join(newText) : cur.replace(oldText, newText));
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
if (call.tool === "multi_edit_file") {
|
|
70
|
+
let cur = cache.get(filePath);
|
|
71
|
+
if (cur !== undefined) {
|
|
72
|
+
for (const ed of args.edits ?? []) {
|
|
73
|
+
cur = cur.replace(String(ed.old ?? ed.old_string ?? ""), String(ed.new ?? ed.new_string ?? ""));
|
|
74
|
+
}
|
|
75
|
+
cache.set(filePath, cur);
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
if (call.tool === "delete_file")
|
|
79
|
+
cache.drop(filePath);
|
|
80
|
+
if (call.tool === "move_file")
|
|
81
|
+
cache.rename(String(args.from ?? args.source ?? filePath), String(args.to ?? args.destination ?? filePath));
|
|
82
|
+
}
|
|
83
|
+
export function toRelative(absPath, cwd) {
|
|
84
|
+
const rel = path.relative(path.resolve(cwd), path.resolve(absPath));
|
|
85
|
+
return rel && !rel.startsWith("..") && !path.isAbsolute(rel) ? rel : absPath;
|
|
86
|
+
}
|
|
87
|
+
export function composePatchEnvelope(body) {
|
|
88
|
+
return `*** Begin Patch\n${body.endsWith("\n") ? body : `${body}\n`}*** End Patch\n`;
|
|
89
|
+
}
|
|
90
|
+
function composeAddFile(relPath, content) {
|
|
91
|
+
const body = content.split(/\r?\n/).filter((line, i, arr) => !(i === arr.length - 1 && line === "")).map((line) => `+${line}`).join("\n");
|
|
92
|
+
return `*** Add File: ${relPath}\n${body}${body ? "\n" : ""}`;
|
|
93
|
+
}
|
|
94
|
+
function composeDeleteFile(relPath) {
|
|
95
|
+
return `*** Delete File: ${relPath}\n`;
|
|
96
|
+
}
|
|
97
|
+
function composeUpdateFile(relPath, hunks, moveTo) {
|
|
98
|
+
const lines = [`*** Update File: ${relPath}`];
|
|
99
|
+
if (moveTo)
|
|
100
|
+
lines.push(`*** Move to: ${moveTo}`);
|
|
101
|
+
for (const [ctx, removed, added] of hunks) {
|
|
102
|
+
lines.push("@@");
|
|
103
|
+
for (const c of ctx.slice(-3))
|
|
104
|
+
lines.push(` ${c}`);
|
|
105
|
+
for (const r of removed)
|
|
106
|
+
lines.push(`-${r}`);
|
|
107
|
+
for (const a of added)
|
|
108
|
+
lines.push(`+${a}`);
|
|
109
|
+
}
|
|
110
|
+
return `${lines.join("\n")}\n`;
|
|
111
|
+
}
|
|
112
|
+
export function patchForWrite(relPath, content, fileExisted) {
|
|
113
|
+
return composePatchEnvelope(fileExisted ? composeDeleteFile(relPath) + composeAddFile(relPath, content) : composeAddFile(relPath, content));
|
|
114
|
+
}
|
|
115
|
+
export function patchForEdit(relPath, oldText, newText, fileContent, replaceAll = false) {
|
|
116
|
+
const hunks = [];
|
|
117
|
+
if (fileContent === undefined) {
|
|
118
|
+
hunks.push([[], oldText.split(/\r?\n/), newText.split(/\r?\n/)]);
|
|
119
|
+
}
|
|
120
|
+
else {
|
|
121
|
+
const positions = [];
|
|
122
|
+
if (replaceAll) {
|
|
123
|
+
let start = 0;
|
|
124
|
+
while (oldText) {
|
|
125
|
+
const idx = fileContent.indexOf(oldText, start);
|
|
126
|
+
if (idx < 0)
|
|
127
|
+
break;
|
|
128
|
+
positions.push(idx);
|
|
129
|
+
start = idx + oldText.length;
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
else {
|
|
133
|
+
const idx = fileContent.indexOf(oldText);
|
|
134
|
+
if (idx >= 0)
|
|
135
|
+
positions.push(idx);
|
|
136
|
+
}
|
|
137
|
+
if (!positions.length)
|
|
138
|
+
positions.push(-1);
|
|
139
|
+
for (const idx of positions) {
|
|
140
|
+
hunks.push([idx >= 0 ? fileContent.slice(0, idx).split(/\r?\n/) : [], oldText.split(/\r?\n/), newText.split(/\r?\n/)]);
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
return composePatchEnvelope(composeUpdateFile(relPath, hunks));
|
|
144
|
+
}
|
|
145
|
+
export function patchForMultiEdit(relPath, edits, fileContent) {
|
|
146
|
+
const hunks = [];
|
|
147
|
+
let cur = fileContent;
|
|
148
|
+
for (const ed of edits) {
|
|
149
|
+
const oldText = String(ed.old ?? ed.old_string ?? "");
|
|
150
|
+
const newText = String(ed.new ?? ed.new_string ?? "");
|
|
151
|
+
const idx = cur?.indexOf(oldText) ?? -1;
|
|
152
|
+
hunks.push([cur !== undefined && idx >= 0 ? cur.slice(0, idx).split(/\r?\n/) : [], oldText.split(/\r?\n/), newText.split(/\r?\n/)]);
|
|
153
|
+
if (cur !== undefined && idx >= 0)
|
|
154
|
+
cur = cur.slice(0, idx) + newText + cur.slice(idx + oldText.length);
|
|
155
|
+
}
|
|
156
|
+
return composePatchEnvelope(composeUpdateFile(relPath, hunks));
|
|
157
|
+
}
|
|
158
|
+
export function patchForDelete(relPath) {
|
|
159
|
+
return composePatchEnvelope(composeDeleteFile(relPath));
|
|
160
|
+
}
|
|
161
|
+
export function patchForMove(oldRel, newRel) {
|
|
162
|
+
return composePatchEnvelope(composeUpdateFile(oldRel, [], newRel));
|
|
163
|
+
}
|