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.
- package/README.md +144 -78
- package/README.zh-CN.md +145 -79
- package/THIRD_PARTY_NOTICES.md +8 -58
- package/dist/packages/agent/src/index.d.ts +49 -11
- package/dist/packages/agent/src/index.js +50 -55
- package/dist/packages/agent/src/restrict-tools.d.ts +6 -0
- package/dist/packages/agent/src/restrict-tools.js +11 -0
- package/dist/packages/agent/src/system-prompt.d.ts +5 -0
- package/dist/packages/agent/src/system-prompt.js +1 -1
- package/dist/packages/agent/src/wake.d.ts +41 -0
- package/dist/packages/agent/src/wake.js +71 -0
- package/dist/packages/env/src/confine.d.ts +13 -0
- package/dist/packages/env/src/confine.js +40 -0
- package/dist/packages/env/src/connect-ssh.d.ts +33 -0
- package/dist/packages/env/src/connect-ssh.js +132 -0
- package/dist/packages/env/src/index.d.ts +5 -1
- package/dist/packages/env/src/index.js +3 -1
- package/dist/packages/env/src/local.d.ts +11 -2
- package/dist/packages/env/src/local.js +42 -10
- package/dist/packages/env/src/remote.d.ts +17 -1
- package/dist/packages/env/src/remote.js +54 -4
- package/dist/packages/evals/src/bfcl.d.ts +24 -0
- package/dist/packages/evals/src/bfcl.js +71 -0
- package/dist/packages/evals/src/index.d.ts +3 -0
- package/dist/packages/evals/src/index.js +2 -0
- package/dist/packages/evals/src/png.d.ts +2 -0
- package/dist/packages/evals/src/png.js +30 -0
- package/dist/packages/evals/src/pool.d.ts +3 -0
- package/dist/packages/evals/src/pool.js +22 -0
- package/dist/packages/evals/src/providers.d.ts +13 -0
- package/dist/packages/evals/src/providers.js +23 -0
- package/dist/packages/kernel/src/compaction.d.ts +19 -0
- package/dist/packages/kernel/src/compaction.js +12 -2
- package/dist/packages/kernel/src/goal.d.ts +10 -1
- package/dist/packages/kernel/src/goal.js +28 -0
- package/dist/packages/kernel/src/hooks.d.ts +5 -0
- package/dist/packages/kernel/src/hooks.js +21 -1
- package/dist/packages/kernel/src/index.d.ts +3 -2
- package/dist/packages/kernel/src/index.js +1 -0
- package/dist/packages/kernel/src/loop.d.ts +28 -4
- package/dist/packages/kernel/src/loop.js +69 -47
- package/dist/packages/kernel/src/types.d.ts +25 -4
- package/dist/packages/kernel/src/usage.d.ts +3 -0
- package/dist/packages/kernel/src/usage.js +16 -0
- package/dist/packages/llm/src/chat/request.js +22 -4
- package/dist/packages/llm/src/chat/retry.d.ts +1 -1
- package/dist/packages/llm/src/chat/retry.js +1 -1
- package/dist/packages/llm/src/index.js +7 -2
- package/dist/packages/plugins/context/src/index.d.ts +2 -1
- package/dist/packages/plugins/context/src/index.js +2 -1
- package/dist/packages/plugins/limits/src/index.d.ts +4 -1
- package/dist/packages/plugins/redaction/src/index.d.ts +3 -0
- package/dist/packages/plugins/redaction/src/index.js +3 -0
- package/dist/packages/plugins/redaction/src/rules.gen.js +1 -1
- package/dist/packages/plugins/review/src/chain.d.ts +2 -0
- package/dist/packages/plugins/review/src/chain.js +2 -1
- package/dist/packages/plugins/review/src/hard-rules.d.ts +6 -0
- package/dist/packages/plugins/review/src/hard-rules.js +58 -25
- package/dist/packages/plugins/review/src/index.d.ts +13 -0
- package/dist/packages/plugins/review/src/index.js +9 -5
- package/dist/packages/plugins/tools/src/builtin/bash.js +24 -10
- package/dist/packages/plugins/toolset/src/codemode.d.ts +17 -6
- package/dist/packages/plugins/toolset/src/codemode.js +84 -26
- package/dist/packages/plugins/toolset/src/index.d.ts +1 -1
- package/dist/packages/plugins/toolset/src/index.js +1 -1
- package/dist/packages/plugins/trace/src/index.d.ts +27 -0
- package/dist/packages/plugins/trace/src/index.js +29 -0
- package/dist/packages/plugins/trace/src/modules.d.ts +6 -0
- package/dist/packages/plugins/trace/src/modules.js +28 -0
- package/dist/packages/session/src/file-log.d.ts +6 -1
- package/dist/packages/session/src/file-log.js +45 -25
- package/dist/packages/session/src/fork.d.ts +16 -0
- package/dist/packages/session/src/fork.js +70 -0
- package/dist/packages/session/src/index.d.ts +2 -0
- package/dist/packages/session/src/index.js +1 -0
- package/dist/packages/session/src/log-core.d.ts +37 -0
- package/dist/packages/session/src/log-core.js +47 -0
- package/dist/packages/session/src/memory-log.d.ts +3 -12
- package/dist/packages/session/src/memory-log.js +3 -23
- package/dist/packages/session/src/projection.js +2 -0
- package/dist/packages/testing/src/faux.d.ts +36 -0
- package/dist/packages/testing/src/faux.js +69 -0
- package/dist/packages/testing/src/index.d.ts +4 -0
- package/dist/packages/testing/src/index.js +2 -0
- package/dist/packages/testing/src/rng.d.ts +7 -0
- package/dist/packages/testing/src/rng.js +17 -0
- package/dist/sdk/index.d.ts +11 -3
- package/dist/sdk/index.js +5 -1
- package/dist/sdk/testing.d.ts +2 -0
- package/dist/sdk/testing.js +2 -0
- package/package.json +5 -1
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
import { getQuickJS } from "quickjs-emscripten";
|
|
2
|
-
|
|
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
|
|
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
|
|
27
|
-
*
|
|
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:
|
|
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
|
-
|
|
72
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
|
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
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
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
|
|
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
|
|
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";
|
|
@@ -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,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 "./
|
|
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 {
|
|
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
|
-
|
|
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
|
-
|
|
90
|
-
|
|
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
|
-
|
|
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
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
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
|
|
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";
|
|
@@ -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
|
+
};
|