@4fu/arbor-tools 2026.1106.0 → 2026.1106.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 +8 -5
- package/catalog.d.ts +71 -11
- package/catalog.js +177 -31
- package/child-env.d.ts +24 -0
- package/child-env.js +32 -0
- package/code-exec.d.ts +23 -7
- package/code-exec.js +43 -15
- package/groups/core.d.ts +1 -1
- package/groups/core.js +21 -12
- package/groups/external.d.ts +25 -0
- package/groups/external.js +45 -0
- package/groups/files.d.ts +8 -0
- package/groups/files.js +45 -24
- package/groups/notes.d.ts +6 -0
- package/groups/notes.js +52 -25
- package/host.d.ts +0 -2
- package/index.d.ts +4 -1
- package/index.js +3 -0
- package/modules/modules.d.ts +8 -1
- package/modules/modules.js +69 -13
- package/package.json +8 -4
package/code-exec.js
CHANGED
|
@@ -2,10 +2,11 @@
|
|
|
2
2
|
* `code_exec`: everything that is not a decision runs from a script
|
|
3
3
|
* (docs/tools.md#rules).
|
|
4
4
|
*
|
|
5
|
-
* A script that names
|
|
6
|
-
* the host. Calls from a script go through the same hooks as
|
|
7
|
-
* anchors, watermarks, quotas, validation and logging behave
|
|
8
|
-
* module function is the same service call the direct tool
|
|
5
|
+
* A script that names one of arbor's own direct tools is refused with the rule
|
|
6
|
+
* and never reaches the host. Calls from a script go through the same hooks as
|
|
7
|
+
* direct calls, so anchors, watermarks, quotas, validation and logging behave
|
|
8
|
+
* the same: every module function is the same service call the direct tool
|
|
9
|
+
* makes, and so is every tool a plugin or a backend declared.
|
|
9
10
|
*
|
|
10
11
|
* Two lookups are script globals: `tool_search` takes keywords, an exact
|
|
11
12
|
* `module.function` name, or nothing for every module; `tool_describe` returns
|
|
@@ -14,7 +15,6 @@
|
|
|
14
15
|
*/
|
|
15
16
|
import vm from "node:vm";
|
|
16
17
|
import { textOf } from "@4fu/arbor-prompt";
|
|
17
|
-
import { boundResult } from "./bound.js";
|
|
18
18
|
/** How long one script may run before it is stopped. */
|
|
19
19
|
const SCRIPT_TIMEOUT_MS = 60000;
|
|
20
20
|
/** Run one script in its own context, with the modules as globals. */
|
|
@@ -41,6 +41,22 @@ export async function runScript(script, catalog, services, context) {
|
|
|
41
41
|
for (const module of catalog.modules()) {
|
|
42
42
|
globals[module.name] = moduleFunctions(module, catalog, context);
|
|
43
43
|
}
|
|
44
|
+
// A tool a plugin or a backend declared is a capability of its own, not one
|
|
45
|
+
// of arbor's decisions, so a script may call it - through the same path a
|
|
46
|
+
// module function takes (docs/tools.md#rules). A module's name and the
|
|
47
|
+
// script's own globals keep theirs: the catalog has already refused a tool
|
|
48
|
+
// whose name a module declares, and nothing here shadows `tool_search` or
|
|
49
|
+
// `console`.
|
|
50
|
+
for (const tool of catalog.scriptTools()) {
|
|
51
|
+
if (globals[tool.name] !== undefined)
|
|
52
|
+
continue;
|
|
53
|
+
globals[tool.name] = async (args = {}) => {
|
|
54
|
+
const result = await catalog.callModule(tool.name, args, context);
|
|
55
|
+
if (!result.ok)
|
|
56
|
+
throw new Error(result.error?.message ?? result.content);
|
|
57
|
+
return result.content;
|
|
58
|
+
};
|
|
59
|
+
}
|
|
44
60
|
const sandbox = vm.createContext(globals);
|
|
45
61
|
const wrapped = `(async () => {\n${script}\n})()`;
|
|
46
62
|
let value;
|
|
@@ -49,6 +65,21 @@ export async function runScript(script, catalog, services, context) {
|
|
|
49
65
|
value = await compiled.runInContext(sandbox, { timeout: SCRIPT_TIMEOUT_MS });
|
|
50
66
|
}
|
|
51
67
|
catch (error) {
|
|
68
|
+
// A script that outran the timeout gets its own rule: the message is the
|
|
69
|
+
// vm's, and the next action says what to do about a long one.
|
|
70
|
+
const timedOut = typeof error === "object" && error !== null && error.code === "ERR_SCRIPT_EXECUTION_TIMEOUT";
|
|
71
|
+
if (timedOut) {
|
|
72
|
+
return {
|
|
73
|
+
callId: "",
|
|
74
|
+
ok: false,
|
|
75
|
+
content: "",
|
|
76
|
+
error: {
|
|
77
|
+
rule: "script.timeout",
|
|
78
|
+
message: textOf(table, "refused.script_timeout", { ms: SCRIPT_TIMEOUT_MS }),
|
|
79
|
+
nextAction: textOf(table, "next.script_timeout"),
|
|
80
|
+
},
|
|
81
|
+
};
|
|
82
|
+
}
|
|
52
83
|
const message = error instanceof Error ? error.message : String(error);
|
|
53
84
|
return {
|
|
54
85
|
callId: "",
|
|
@@ -62,15 +93,9 @@ export async function runScript(script, catalog, services, context) {
|
|
|
62
93
|
};
|
|
63
94
|
}
|
|
64
95
|
const text = value === undefined ? "" : typeof value === "string" ? value : JSON.stringify(value);
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
content: await boundResult(text ?? "", {
|
|
69
|
-
workspace: context.workspace,
|
|
70
|
-
callId: context.call,
|
|
71
|
-
table,
|
|
72
|
-
}),
|
|
73
|
-
};
|
|
96
|
+
// The whole answer goes back to the script's caller; the cut happens once,
|
|
97
|
+
// at the one exit every direct call leaves by (docs/prompting.md).
|
|
98
|
+
return { callId: "", ok: true, content: text ?? "" };
|
|
74
99
|
}
|
|
75
100
|
/** One module's functions, as the globals a script calls. */
|
|
76
101
|
function moduleFunctions(module, catalog, context) {
|
|
@@ -87,7 +112,10 @@ function moduleFunctions(module, catalog, context) {
|
|
|
87
112
|
}
|
|
88
113
|
/**
|
|
89
114
|
* The direct tool a script names, if any. This is a guard, not a sandbox: it
|
|
90
|
-
* keeps
|
|
115
|
+
* keeps one of arbor's own decisions or commands out of a script, and the host
|
|
116
|
+
* still enforces every rule. A tool a plugin or a backend declared is not in
|
|
117
|
+
* the list - a script may call it, the way it calls a module function
|
|
118
|
+
* (docs/tools.md#rules).
|
|
91
119
|
*/
|
|
92
120
|
export function directToolIn(script, tools) {
|
|
93
121
|
for (const tool of tools) {
|
package/groups/core.d.ts
CHANGED
|
@@ -19,7 +19,7 @@ export declare class CoreToolGroup implements ToolGroup {
|
|
|
19
19
|
private send;
|
|
20
20
|
/**
|
|
21
21
|
* `end` with unfinished work needs a handoff: current topics, progress,
|
|
22
|
-
* pending items and agreements. The host knows
|
|
22
|
+
* pending items and agreements. The host knows what is open and names it; the
|
|
23
23
|
* tool does not guess.
|
|
24
24
|
*/
|
|
25
25
|
private end;
|
package/groups/core.js
CHANGED
|
@@ -68,27 +68,23 @@ export class CoreToolGroup {
|
|
|
68
68
|
}
|
|
69
69
|
/**
|
|
70
70
|
* `end` with unfinished work needs a handoff: current topics, progress,
|
|
71
|
-
* pending items and agreements. The host knows
|
|
71
|
+
* pending items and agreements. The host knows what is open and names it; the
|
|
72
72
|
* tool does not guess.
|
|
73
73
|
*/
|
|
74
74
|
async end(request, window) {
|
|
75
75
|
const table = this.services.table;
|
|
76
76
|
const handoff = handoffOf(request.args.handoff);
|
|
77
|
-
if (handoff === undefined && (await this.services.hasOpenWork(window))) {
|
|
78
|
-
return refused("end.handoff.required", table, "end.no_handoff");
|
|
79
|
-
}
|
|
80
77
|
const result = await this.services.endWindow(window, handoff);
|
|
81
|
-
|
|
82
|
-
...result,
|
|
83
|
-
|
|
84
|
-
content: textOf(table, "result.ended", { window }),
|
|
85
|
-
};
|
|
78
|
+
if (!result.ok)
|
|
79
|
+
return { ...result, callId: request.callId };
|
|
80
|
+
return { ...result, callId: request.callId, content: textOf(table, "result.ended", { window }) };
|
|
86
81
|
}
|
|
87
82
|
async search(request, window) {
|
|
88
83
|
const table = this.services.table;
|
|
89
84
|
const query = stringArg(request.args, "query");
|
|
90
85
|
const scope = stringArg(request.args, "scope");
|
|
91
|
-
const
|
|
86
|
+
const wanted = scope === "" ? undefined : scope.split(",").map((part) => part.trim());
|
|
87
|
+
const outcome = await this.services.search(window, query, wanted);
|
|
92
88
|
const lines = [];
|
|
93
89
|
for (const hit of outcome.hits) {
|
|
94
90
|
lines.push(`${hit.ref} ${hit.kind} ${hit.summary}`);
|
|
@@ -100,8 +96,21 @@ export class CoreToolGroup {
|
|
|
100
96
|
lines.push(`${live.window} ${live.doing}`);
|
|
101
97
|
}
|
|
102
98
|
const body = lines.length === 0 ? textOf(table, "result.search.empty") : lines.join("\n");
|
|
103
|
-
|
|
104
|
-
|
|
99
|
+
// What was searched, how much there is, and how to reach the rest: a limit
|
|
100
|
+
// that shapes a result without refusing is reported every time
|
|
101
|
+
// (docs/prompting.md#rules-surface-where-they-bite).
|
|
102
|
+
const more = outcome.total - lines.length;
|
|
103
|
+
return {
|
|
104
|
+
callId: request.callId,
|
|
105
|
+
ok: true,
|
|
106
|
+
content: [
|
|
107
|
+
body,
|
|
108
|
+
textOf(table, "result.search.total", { total: outcome.total }),
|
|
109
|
+
textOf(table, "result.search.scope", { scope: outcome.scope.join("、") }),
|
|
110
|
+
...(more > 0 ? [textOf(table, "result.search.more", { count: more })] : []),
|
|
111
|
+
outcome.nextCall,
|
|
112
|
+
].join("\n"),
|
|
113
|
+
};
|
|
105
114
|
}
|
|
106
115
|
notOwned(request) {
|
|
107
116
|
return {
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The group a plugin's or a backend's direct tools are registered as
|
|
3
|
+
* (docs/tools.md#external-tools).
|
|
4
|
+
*
|
|
5
|
+
* One group per source, holding every tool that source declared. Nothing here
|
|
6
|
+
* is special: the group declares its tools the way any group does and runs a
|
|
7
|
+
* call the way any group does, so the hooks, the journaling, the scrub and the
|
|
8
|
+
* one result cut around a call are the ones the host and the catalog apply to
|
|
9
|
+
* every direct tool.
|
|
10
|
+
*
|
|
11
|
+
* The `source` rides on each spec, so the tools list the model reads says where
|
|
12
|
+
* a tool comes from and `tool_search` answers with the same line.
|
|
13
|
+
*/
|
|
14
|
+
import type { CallContext, DirectToolSpec, ExternalTool, ToolCall, ToolGroup, ToolResult } from "@4fu/arbor-interfaces";
|
|
15
|
+
import { type ToolServices } from "../host.ts";
|
|
16
|
+
export declare class ExternalToolGroup implements ToolGroup {
|
|
17
|
+
readonly id: string;
|
|
18
|
+
private readonly declared;
|
|
19
|
+
private readonly source;
|
|
20
|
+
private readonly services;
|
|
21
|
+
constructor(declared: readonly ExternalTool[], source: string, services: Pick<ToolServices, "table">);
|
|
22
|
+
tools(): readonly DirectToolSpec[];
|
|
23
|
+
modules(): readonly [];
|
|
24
|
+
call(request: ToolCall, context: CallContext): Promise<ToolResult>;
|
|
25
|
+
}
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The group a plugin's or a backend's direct tools are registered as
|
|
3
|
+
* (docs/tools.md#external-tools).
|
|
4
|
+
*
|
|
5
|
+
* One group per source, holding every tool that source declared. Nothing here
|
|
6
|
+
* is special: the group declares its tools the way any group does and runs a
|
|
7
|
+
* call the way any group does, so the hooks, the journaling, the scrub and the
|
|
8
|
+
* one result cut around a call are the ones the host and the catalog apply to
|
|
9
|
+
* every direct tool.
|
|
10
|
+
*
|
|
11
|
+
* The `source` rides on each spec, so the tools list the model reads says where
|
|
12
|
+
* a tool comes from and `tool_search` answers with the same line.
|
|
13
|
+
*/
|
|
14
|
+
import { refused } from "../host.js";
|
|
15
|
+
export class ExternalToolGroup {
|
|
16
|
+
id;
|
|
17
|
+
declared;
|
|
18
|
+
source;
|
|
19
|
+
services;
|
|
20
|
+
constructor(declared, source, services) {
|
|
21
|
+
this.id = `external:${source}`;
|
|
22
|
+
this.declared = declared;
|
|
23
|
+
this.source = source;
|
|
24
|
+
this.services = services;
|
|
25
|
+
}
|
|
26
|
+
tools() {
|
|
27
|
+
return this.declared.map((tool) => ({
|
|
28
|
+
name: tool.name,
|
|
29
|
+
description: tool.description,
|
|
30
|
+
parameters: tool.parameters,
|
|
31
|
+
source: this.source,
|
|
32
|
+
}));
|
|
33
|
+
}
|
|
34
|
+
modules() {
|
|
35
|
+
return [];
|
|
36
|
+
}
|
|
37
|
+
async call(request, context) {
|
|
38
|
+
const tool = this.declared.find((one) => one.name === request.name);
|
|
39
|
+
// The catalog routes by name, so a call reaches this group only for a name
|
|
40
|
+
// it declares; the refusal keeps the answer in the core's error shape.
|
|
41
|
+
if (tool === undefined)
|
|
42
|
+
return refused("tool.not_owned", this.services.table, "unknown_tool", { name: request.name });
|
|
43
|
+
return tool.execute(request, context);
|
|
44
|
+
}
|
|
45
|
+
}
|
package/groups/files.d.ts
CHANGED
|
@@ -17,6 +17,14 @@
|
|
|
17
17
|
* stopped with `bash_stop`, through the host's own process path.
|
|
18
18
|
*/
|
|
19
19
|
import type { CallContext, DirectToolSpec, ToolCall, ToolGroup, ToolResult } from "@4fu/arbor-interfaces";
|
|
20
|
+
/**
|
|
21
|
+
* The environment one command runs with: the host's environment, the window's
|
|
22
|
+
* own directory, and the extra environment the workspace
|
|
23
|
+
* implementation asks for. This is the one place a command's environment is
|
|
24
|
+
* built, so everything a deployment adds to it is added here
|
|
25
|
+
* (docs/environment.md#what-a-subprocess-gets).
|
|
26
|
+
*/
|
|
27
|
+
export declare function bashEnvironment(cwd: string, extraEnv?: Record<string, string>): Record<string, string>;
|
|
20
28
|
export declare class FileToolGroup implements ToolGroup {
|
|
21
29
|
readonly id = "files";
|
|
22
30
|
private readonly services;
|
package/groups/files.js
CHANGED
|
@@ -20,13 +20,23 @@ import { spawn } from "node:child_process";
|
|
|
20
20
|
import { mkdir, readFile, writeFile } from "node:fs/promises";
|
|
21
21
|
import { dirname, isAbsolute, join, normalize, resolve } from "node:path";
|
|
22
22
|
import { textOf } from "@4fu/arbor-prompt";
|
|
23
|
-
import {
|
|
23
|
+
import { childEnvironment } from "../child-env.js";
|
|
24
24
|
import { envCommand } from "../env-stage.js";
|
|
25
25
|
import { toolSpec } from "../host.js";
|
|
26
26
|
/** How long `bash` waits before it hands the command to `bash_output`. */
|
|
27
27
|
const BASH_WAIT_MS = 20000;
|
|
28
28
|
/** How much one background job keeps in memory. */
|
|
29
29
|
const JOB_BUFFER = 200000;
|
|
30
|
+
/**
|
|
31
|
+
* The environment one command runs with: the host's environment, the window's
|
|
32
|
+
* own directory, and the extra environment the workspace
|
|
33
|
+
* implementation asks for. This is the one place a command's environment is
|
|
34
|
+
* built, so everything a deployment adds to it is added here
|
|
35
|
+
* (docs/environment.md#what-a-subprocess-gets).
|
|
36
|
+
*/
|
|
37
|
+
export function bashEnvironment(cwd, extraEnv) {
|
|
38
|
+
return childEnvironment({ workspace: cwd, env: extraEnv });
|
|
39
|
+
}
|
|
30
40
|
export class FileToolGroup {
|
|
31
41
|
id = "files";
|
|
32
42
|
services;
|
|
@@ -119,13 +129,16 @@ export class FileToolGroup {
|
|
|
119
129
|
// there and `path` is not a host path.
|
|
120
130
|
return this.ran(request, await runToEnd(workspace.exec, command));
|
|
121
131
|
}
|
|
122
|
-
const job = this.start(command, workspace.path);
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
132
|
+
const job = this.start(command, workspace.path, this.services.workspace?.bashEnv?.(window));
|
|
133
|
+
const wait = numberArg(request.args, "timeout", BASH_WAIT_MS);
|
|
134
|
+
await Promise.race([job.ended, sleep(wait)]);
|
|
135
|
+
// A command that ran to its end inside the wait window is answered with
|
|
136
|
+
// what it wrote; one that outlived it becomes a background job, and the
|
|
137
|
+
// result line says the window is not woken when it ends.
|
|
138
|
+
const content = job.done
|
|
139
|
+
? job.output
|
|
140
|
+
: `${job.output}\n${textOf(table, "result.bash.started", { job: job.id, ms: wait })}`;
|
|
141
|
+
return { callId: request.callId, ok: job.done ? job.code === 0 : true, content };
|
|
129
142
|
}
|
|
130
143
|
/** One command that ran to its end: what it wrote, and whether it succeeded. */
|
|
131
144
|
ran(request, outcome) {
|
|
@@ -174,15 +187,12 @@ export class FileToolGroup {
|
|
|
174
187
|
const lines = text.split("\n");
|
|
175
188
|
const offset = Math.max(0, numberArg(request.args, "offset", 0));
|
|
176
189
|
const limit = Math.min(500, Math.max(1, numberArg(request.args, "limit", 400)));
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
callId: request.callId,
|
|
180
|
-
table,
|
|
181
|
-
});
|
|
190
|
+
// The page is bounded once, at the one exit every call leaves by, so this
|
|
191
|
+
// tool cuts nothing of its own.
|
|
182
192
|
return {
|
|
183
193
|
callId: request.callId,
|
|
184
194
|
ok: true,
|
|
185
|
-
content: `${textOf(table, "result.read.file", { path, lines: lines.length })}\n${
|
|
195
|
+
content: `${textOf(table, "result.read.file", { path, lines: lines.length })}\n${lines.slice(offset, offset + limit).join("\n")}`,
|
|
186
196
|
};
|
|
187
197
|
}
|
|
188
198
|
async write(request, window) {
|
|
@@ -243,34 +253,45 @@ export class FileToolGroup {
|
|
|
243
253
|
await mkdir(dirname(target), { recursive: true });
|
|
244
254
|
await writeFile(target, text, "utf8");
|
|
245
255
|
}
|
|
246
|
-
start(command, cwd) {
|
|
256
|
+
start(command, cwd, extraEnv) {
|
|
247
257
|
const id = `b${this.nextJob}`;
|
|
248
258
|
this.nextJob += 1;
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
child
|
|
255
|
-
|
|
259
|
+
// The one place a command's environment is built: the host's environment,
|
|
260
|
+
// the window's own directory, and the extra environment the workspace
|
|
261
|
+
// implementation asks for - the ovnfs workspace's `ARBOR_WINDOW` and git
|
|
262
|
+
// hook path. The extra variables reach no MCP server and no service
|
|
263
|
+
// (docs/environment.md#what-a-subprocess-gets).
|
|
264
|
+
const child = spawn("/bin/sh", ["-c", command], { cwd, env: bashEnvironment(cwd, extraEnv) });
|
|
265
|
+
let end = () => { };
|
|
256
266
|
const job = {
|
|
257
267
|
id,
|
|
258
268
|
output: "",
|
|
259
269
|
done: false,
|
|
260
270
|
code: null,
|
|
271
|
+
ended: new Promise((resolve) => {
|
|
272
|
+
end = resolve;
|
|
273
|
+
}),
|
|
261
274
|
stop: () => {
|
|
262
275
|
child.kill("SIGTERM");
|
|
263
276
|
},
|
|
264
277
|
};
|
|
278
|
+
// Output lands on the job as it is written, so a running job's output can
|
|
279
|
+
// be read before it ends.
|
|
280
|
+
const append = (chunk) => {
|
|
281
|
+
job.output = (job.output + chunk.toString("utf8")).slice(-JOB_BUFFER);
|
|
282
|
+
};
|
|
283
|
+
child.stdout.on("data", append);
|
|
284
|
+
child.stderr.on("data", append);
|
|
265
285
|
child.on("close", (code) => {
|
|
266
286
|
job.done = true;
|
|
267
287
|
job.code = code;
|
|
268
|
-
|
|
288
|
+
end();
|
|
269
289
|
});
|
|
270
290
|
child.on("error", (error) => {
|
|
271
291
|
job.done = true;
|
|
272
292
|
job.code = 1;
|
|
273
|
-
job.output
|
|
293
|
+
job.output += error.message;
|
|
294
|
+
end();
|
|
274
295
|
});
|
|
275
296
|
this.jobs.set(id, job);
|
|
276
297
|
return job;
|
package/groups/notes.d.ts
CHANGED
|
@@ -27,6 +27,12 @@ export declare class NoteToolGroup implements ToolGroup {
|
|
|
27
27
|
* that changed since the one the writer read.
|
|
28
28
|
*/
|
|
29
29
|
private record;
|
|
30
|
+
/**
|
|
31
|
+
* One write's answer, with the active set's budget beside it: the budget is
|
|
32
|
+
* part of every prefix, so a window that writes hears how full it is
|
|
33
|
+
* (docs/memory.md#the-active-set). A delete is its own line: it removed a
|
|
34
|
+
* note rather than writing one.
|
|
35
|
+
*/
|
|
30
36
|
private answer;
|
|
31
37
|
/** One refusal the note store returned, in the core's error shape. */
|
|
32
38
|
private refusalOf;
|
package/groups/notes.js
CHANGED
|
@@ -24,6 +24,7 @@ export class NoteToolGroup {
|
|
|
24
24
|
title: { type: "string", required: true },
|
|
25
25
|
summary: { type: "string" },
|
|
26
26
|
body: { type: "string", required: true },
|
|
27
|
+
refs: { type: "refs" },
|
|
27
28
|
}),
|
|
28
29
|
toolSpec(table, "note_replace", {
|
|
29
30
|
id: { type: "string", required: true },
|
|
@@ -31,6 +32,7 @@ export class NoteToolGroup {
|
|
|
31
32
|
title: { type: "string" },
|
|
32
33
|
summary: { type: "string" },
|
|
33
34
|
body: { type: "string", required: true },
|
|
35
|
+
refs: { type: "refs" },
|
|
34
36
|
}),
|
|
35
37
|
toolSpec(table, "note_delete", {
|
|
36
38
|
id: { type: "string", required: true },
|
|
@@ -57,59 +59,76 @@ export class NoteToolGroup {
|
|
|
57
59
|
}
|
|
58
60
|
}
|
|
59
61
|
async write(request, store, window) {
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
return this.refusal(request, "note.write.empty");
|
|
62
|
+
// Every rule a write has to meet lives in the store, which answers in the
|
|
63
|
+
// core's error shape; the group adds nothing of its own.
|
|
64
|
+
const refs = refsArg(request.args, "refs");
|
|
64
65
|
return this.record(request, store, window, {
|
|
65
|
-
title,
|
|
66
|
-
body,
|
|
66
|
+
title: stringArg(request.args, "title"),
|
|
67
|
+
body: stringArg(request.args, "body"),
|
|
67
68
|
...(stringArg(request.args, "summary") === "" ? {} : { summary: stringArg(request.args, "summary") }),
|
|
69
|
+
...(refs.length === 0 ? {} : { refs }),
|
|
68
70
|
});
|
|
69
71
|
}
|
|
70
72
|
async replace(request, store, window) {
|
|
71
|
-
const
|
|
72
|
-
const revision = numberArg(request.args, "revision");
|
|
73
|
-
const body = stringArg(request.args, "body");
|
|
74
|
-
if (id === "" || body === "")
|
|
75
|
-
return this.refusal(request, "note.write.empty");
|
|
73
|
+
const refs = refsArg(request.args, "refs");
|
|
76
74
|
return this.record(request, store, window, {
|
|
77
|
-
id: id,
|
|
75
|
+
id: stringArg(request.args, "id"),
|
|
78
76
|
title: stringArg(request.args, "title"),
|
|
79
|
-
body,
|
|
77
|
+
body: stringArg(request.args, "body"),
|
|
80
78
|
replace: true,
|
|
81
|
-
expectedRevision: revision,
|
|
79
|
+
expectedRevision: numberArg(request.args, "revision"),
|
|
82
80
|
...(stringArg(request.args, "summary") === "" ? {} : { summary: stringArg(request.args, "summary") }),
|
|
81
|
+
...(refs.length === 0 ? {} : { refs }),
|
|
83
82
|
});
|
|
84
83
|
}
|
|
85
84
|
async remove(request, store, window) {
|
|
86
|
-
const id = stringArg(request.args, "id");
|
|
87
|
-
if (id === "")
|
|
88
|
-
return this.refusal(request, "note.write.empty");
|
|
89
85
|
return this.record(request, store, window, {
|
|
90
|
-
id: id,
|
|
86
|
+
id: stringArg(request.args, "id"),
|
|
91
87
|
title: "",
|
|
92
88
|
body: "",
|
|
93
89
|
delete: true,
|
|
94
90
|
expectedRevision: numberArg(request.args, "revision"),
|
|
95
|
-
});
|
|
91
|
+
}, true);
|
|
96
92
|
}
|
|
97
93
|
/**
|
|
98
94
|
* One write, with the anchor the host fills in. A refused write changes
|
|
99
95
|
* nothing; the answer carries the rule, the next action and the revisions
|
|
100
96
|
* that changed since the one the writer read.
|
|
101
97
|
*/
|
|
102
|
-
async record(request,
|
|
98
|
+
async record(request, store, window, input, deleted = false) {
|
|
103
99
|
try {
|
|
104
100
|
const note = await this.services.writeNote(window, input);
|
|
105
|
-
return this.answer(request, note.ref,
|
|
101
|
+
return this.answer(request, note.ref, store, deleted);
|
|
106
102
|
}
|
|
107
103
|
catch (error) {
|
|
108
104
|
return this.refusalOf(request, error);
|
|
109
105
|
}
|
|
110
106
|
}
|
|
111
|
-
|
|
112
|
-
|
|
107
|
+
/**
|
|
108
|
+
* One write's answer, with the active set's budget beside it: the budget is
|
|
109
|
+
* part of every prefix, so a window that writes hears how full it is
|
|
110
|
+
* (docs/memory.md#the-active-set). A delete is its own line: it removed a
|
|
111
|
+
* note rather than writing one.
|
|
112
|
+
*/
|
|
113
|
+
async answer(request, ref, store, deleted = false) {
|
|
114
|
+
const table = this.services.table;
|
|
115
|
+
const snapshot = await store.snapshot().catch(() => null);
|
|
116
|
+
return {
|
|
117
|
+
callId: request.callId,
|
|
118
|
+
ok: true,
|
|
119
|
+
content: [
|
|
120
|
+
textOf(table, deleted ? "result.note_deleted" : "result.note_written", { ref }),
|
|
121
|
+
...(snapshot === null
|
|
122
|
+
? []
|
|
123
|
+
: [
|
|
124
|
+
textOf(table, "result.note.budget", {
|
|
125
|
+
used: snapshot.tokens,
|
|
126
|
+
hard: snapshot.budget.tokens,
|
|
127
|
+
left: Math.max(0, snapshot.budget.count - snapshot.total),
|
|
128
|
+
}),
|
|
129
|
+
]),
|
|
130
|
+
].join("\n"),
|
|
131
|
+
};
|
|
113
132
|
}
|
|
114
133
|
/** One refusal the note store returned, in the core's error shape. */
|
|
115
134
|
refusalOf(request, error) {
|
|
@@ -117,8 +136,9 @@ export class NoteToolGroup {
|
|
|
117
136
|
const info = error;
|
|
118
137
|
const revisions = info.details?.revisions ?? [];
|
|
119
138
|
const lines = [info.message ?? String(error)];
|
|
120
|
-
if (info.nextAction !== undefined)
|
|
121
|
-
lines.push(
|
|
139
|
+
if (info.nextAction !== undefined) {
|
|
140
|
+
lines.push(textOf(table, "error.next", { next: info.nextAction }));
|
|
141
|
+
}
|
|
122
142
|
for (const revision of revisions) {
|
|
123
143
|
lines.push(`${revision.ref} ${revision.window ?? ""}`);
|
|
124
144
|
}
|
|
@@ -174,3 +194,10 @@ function numberArg(args, name) {
|
|
|
174
194
|
const value = args[name];
|
|
175
195
|
return typeof value === "number" && Number.isFinite(value) ? value : undefined;
|
|
176
196
|
}
|
|
197
|
+
/** The refs a write names, so the store can link them to the note. */
|
|
198
|
+
function refsArg(args, name) {
|
|
199
|
+
const value = args[name];
|
|
200
|
+
if (!Array.isArray(value))
|
|
201
|
+
return [];
|
|
202
|
+
return value.filter((item) => typeof item === "string");
|
|
203
|
+
}
|
package/host.d.ts
CHANGED
|
@@ -76,8 +76,6 @@ export interface ToolServices {
|
|
|
76
76
|
ensureWorkspace(window: WindowRef): Promise<WorkspaceMount | undefined>;
|
|
77
77
|
/** End the run, with a handoff when there is unfinished work. */
|
|
78
78
|
endWindow(window: WindowRef, handoff?: Handoff): Promise<ToolResult>;
|
|
79
|
-
/** True when the window still has work the host can see. */
|
|
80
|
-
hasOpenWork(window: WindowRef): Promise<boolean>;
|
|
81
79
|
/**
|
|
82
80
|
* One note write, with the anchor the host fills in: the window, the event
|
|
83
81
|
* and the evidence. The model never declares an anchor.
|
package/index.d.ts
CHANGED
|
@@ -7,11 +7,14 @@
|
|
|
7
7
|
* in `code_exec`. There is no CLI, and there is no second path around the
|
|
8
8
|
* service layer.
|
|
9
9
|
*/
|
|
10
|
-
export { ToolCatalog, type ToolCatalogOptions } from "./catalog.ts";
|
|
10
|
+
export { ToolCatalog, type ExternalToolRefusal, type ToolCatalogOptions } from "./catalog.ts";
|
|
11
11
|
export { SCRIPT_TIMEOUT_MS, directToolIn, runScript, type CodeExecContext, type ScriptCatalog, } from "./code-exec.ts";
|
|
12
12
|
export { RESULT_LIMIT, boundResult, boundText, nextCall, type Bound } from "./bound.ts";
|
|
13
|
+
export { WORKSPACE_ENV, childEnvironment, type ChildEnvInput } from "./child-env.ts";
|
|
13
14
|
export { callContext, moduleSpec, refused, toolSpec, type EnvCatalog, type LiveWindow, type ReloadOutcome, type SearchOutcome, type SendOutcome, type TaskRecord, type TaskSummary, type ToolServices, } from "./host.ts";
|
|
14
15
|
export { CoreToolGroup, handoffOf } from "./groups/core.ts";
|
|
16
|
+
export { ExternalToolGroup } from "./groups/external.ts";
|
|
17
|
+
export { bashEnvironment } from "./groups/files.ts";
|
|
15
18
|
export { EnvToolGroup } from "./groups/env.ts";
|
|
16
19
|
export { NoteToolGroup } from "./groups/notes.ts";
|
|
17
20
|
export { FileToolGroup } from "./groups/files.ts";
|
package/index.js
CHANGED
|
@@ -10,8 +10,11 @@
|
|
|
10
10
|
export { ToolCatalog } from "./catalog.js";
|
|
11
11
|
export { SCRIPT_TIMEOUT_MS, directToolIn, runScript, } from "./code-exec.js";
|
|
12
12
|
export { RESULT_LIMIT, boundResult, boundText, nextCall } from "./bound.js";
|
|
13
|
+
export { WORKSPACE_ENV, childEnvironment } from "./child-env.js";
|
|
13
14
|
export { callContext, moduleSpec, refused, toolSpec, } from "./host.js";
|
|
14
15
|
export { CoreToolGroup, handoffOf } from "./groups/core.js";
|
|
16
|
+
export { ExternalToolGroup } from "./groups/external.js";
|
|
17
|
+
export { bashEnvironment } from "./groups/files.js";
|
|
15
18
|
export { EnvToolGroup } from "./groups/env.js";
|
|
16
19
|
export { NoteToolGroup } from "./groups/notes.js";
|
|
17
20
|
export { FileToolGroup } from "./groups/files.js";
|
package/modules/modules.d.ts
CHANGED
|
@@ -17,7 +17,14 @@ export declare class JournalModuleGroup implements ToolGroup {
|
|
|
17
17
|
tools(): readonly [];
|
|
18
18
|
modules(): readonly ModuleSpec[];
|
|
19
19
|
call(request: ToolCall, _context: CallContext): Promise<ToolResult>;
|
|
20
|
+
/**
|
|
21
|
+
* One answer: the items, then one 相关 line per returned item for the first
|
|
22
|
+
* three of them, then the total and the call that pages the rest
|
|
23
|
+
* (docs/prompting.md#information-density).
|
|
24
|
+
*/
|
|
20
25
|
private answer;
|
|
26
|
+
/** One 相关 line per item, for the first three: no line without neighbours. */
|
|
27
|
+
private related;
|
|
21
28
|
private notOwned;
|
|
22
29
|
}
|
|
23
30
|
/** `windows`: list, show, send, broadcast, inbox. */
|
|
@@ -90,7 +97,7 @@ export declare class ScheduleModuleGroup implements ToolGroup {
|
|
|
90
97
|
private create;
|
|
91
98
|
/** `schedule.update`: only the fields the script named change. */
|
|
92
99
|
private update;
|
|
93
|
-
/** One repeat rule as one line: only once, a period, or a
|
|
100
|
+
/** One repeat rule as one line: only once, a period, a daily or a weekly time. */
|
|
94
101
|
private repeatText;
|
|
95
102
|
/** One argument the script did not name in a shape this module can read. */
|
|
96
103
|
private badInput;
|