sakura-agent 0.1.0 → 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.
Files changed (91) hide show
  1. package/README.md +144 -78
  2. package/README.zh-CN.md +145 -79
  3. package/THIRD_PARTY_NOTICES.md +8 -58
  4. package/dist/packages/agent/src/index.d.ts +49 -11
  5. package/dist/packages/agent/src/index.js +50 -55
  6. package/dist/packages/agent/src/restrict-tools.d.ts +6 -0
  7. package/dist/packages/agent/src/restrict-tools.js +11 -0
  8. package/dist/packages/agent/src/system-prompt.d.ts +5 -0
  9. package/dist/packages/agent/src/system-prompt.js +1 -1
  10. package/dist/packages/agent/src/wake.d.ts +41 -0
  11. package/dist/packages/agent/src/wake.js +71 -0
  12. package/dist/packages/env/src/confine.d.ts +13 -0
  13. package/dist/packages/env/src/confine.js +40 -0
  14. package/dist/packages/env/src/connect-ssh.d.ts +33 -0
  15. package/dist/packages/env/src/connect-ssh.js +132 -0
  16. package/dist/packages/env/src/index.d.ts +5 -1
  17. package/dist/packages/env/src/index.js +3 -1
  18. package/dist/packages/env/src/local.d.ts +11 -2
  19. package/dist/packages/env/src/local.js +42 -10
  20. package/dist/packages/env/src/remote.d.ts +17 -1
  21. package/dist/packages/env/src/remote.js +54 -4
  22. package/dist/packages/evals/src/bfcl.d.ts +24 -0
  23. package/dist/packages/evals/src/bfcl.js +71 -0
  24. package/dist/packages/evals/src/index.d.ts +3 -0
  25. package/dist/packages/evals/src/index.js +2 -0
  26. package/dist/packages/evals/src/png.d.ts +2 -0
  27. package/dist/packages/evals/src/png.js +30 -0
  28. package/dist/packages/evals/src/pool.d.ts +3 -0
  29. package/dist/packages/evals/src/pool.js +22 -0
  30. package/dist/packages/evals/src/providers.d.ts +13 -0
  31. package/dist/packages/evals/src/providers.js +23 -0
  32. package/dist/packages/kernel/src/compaction.d.ts +19 -0
  33. package/dist/packages/kernel/src/compaction.js +12 -2
  34. package/dist/packages/kernel/src/goal.d.ts +10 -1
  35. package/dist/packages/kernel/src/goal.js +28 -0
  36. package/dist/packages/kernel/src/hooks.d.ts +5 -0
  37. package/dist/packages/kernel/src/hooks.js +21 -1
  38. package/dist/packages/kernel/src/index.d.ts +3 -2
  39. package/dist/packages/kernel/src/index.js +1 -0
  40. package/dist/packages/kernel/src/loop.d.ts +28 -4
  41. package/dist/packages/kernel/src/loop.js +69 -47
  42. package/dist/packages/kernel/src/types.d.ts +25 -4
  43. package/dist/packages/kernel/src/usage.d.ts +3 -0
  44. package/dist/packages/kernel/src/usage.js +16 -0
  45. package/dist/packages/llm/src/chat/request.js +22 -4
  46. package/dist/packages/llm/src/chat/retry.d.ts +1 -1
  47. package/dist/packages/llm/src/chat/retry.js +1 -1
  48. package/dist/packages/llm/src/index.js +7 -2
  49. package/dist/packages/plugins/context/src/index.d.ts +2 -1
  50. package/dist/packages/plugins/context/src/index.js +2 -1
  51. package/dist/packages/plugins/limits/src/index.d.ts +4 -1
  52. package/dist/packages/plugins/redaction/src/index.d.ts +3 -0
  53. package/dist/packages/plugins/redaction/src/index.js +3 -0
  54. package/dist/packages/plugins/redaction/src/rules.gen.js +1 -1
  55. package/dist/packages/plugins/review/src/chain.d.ts +2 -0
  56. package/dist/packages/plugins/review/src/chain.js +2 -1
  57. package/dist/packages/plugins/review/src/hard-rules.d.ts +6 -0
  58. package/dist/packages/plugins/review/src/hard-rules.js +58 -25
  59. package/dist/packages/plugins/review/src/index.d.ts +13 -0
  60. package/dist/packages/plugins/review/src/index.js +9 -5
  61. package/dist/packages/plugins/tools/src/builtin/bash.js +24 -10
  62. package/dist/packages/plugins/toolset/src/codemode.d.ts +17 -6
  63. package/dist/packages/plugins/toolset/src/codemode.js +84 -26
  64. package/dist/packages/plugins/toolset/src/index.d.ts +1 -1
  65. package/dist/packages/plugins/toolset/src/index.js +1 -1
  66. package/dist/packages/plugins/trace/src/index.d.ts +27 -0
  67. package/dist/packages/plugins/trace/src/index.js +29 -0
  68. package/dist/packages/plugins/trace/src/modules.d.ts +6 -0
  69. package/dist/packages/plugins/trace/src/modules.js +28 -0
  70. package/dist/packages/session/src/file-log.d.ts +6 -1
  71. package/dist/packages/session/src/file-log.js +45 -25
  72. package/dist/packages/session/src/fork.d.ts +16 -0
  73. package/dist/packages/session/src/fork.js +70 -0
  74. package/dist/packages/session/src/index.d.ts +2 -0
  75. package/dist/packages/session/src/index.js +1 -0
  76. package/dist/packages/session/src/log-core.d.ts +37 -0
  77. package/dist/packages/session/src/log-core.js +47 -0
  78. package/dist/packages/session/src/memory-log.d.ts +3 -12
  79. package/dist/packages/session/src/memory-log.js +3 -23
  80. package/dist/packages/session/src/projection.js +2 -0
  81. package/dist/packages/testing/src/faux.d.ts +36 -0
  82. package/dist/packages/testing/src/faux.js +69 -0
  83. package/dist/packages/testing/src/index.d.ts +4 -0
  84. package/dist/packages/testing/src/index.js +2 -0
  85. package/dist/packages/testing/src/rng.d.ts +7 -0
  86. package/dist/packages/testing/src/rng.js +17 -0
  87. package/dist/sdk/index.d.ts +11 -3
  88. package/dist/sdk/index.js +5 -1
  89. package/dist/sdk/testing.d.ts +2 -0
  90. package/dist/sdk/testing.js +2 -0
  91. package/package.json +5 -1
@@ -1,5 +1,7 @@
1
1
  import { getQuickJS } from "quickjs-emscripten";
2
- const DESCRIPTION = "Runs a short JavaScript program in a sandbox (no files, no network, no timers) and returns what it prints and returns. " +
2
+ /** The name of the tool that runs a script. Whoever needs to refer to it takes it from here. */
3
+ export const CODEMODE_TOOL = "run_code";
4
+ const DESCRIPTION = "Runs a short JavaScript program in an isolated runtime (no files, no network, no timers) and returns what it prints and returns. " +
3
5
  "Inside the program, `tools.<name>(args)` calls a tool and returns a promise of its text; use `await`. `console.log` output is returned. " +
4
6
  "Use it to combine several tool calls or to process their results without sending everything through the conversation. Available tools: ";
5
7
  const PRELUDE = `
@@ -10,7 +12,7 @@ globalThis.tools = new Proxy({}, { get: (_t, name) => async (args) => {
10
12
  } });
11
13
  globalThis.console = { log: (...parts) => __print(parts.map((p) => (typeof p === "string" ? p : JSON.stringify(p))).join(" ")) };
12
14
  `;
13
- /** Deeper than this, the host's own stack overflows before the sandbox notices, which aborts the whole WebAssembly module; 256 KB still allows recursion about 1500 deep. */
15
+ /** Deeper than this, the host's own stack overflows before the runtime notices, which aborts the whole WebAssembly module; 256 KB still allows recursion about 1500 deep. */
14
16
  const STACK_BYTES = 256 * 1024;
15
17
  const text = (value, isError = false) => ({ content: [{ type: "text", text: value }], ...(isError ? { isError } : {}) });
16
18
  const textOfContent = (content) => content.map((part) => (part.type === "text" ? part.text : `[${part.type}]`)).join("\n");
@@ -23,11 +25,14 @@ function cut(value, limit) {
23
25
  /**
24
26
  * Lets the model write one JavaScript program that calls tools, instead of one call per request. The program runs in QuickJS
25
27
  * (WebAssembly), which has no access to the machine: only the tools in the catalog, and only those whose effect is allowed.
26
- * Calls made from the script do not pass through the hooks of the loop. Two things stand in for them: only read-only tools are allowed by default,
27
- * and `authorize` (for example the review plugin's) is asked before each call.
28
+ * Calls made from the script do not pass through the hooks of the loop, so `authorize` (for example the review plugin's) is asked
29
+ * before each call. With no `authorize`, every call runs, as a direct call would with no review installed.
30
+ *
31
+ * The runtime only keeps the script itself away from the machine. The tools it calls (a shell command, a file write, an MCP tool) run outside the
32
+ * runtime with their full power, so a call from a script is exactly as risky as the same call made directly, and needs the same review.
28
33
  */
29
34
  export function codemodePlugin(options) {
30
- const allowed = new Set(options.allowEffects ?? ["read"]);
35
+ const allowed = new Set(options.allowEffects ?? ["read", "write", "exec", "network", "irreversible"]);
31
36
  const byName = new Map(options.catalog.map((tool) => [tool.name, tool]));
32
37
  const timeoutMs = options.timeoutMs ?? 10_000;
33
38
  const memoryBytes = options.memoryBytes ?? 64 * 1024 * 1024;
@@ -42,7 +47,7 @@ export function codemodePlugin(options) {
42
47
  try {
43
48
  const args = JSON.parse(rawArgs);
44
49
  if (options.authorize) {
45
- const decision = await options.authorize({ id: `${context.callId}/${++calls}`, name, args }, { log: context.log, signal: context.signal, tool: (wanted) => byName.get(wanted) });
50
+ const decision = await options.authorize({ id: `${context.callId}/${++calls}`, name, args }, { log: context.log, signal: context.signal, tool: (wanted) => byName.get(wanted), ...(context.env?.cwd !== undefined ? { cwd: context.env.cwd } : {}) });
46
51
  if (decision.kind === "deny")
47
52
  return JSON.stringify({ error: decision.reason });
48
53
  }
@@ -55,7 +60,7 @@ export function codemodePlugin(options) {
55
60
  }
56
61
  }
57
62
  const runCode = {
58
- name: "run_code",
63
+ name: CODEMODE_TOOL,
59
64
  description: DESCRIPTION + options.catalog.map((tool) => tool.name).join(", "),
60
65
  parameters: { type: "object", properties: { code: { type: "string", description: "The body of an async function; use `return` for the result" } }, required: ["code"] },
61
66
  effect: "exec",
@@ -68,13 +73,45 @@ export function codemodePlugin(options) {
68
73
  const runtime = QuickJS.newRuntime();
69
74
  runtime.setMemoryLimit(memoryBytes);
70
75
  runtime.setMaxStackSize(STACK_BYTES);
71
- const deadline = Date.now() + timeoutMs;
72
- runtime.setInterruptHandler(() => Date.now() > deadline || context.signal.aborted);
76
+ // The script's own time is the time spent inside the runtime plus the time spent waiting while no tool call is under way.
77
+ // Waiting for a tool call is not counted, but a loop that runs while a call is under way is (it is counted as it runs).
78
+ const startedAt = Date.now();
79
+ let insideMs = 0;
80
+ let idleMs = 0;
81
+ let sliceStart = 0;
82
+ const used = () => insideMs + idleMs + (sliceStart > 0 ? Date.now() - sliceStart : 0);
83
+ const inRuntime = (work) => {
84
+ if (sliceStart > 0)
85
+ return work();
86
+ sliceStart = Date.now();
87
+ try {
88
+ return work();
89
+ }
90
+ finally {
91
+ insideMs += Date.now() - sliceStart;
92
+ sliceStart = 0;
93
+ }
94
+ };
95
+ const overTotal = () => options.totalTimeoutMs !== undefined && Date.now() - startedAt > options.totalTimeoutMs;
96
+ runtime.setInterruptHandler(() => used() > timeoutMs || overTotal() || context.signal.aborted);
73
97
  const vm = runtime.newContext();
74
98
  const printed = [];
75
- const pending = [];
99
+ const unsettled = new Set(); // the calls that have not come back yet; the wait below must not include the ones that have, or it would not wait
76
100
  const handles = [];
77
101
  const deferreds = [];
102
+ // The calls that have not returned. Each has its own signal, joined to the run's: cancelling the run stops them all, and when the script
103
+ // ends the ones still under way are stopped. A call that has returned (a background command, say) is left to the run.
104
+ const underway = new Set();
105
+ const failure = (message) => text(printed.length > 0 ? cut(`${printed.join("\n")}\n${message}`, maxOutput) : message, true);
106
+ const late = () => {
107
+ if (context.signal.aborted)
108
+ return "The script was cancelled."; // the calls it was waiting for were stopped with the run, so the script may report their errors instead
109
+ if (used() > timeoutMs)
110
+ return `The script took longer than ${seconds(timeoutMs)} seconds and was stopped.`;
111
+ if (overTotal())
112
+ return `The script took longer than ${seconds(options.totalTimeoutMs ?? 0)} seconds in total and was stopped.`;
113
+ return undefined;
114
+ };
78
115
  try {
79
116
  const print = vm.newFunction("__print", (line) => {
80
117
  printed.push(vm.getString(line));
@@ -86,54 +123,71 @@ export function codemodePlugin(options) {
86
123
  deferreds.push(promise);
87
124
  const name = vm.getString(nameHandle);
88
125
  const rawArgs = vm.getString(argsHandle);
89
- const work = callTool(name, rawArgs, context).then((reply) => {
126
+ const controller = new AbortController();
127
+ const stop = () => controller.abort();
128
+ if (context.signal.aborted)
129
+ stop();
130
+ else
131
+ context.signal.addEventListener("abort", stop, { once: true }); // stays joined after the call returns, so a background command it started is still stopped with the run
132
+ underway.add(controller);
133
+ const work = callTool(name, rawArgs, { ...context, signal: controller.signal })
134
+ .then((reply) => {
90
135
  if (!vm.alive)
91
136
  return;
92
137
  const value = vm.newString(reply);
93
138
  promise.resolve(value);
94
139
  value.dispose();
140
+ })
141
+ .catch(() => undefined)
142
+ .finally(() => {
143
+ underway.delete(controller);
144
+ unsettled.delete(work);
95
145
  });
96
- pending.push(work);
146
+ unsettled.add(work);
97
147
  void promise.settled
98
148
  .then(() => {
99
149
  if (runtime.alive)
100
- runtime.executePendingJobs(); // the script may be over and the sandbox gone by now
150
+ inRuntime(() => runtime.executePendingJobs()); // the script may be over and the runtime gone by now
101
151
  })
102
152
  .catch(() => undefined);
103
153
  return promise.handle;
104
154
  });
105
155
  vm.setProp(vm.global, "__callTool", call);
106
156
  call.dispose();
107
- const prelude = vm.evalCode(PRELUDE);
157
+ const prelude = inRuntime(() => vm.evalCode(PRELUDE));
108
158
  if (prelude.error) {
109
159
  prelude.error.dispose();
110
- return text("The sandbox could not start.", true);
160
+ return text("The script runtime could not start.", true);
111
161
  }
112
162
  prelude.value.dispose();
113
- const evaluated = vm.evalCode(`(async () => {\n${code}\n})()`);
163
+ const evaluated = inRuntime(() => vm.evalCode(`(async () => {\n${code}\n})()`));
114
164
  if (evaluated.error) {
115
165
  const message = vm.dump(evaluated.error);
116
166
  evaluated.error.dispose();
117
- return text(`Script error: ${formatError(message)}`, true);
167
+ return failure(late() ?? `Script error: ${formatError(message)}`);
118
168
  }
119
169
  handles.push(evaluated.value);
120
- runtime.executePendingJobs();
170
+ inRuntime(() => runtime.executePendingJobs());
121
171
  // the script is done when its promise settles; tool calls it started finish in between
122
172
  let state = vm.getPromiseState(evaluated.value);
173
+ let tick = Date.now();
123
174
  while (state.type === "pending") {
124
- if (Date.now() > deadline)
125
- return text(`The script took longer than ${Math.round(timeoutMs / 1000)} seconds and was stopped.`, true);
126
- if (context.signal.aborted)
127
- return text("The script was cancelled.", true);
128
- await Promise.race([Promise.allSettled(pending), new Promise((resolve) => setTimeout(resolve, 20))]);
129
- runtime.executePendingJobs();
175
+ const now = Date.now();
176
+ if (underway.size === 0)
177
+ idleMs += now - tick;
178
+ tick = now;
179
+ const over = late();
180
+ if (over)
181
+ return failure(over);
182
+ await Promise.race([...unsettled, new Promise((resolve) => setTimeout(resolve, 20))]);
183
+ inRuntime(() => runtime.executePendingJobs());
130
184
  state = vm.getPromiseState(evaluated.value);
131
185
  }
132
186
  const output = printed.length > 0 ? `${printed.join("\n")}\n` : "";
133
187
  if (state.type === "rejected") {
134
188
  const message = vm.dump(state.error);
135
189
  state.error.dispose();
136
- return text(cut(`${output}Script error: ${formatError(message)}`, maxOutput), true);
190
+ return failure(late() ?? `Script error: ${formatError(message)}`);
137
191
  }
138
192
  const value = vm.dump(state.value);
139
193
  state.value.dispose();
@@ -142,9 +196,11 @@ export function codemodePlugin(options) {
142
196
  return text(cut(combined === "" ? "(no output)" : combined, maxOutput));
143
197
  }
144
198
  catch (error) {
145
- return text(`Script failed: ${error instanceof Error ? error.message : String(error)}`, true);
199
+ return failure(`Script failed: ${error instanceof Error ? error.message : String(error)}`);
146
200
  }
147
201
  finally {
202
+ for (const controller of underway)
203
+ controller.abort(); // calls still under way when the script ends are stopped
148
204
  for (const handle of handles)
149
205
  if (handle.alive)
150
206
  handle.dispose();
@@ -158,6 +214,8 @@ export function codemodePlugin(options) {
158
214
  };
159
215
  return { name: "codemode", compensates: "one tool call per model request is slow and fills the context with intermediate results", setup: (api) => api.registerTool(runCode) };
160
216
  }
217
+ /** Seconds for an error message: a whole number when it is one, otherwise one decimal (0.3 and not 0). */
218
+ const seconds = (ms) => Number((ms / 1000).toFixed(1));
161
219
  function formatError(dumped) {
162
220
  if (typeof dumped === "object" && dumped !== null) {
163
221
  const { name, message } = dumped;
@@ -2,5 +2,5 @@ export { searchPlugin } from "./search.js";
2
2
  export type { SearchOptions } from "./search.js";
3
3
  export { connectMcp } from "./mcp.js";
4
4
  export type { McpConnection, McpServerConfig, McpServerStatus } from "./mcp.js";
5
- export { codemodePlugin } from "./codemode.js";
5
+ export { CODEMODE_TOOL, codemodePlugin } from "./codemode.js";
6
6
  export type { Authorize, CodemodeOptions } from "./codemode.js";
@@ -1,3 +1,3 @@
1
1
  export { searchPlugin } from "./search.js";
2
2
  export { connectMcp } from "./mcp.js";
3
- export { codemodePlugin } from "./codemode.js";
3
+ export { CODEMODE_TOOL, codemodePlugin } from "./codemode.js";
@@ -0,0 +1,27 @@
1
+ import type { Plugin } from "../../../kernel/src/index.js";
2
+ /** One step of a run, tagged with the module that did the work. Written one per line to a trace file. */
3
+ export interface TraceRecord {
4
+ /** Milliseconds since the trace began. */
5
+ t: number;
6
+ /** Module id, matching the node ids of the architecture diagram. */
7
+ module: string;
8
+ /** What happened (the event type). */
9
+ fn: string;
10
+ /** A short detail, never message text. */
11
+ detail?: string;
12
+ }
13
+ export interface TraceOptions {
14
+ /** Append records to this file as JSON lines. */
15
+ file?: string;
16
+ /** Receive each record. */
17
+ sink?(record: TraceRecord): void;
18
+ now?: () => number;
19
+ }
20
+ export interface TracePlugin extends Plugin {
21
+ records: TraceRecord[];
22
+ }
23
+ /**
24
+ * Records what runs, for the module map. It only watches events, never changes anything the model sees,
25
+ * and keeps no message text. A development aid, off unless added.
26
+ */
27
+ export declare function tracePlugin(options?: TraceOptions): TracePlugin;
@@ -0,0 +1,29 @@
1
+ import { appendFileSync, mkdirSync } from "node:fs";
2
+ import { dirname } from "node:path";
3
+ import { moduleOf } from "./modules.js";
4
+ /**
5
+ * Records what runs, for the module map. It only watches events, never changes anything the model sees,
6
+ * and keeps no message text. A development aid, off unless added.
7
+ */
8
+ export function tracePlugin(options = {}) {
9
+ const now = options.now ?? Date.now;
10
+ const started = now();
11
+ const records = [];
12
+ if (options.file)
13
+ mkdirSync(dirname(options.file), { recursive: true });
14
+ return {
15
+ name: "trace",
16
+ compensates: "nothing: it only watches",
17
+ records,
18
+ setup(api) {
19
+ api.on("event", (event) => {
20
+ const { module, detail } = moduleOf(event);
21
+ const record = { t: now() - started, module, fn: event.type, ...(detail !== undefined ? { detail } : {}) };
22
+ records.push(record);
23
+ options.sink?.(record);
24
+ if (options.file)
25
+ appendFileSync(options.file, `${JSON.stringify(record)}\n`);
26
+ });
27
+ },
28
+ };
29
+ }
@@ -0,0 +1,6 @@
1
+ import type { Event } from "../../../kernel/src/index.js";
2
+ /** Which module does the work behind each kind of event. */
3
+ export declare function moduleOf(event: Event): {
4
+ module: string;
5
+ detail?: string;
6
+ };
@@ -0,0 +1,28 @@
1
+ /** Which module does the work behind each kind of event. */
2
+ export function moduleOf(event) {
3
+ switch (event.type) {
4
+ case "run_start":
5
+ case "run_end":
6
+ case "turn_start":
7
+ case "turn_end":
8
+ case "paused":
9
+ return { module: "kernel", ...(event.type === "run_end" ? { detail: event.reason } : event.type === "paused" ? { detail: event.reason } : {}) };
10
+ case "message_start":
11
+ case "message_update":
12
+ case "reasoning_update":
13
+ case "message_reset":
14
+ case "message_end":
15
+ return { module: "llm" };
16
+ case "tool_start":
17
+ case "tool_end":
18
+ return { module: "tools", detail: event.call.name };
19
+ case "compaction":
20
+ return { module: "compaction" };
21
+ case "goal":
22
+ return { module: "goal", detail: event.status };
23
+ case "signal":
24
+ return { module: "kernel", detail: "signal" };
25
+ case "plugin":
26
+ return { module: `plugin-${event.name}` };
27
+ }
28
+ }
@@ -1,10 +1,15 @@
1
- import type { LogOptions, RewindableLog } from "./memory-log.js";
1
+ import type { LogOptions, RewindableLog } from "./log-core.js";
2
2
  export interface SessionHeader {
3
3
  type: "header";
4
4
  version: number;
5
5
  id: string;
6
6
  cwd: string;
7
7
  ts: number;
8
+ /** Set on a session that was made by forking another: the entry it was copied up to, and the session it came from when that is known. */
9
+ forkedFrom?: {
10
+ entry: string;
11
+ session?: string;
12
+ };
8
13
  }
9
14
  export interface FileLog extends RewindableLog {
10
15
  header: SessionHeader;
@@ -1,7 +1,7 @@
1
1
  import { randomUUID } from "node:crypto";
2
2
  import { appendFile, mkdir, open, readFile, rm, truncate, writeFile } from "node:fs/promises";
3
3
  import { dirname } from "node:path";
4
- import { projectMessages } from "./projection.js";
4
+ import { createLog } from "./log-core.js";
5
5
  import { createTree } from "./tree.js";
6
6
  const FORMAT_VERSION = 1;
7
7
  function isProcessAlive(pid) {
@@ -34,6 +34,7 @@ function parseFile(text) {
34
34
  const complete = lines.slice(0, -1); // the last piece has no trailing newline: it is empty or a torn write
35
35
  let header;
36
36
  const entries = [];
37
+ let rewoundTo;
37
38
  let validBytes = 0;
38
39
  for (const [index, line] of complete.entries()) {
39
40
  let value;
@@ -50,14 +51,18 @@ function parseFile(text) {
50
51
  if (header.version > FORMAT_VERSION)
51
52
  throw new Error(`session file version ${header.version} is newer than supported`);
52
53
  }
54
+ else if (value.type === "rewind") {
55
+ rewoundTo = value.to;
56
+ }
53
57
  else {
54
58
  entries.push(value);
59
+ rewoundTo = undefined;
55
60
  }
56
61
  validBytes += Buffer.byteLength(line) + 1;
57
62
  }
58
63
  if (!header)
59
64
  throw new Error("corrupt session file: missing header");
60
- return { header, entries, validBytes };
65
+ return { header, entries, validBytes, ...(rewoundTo !== undefined ? { rewoundTo } : {}) };
61
66
  }
62
67
  /**
63
68
  * One JSONL file per session: a header line, then one complete entry per line.
@@ -71,6 +76,8 @@ export async function openFileLog(path, options = {}) {
71
76
  await acquireLock(lockPath);
72
77
  let header;
73
78
  let entries;
79
+ let rewoundTo;
80
+ let size; // bytes of the file that are known to be good
74
81
  try {
75
82
  const existing = await readFile(path, "utf8").catch((error) => {
76
83
  if (error.code === "ENOENT")
@@ -79,15 +86,19 @@ export async function openFileLog(path, options = {}) {
79
86
  });
80
87
  if (existing === undefined || existing === "") {
81
88
  header = { type: "header", version: FORMAT_VERSION, id: newId(), cwd: options.cwd ?? process.cwd(), ts: now() };
82
- await writeFile(path, `${JSON.stringify(header)}\n`);
89
+ const line = `${JSON.stringify(header)}\n`;
90
+ await writeFile(path, line);
83
91
  entries = [];
92
+ size = Buffer.byteLength(line);
84
93
  }
85
94
  else {
86
95
  const parsed = parseFile(existing);
87
96
  header = parsed.header;
88
97
  entries = parsed.entries;
89
- if (parsed.validBytes < Buffer.byteLength(existing))
90
- await truncate(path, parsed.validBytes);
98
+ rewoundTo = parsed.rewoundTo;
99
+ size = parsed.validBytes;
100
+ if (size < Buffer.byteLength(existing))
101
+ await truncate(path, size);
91
102
  }
92
103
  }
93
104
  catch (error) {
@@ -95,29 +106,38 @@ export async function openFileLog(path, options = {}) {
95
106
  throw error;
96
107
  }
97
108
  const tree = createTree(entries);
98
- let writing = Promise.resolve();
109
+ if (rewoundTo !== undefined && (rewoundTo === null || entries.some((entry) => entry.id === rewoundTo)))
110
+ tree.moveLeaf(rewoundTo);
111
+ // A write that fails can leave half a line behind. The next write first cuts the file back to what is known to be good, so that half line
112
+ // never ends up in the middle of the file where it would make the whole session unreadable.
113
+ let damaged = false;
114
+ async function write(line) {
115
+ try {
116
+ if (damaged) {
117
+ await truncate(path, size);
118
+ damaged = false;
119
+ }
120
+ await appendFile(path, line);
121
+ size += Buffer.byteLength(line);
122
+ }
123
+ catch (error) {
124
+ damaged = true;
125
+ throw error;
126
+ }
127
+ }
128
+ const log = createLog(tree, {
129
+ saveEntry: (entry) => write(`${JSON.stringify(entry)}\n`),
130
+ saveRewind: (to) => write(`${JSON.stringify({ type: "rewind", to })}\n`),
131
+ }, options);
99
132
  return {
100
133
  header,
101
- async append(body) {
102
- const entry = { ...body, id: newId(), parentId: tree.leaf(), ts: now() };
103
- tree.add(entry);
104
- writing = writing.then(() => appendFile(path, `${JSON.stringify(entry)}\n`));
105
- await writing;
106
- return entry;
107
- },
108
- async branch() {
109
- return tree.branch();
110
- },
111
- async project(upTo) {
112
- const branch = tree.branch();
113
- return projectMessages(upTo === undefined
114
- ? branch
115
- : branch.slice(0, Math.max(0, branch.findIndex((entry) => entry.id === upTo))));
116
- },
117
- rewindTo: (entryId) => tree.moveLeaf(entryId),
118
- leaf: () => tree.leaf(),
134
+ append: log.append,
135
+ branch: log.branch,
136
+ project: log.project,
137
+ rewindTo: log.rewindTo,
138
+ leaf: log.leaf,
119
139
  async close() {
120
- await writing;
140
+ await log.settled();
121
141
  await rm(lockPath, { force: true });
122
142
  },
123
143
  };
@@ -0,0 +1,16 @@
1
+ import type { Log } from "../../kernel/src/index.js";
2
+ import type { SessionHeader } from "./file-log.js";
3
+ export interface ForkOptions {
4
+ /** The last entry to copy, an id from `log.branch()`. Left out, the whole current branch is copied. */
5
+ upTo?: string;
6
+ /** The working directory to record in the new session. Default: this process's. */
7
+ cwd?: string;
8
+ /** The id of the session the log comes from, if it has one; recorded so that the new session says where it came from. */
9
+ sessionId?: string;
10
+ }
11
+ /**
12
+ * Copies the conversation up to a point into a new session file, which is then a session of its own: its entries are the same entries (same ids, same
13
+ * parents), so what the model is sent from it is exactly what it was sent from the original at that point, and a provider's prompt cache still applies.
14
+ * The source is only read. The file appears whole or not at all, and an existing file is never replaced.
15
+ */
16
+ export declare function forkLog(log: Log, path: string, options?: ForkOptions): Promise<SessionHeader>;
@@ -0,0 +1,70 @@
1
+ import { randomUUID } from "node:crypto";
2
+ import { link, mkdir, unlink, writeFile } from "node:fs/promises";
3
+ import { dirname } from "node:path";
4
+ /** Where to stop copying so that no turn is cut in two: after the results that answer the calls of the last assistant message that was copied. */
5
+ function endOfTurn(branch, last) {
6
+ let assistant = -1;
7
+ for (let index = last; index >= 0; index--) {
8
+ const entry = branch[index];
9
+ if (entry?.type === "message" && entry.message.role === "assistant") {
10
+ assistant = index;
11
+ break;
12
+ }
13
+ }
14
+ const opened = assistant >= 0 ? branch[assistant] : undefined;
15
+ if (opened?.type !== "message")
16
+ return last;
17
+ const open = new Set(opened.message.content.flatMap((part) => (part.type === "tool_call" ? [part.id] : [])));
18
+ for (let index = assistant + 1; index <= last; index++) {
19
+ const entry = branch[index];
20
+ if (entry?.type === "message" && entry.message.toolCallId)
21
+ open.delete(entry.message.toolCallId);
22
+ }
23
+ let end = last;
24
+ while (open.size > 0 && end + 1 < branch.length) {
25
+ const next = branch[end + 1];
26
+ if (next.type === "message" && next.message.role !== "tool")
27
+ break; // a new turn begins: calls that were never answered stay as they were in the source
28
+ end++;
29
+ if (next.type === "message" && next.message.toolCallId)
30
+ open.delete(next.message.toolCallId);
31
+ }
32
+ return end;
33
+ }
34
+ /**
35
+ * Copies the conversation up to a point into a new session file, which is then a session of its own: its entries are the same entries (same ids, same
36
+ * parents), so what the model is sent from it is exactly what it was sent from the original at that point, and a provider's prompt cache still applies.
37
+ * The source is only read. The file appears whole or not at all, and an existing file is never replaced.
38
+ */
39
+ export async function forkLog(log, path, options = {}) {
40
+ const branch = await log.branch();
41
+ let last = branch.length - 1;
42
+ if (options.upTo !== undefined) {
43
+ last = branch.findIndex((entry) => entry.id === options.upTo);
44
+ if (last === -1)
45
+ throw new Error(`entry ${options.upTo} is not on the current branch`);
46
+ }
47
+ const copied = branch.slice(0, endOfTurn(branch, last) + 1);
48
+ const header = {
49
+ type: "header",
50
+ version: 1,
51
+ id: randomUUID().slice(0, 8),
52
+ cwd: options.cwd ?? process.cwd(),
53
+ ts: Date.now(),
54
+ ...(copied.length > 0 ? { forkedFrom: { entry: copied.at(-1).id, ...(options.sessionId !== undefined ? { session: options.sessionId } : {}) } } : {}),
55
+ };
56
+ const text = [header, ...copied].map((line) => `${JSON.stringify(line)}\n`).join("");
57
+ await mkdir(dirname(path), { recursive: true });
58
+ const temporary = `${path}.${randomUUID().slice(0, 8)}.tmp`;
59
+ try {
60
+ await writeFile(temporary, text, { flag: "wx" });
61
+ // a hard link fails if the target exists, so a session that is there is never replaced, and the new one never shows up half written
62
+ await link(temporary, path).catch((error) => {
63
+ throw error.code === "EEXIST" ? new Error(`${path} already exists`) : error;
64
+ });
65
+ }
66
+ finally {
67
+ await unlink(temporary).catch(() => undefined);
68
+ }
69
+ return header;
70
+ }
@@ -1,4 +1,6 @@
1
1
  export { createMemoryLog } from "./memory-log.js";
2
2
  export type { LogOptions, RewindableLog } from "./memory-log.js";
3
3
  export { openFileLog } from "./file-log.js";
4
+ export { forkLog } from "./fork.js";
5
+ export type { ForkOptions } from "./fork.js";
4
6
  export type { FileLog, SessionHeader } from "./file-log.js";
@@ -1,2 +1,3 @@
1
1
  export { createMemoryLog } from "./memory-log.js";
2
2
  export { openFileLog } from "./file-log.js";
3
+ export { forkLog } from "./fork.js";
@@ -0,0 +1,37 @@
1
+ import type { EntryBody, Log, LogEntry } from "../../kernel/src/index.js";
2
+ import type { createTree } from "./tree.js";
3
+ export interface LogOptions {
4
+ now?: () => number;
5
+ newId?: () => string;
6
+ }
7
+ /** A session log that can go back to an earlier entry and carry on from there. */
8
+ export interface RewindableLog extends Log {
9
+ /** Moves the end of the conversation back to an earlier entry (or to the very start with null). Later entries start a new branch. */
10
+ rewindTo(entryId: string | null): void;
11
+ /** Id of the entry the next one will follow. */
12
+ leaf(): string | null;
13
+ }
14
+ /** Where a log keeps its entries. A memory log keeps them nowhere else. */
15
+ export interface Storage {
16
+ /** Makes the entry durable. If this fails the entry is not part of the log. */
17
+ saveEntry(entry: LogEntry): Promise<void>;
18
+ /** Records that the end of the conversation moved. Best effort: the next entry carries the same information, so a lost record only matters if nothing follows. */
19
+ saveRewind(to: string | null): Promise<void>;
20
+ }
21
+ /**
22
+ * The one implementation of "a log": entries in a tree, one leaf, appends in order. The log is only as durable as its storage, and a log and its
23
+ * storage never disagree: an entry joins the tree after it is saved, and an append that fails changes nothing and does not stop the ones after it.
24
+ */
25
+ export declare function createLog(tree: ReturnType<typeof createTree>, storage: Storage, options?: LogOptions): {
26
+ /** Resolves when everything started so far has been saved or has failed. */
27
+ settled: () => Promise<undefined>;
28
+ append(body: EntryBody): Promise<LogEntry>;
29
+ branch(): Promise<LogEntry[]>;
30
+ project(upTo?: string): Promise<import("../../kernel/src/index.js").Message[]>;
31
+ /**
32
+ * Takes effect at once. Entries that were already waiting to be saved follow the new end, so do not rewind while appends are in flight
33
+ * (the kernel does not: one run at a time, and it waits for each append).
34
+ */
35
+ rewindTo(entryId: string | null): void;
36
+ leaf: () => string | null;
37
+ };