@4fu/arbor-tools 0.0.0-stage → 2026.1106.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +187 -2
- package/bound.d.ts +34 -0
- package/bound.js +55 -0
- package/catalog.d.ts +60 -0
- package/catalog.js +224 -0
- package/code-exec.d.ts +55 -0
- package/code-exec.js +100 -0
- package/env-stage.d.ts +29 -0
- package/env-stage.js +63 -0
- package/groups/core.d.ts +30 -0
- package/groups/core.js +148 -0
- package/groups/env.d.ts +45 -0
- package/groups/env.js +190 -0
- package/groups/files.d.ts +63 -0
- package/groups/files.js +343 -0
- package/groups/notes.d.ts +37 -0
- package/groups/notes.js +176 -0
- package/host.d.ts +199 -0
- package/host.js +70 -0
- package/index.d.ts +18 -0
- package/index.js +18 -0
- package/modules/env.d.ts +42 -0
- package/modules/env.js +237 -0
- package/modules/modules.d.ts +121 -0
- package/modules/modules.js +1060 -0
- package/package.json +55 -4
package/groups/files.js
ADDED
|
@@ -0,0 +1,343 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The window's workspace: `bash`, `bash_output`, `bash_stop`, `read`, `write`
|
|
3
|
+
* and `edit` (docs/tools.md#direct-tools, docs/workspaces.md).
|
|
4
|
+
*
|
|
5
|
+
* A plain command is a direct call, a chain is a script. One behaviour per
|
|
6
|
+
* tool: a command that outlives `bash`'s wait window is followed with
|
|
7
|
+
* `bash_output` and stopped with `bash_stop`.
|
|
8
|
+
*
|
|
9
|
+
* Everything reaches the workspace through the `Workspace` interface, never the
|
|
10
|
+
* host filesystem directly: a capability the implementation owns - `exec`,
|
|
11
|
+
* `readFile`, `writeFile` - is used when it is present, and the host path in
|
|
12
|
+
* `mount.path` when it is not. An implementation that owns its execution
|
|
13
|
+
* environment has a `path` that is not a host path and an `exec` that is the
|
|
14
|
+
* only way to run a command there; a plain-directory workspace owns no
|
|
15
|
+
* execution environment, so the caller runs the command itself. Either way a
|
|
16
|
+
* command that outlives `bash`'s wait window is followed with `bash_output` and
|
|
17
|
+
* stopped with `bash_stop`, through the host's own process path.
|
|
18
|
+
*/
|
|
19
|
+
import { spawn } from "node:child_process";
|
|
20
|
+
import { mkdir, readFile, writeFile } from "node:fs/promises";
|
|
21
|
+
import { dirname, isAbsolute, join, normalize, resolve } from "node:path";
|
|
22
|
+
import { textOf } from "@4fu/arbor-prompt";
|
|
23
|
+
import { boundResult } from "../bound.js";
|
|
24
|
+
import { envCommand } from "../env-stage.js";
|
|
25
|
+
import { toolSpec } from "../host.js";
|
|
26
|
+
/** How long `bash` waits before it hands the command to `bash_output`. */
|
|
27
|
+
const BASH_WAIT_MS = 20000;
|
|
28
|
+
/** How much one background job keeps in memory. */
|
|
29
|
+
const JOB_BUFFER = 200000;
|
|
30
|
+
export class FileToolGroup {
|
|
31
|
+
id = "files";
|
|
32
|
+
services;
|
|
33
|
+
jobs = new Map();
|
|
34
|
+
nextJob = 1;
|
|
35
|
+
constructor(services) {
|
|
36
|
+
this.services = services;
|
|
37
|
+
}
|
|
38
|
+
tools() {
|
|
39
|
+
const table = this.services.table;
|
|
40
|
+
return [
|
|
41
|
+
toolSpec(table, "bash", {
|
|
42
|
+
command: { type: "string", required: true },
|
|
43
|
+
timeout: { type: "number" },
|
|
44
|
+
}),
|
|
45
|
+
toolSpec(table, "bash_output", { job: { type: "string", required: true } }),
|
|
46
|
+
toolSpec(table, "bash_stop", { job: { type: "string", required: true } }),
|
|
47
|
+
toolSpec(table, "read", {
|
|
48
|
+
path: { type: "string", required: true },
|
|
49
|
+
offset: { type: "number" },
|
|
50
|
+
limit: { type: "number" },
|
|
51
|
+
}),
|
|
52
|
+
toolSpec(table, "write", {
|
|
53
|
+
path: { type: "string", required: true },
|
|
54
|
+
text: { type: "string", required: true },
|
|
55
|
+
}),
|
|
56
|
+
toolSpec(table, "edit", {
|
|
57
|
+
path: { type: "string", required: true },
|
|
58
|
+
old: { type: "string", required: true },
|
|
59
|
+
new: { type: "string", required: true },
|
|
60
|
+
}),
|
|
61
|
+
];
|
|
62
|
+
}
|
|
63
|
+
modules() {
|
|
64
|
+
return [];
|
|
65
|
+
}
|
|
66
|
+
async call(request, context) {
|
|
67
|
+
switch (request.name) {
|
|
68
|
+
case "bash":
|
|
69
|
+
return this.bash(request, context.window, context);
|
|
70
|
+
case "bash_output":
|
|
71
|
+
return this.bashOutput(request);
|
|
72
|
+
case "bash_stop":
|
|
73
|
+
return this.bashStop(request);
|
|
74
|
+
case "read":
|
|
75
|
+
return this.read(request, context.window);
|
|
76
|
+
case "write":
|
|
77
|
+
return this.write(request, context.window);
|
|
78
|
+
case "edit":
|
|
79
|
+
return this.edit(request, context.window);
|
|
80
|
+
default:
|
|
81
|
+
return this.notOwned(request);
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
/** The window's own directory, created on first use. */
|
|
85
|
+
async mount(window) {
|
|
86
|
+
return this.services.ensureWorkspace(window);
|
|
87
|
+
}
|
|
88
|
+
/**
|
|
89
|
+
* The environment, at the path a window reads it at. It is mounted read-only,
|
|
90
|
+
* so a window without a workspace still reads `/env/skills/<name>/SKILL.md`
|
|
91
|
+
* (docs/environment.md#layout), and a write to it is refused.
|
|
92
|
+
*/
|
|
93
|
+
envPath(path) {
|
|
94
|
+
const env = this.services.envPaths();
|
|
95
|
+
if (env === undefined)
|
|
96
|
+
return undefined;
|
|
97
|
+
if (path !== env.prefix && !path.startsWith(`${env.prefix}/`))
|
|
98
|
+
return undefined;
|
|
99
|
+
const inside = path === env.prefix ? "" : path.slice(env.prefix.length + 1);
|
|
100
|
+
return inside === "" ? env.directory : join(env.directory, inside);
|
|
101
|
+
}
|
|
102
|
+
/** Record which revision of an item a window read, for `reload`'s check. */
|
|
103
|
+
async noteEnvRead(window, path) {
|
|
104
|
+
const prefix = this.services.envPaths()?.prefix;
|
|
105
|
+
if (prefix === undefined || (path !== prefix && !path.startsWith(`${prefix}/`)))
|
|
106
|
+
return;
|
|
107
|
+
const item = await this.services.envItemAt(path);
|
|
108
|
+
if (item !== undefined)
|
|
109
|
+
this.services.envRead(window, item);
|
|
110
|
+
}
|
|
111
|
+
async bash(request, window, context) {
|
|
112
|
+
const table = this.services.table;
|
|
113
|
+
const workspace = await this.mount(window);
|
|
114
|
+
if (workspace === undefined)
|
|
115
|
+
return this.absent(request);
|
|
116
|
+
const command = await envCommand(workspace, stringArg(request.args, "command"), this.services.envPaths(), context.envRevision);
|
|
117
|
+
if (workspace.exec !== undefined) {
|
|
118
|
+
// The implementation owns the execution environment, so the command runs
|
|
119
|
+
// there and `path` is not a host path.
|
|
120
|
+
return this.ran(request, await runToEnd(workspace.exec, command));
|
|
121
|
+
}
|
|
122
|
+
const job = this.start(command, workspace.path);
|
|
123
|
+
await sleep(numberArg(request.args, "timeout", BASH_WAIT_MS));
|
|
124
|
+
return {
|
|
125
|
+
callId: request.callId,
|
|
126
|
+
ok: job.done ? job.code === 0 : true,
|
|
127
|
+
content: `${job.output}\n${textOf(table, "result.bash.started", { job: job.id })}`,
|
|
128
|
+
};
|
|
129
|
+
}
|
|
130
|
+
/** One command that ran to its end: what it wrote, and whether it succeeded. */
|
|
131
|
+
ran(request, outcome) {
|
|
132
|
+
const body = [outcome.stdout, outcome.stderr].filter((part) => part !== "").join("\n");
|
|
133
|
+
return { callId: request.callId, ok: outcome.exitCode === 0, content: body };
|
|
134
|
+
}
|
|
135
|
+
async bashOutput(request) {
|
|
136
|
+
const table = this.services.table;
|
|
137
|
+
const id = stringArg(request.args, "job");
|
|
138
|
+
const job = this.jobs.get(id);
|
|
139
|
+
if (job === undefined)
|
|
140
|
+
return this.jobMissing(request, id);
|
|
141
|
+
const content = job.done
|
|
142
|
+
? `${job.output}\n${textOf(table, "result.bash.stopped", { job: id })}`
|
|
143
|
+
: `${job.output}\n${textOf(table, "result.bash.running", { job: id })}`;
|
|
144
|
+
return { callId: request.callId, ok: job.done ? job.code === 0 : true, content };
|
|
145
|
+
}
|
|
146
|
+
async bashStop(request) {
|
|
147
|
+
const table = this.services.table;
|
|
148
|
+
const id = stringArg(request.args, "job");
|
|
149
|
+
const job = this.jobs.get(id);
|
|
150
|
+
if (job === undefined)
|
|
151
|
+
return this.jobMissing(request, id);
|
|
152
|
+
job.stop();
|
|
153
|
+
return {
|
|
154
|
+
callId: request.callId,
|
|
155
|
+
ok: true,
|
|
156
|
+
content: `${job.output}\n${textOf(table, "result.bash.stopped", { job: id })}`,
|
|
157
|
+
};
|
|
158
|
+
}
|
|
159
|
+
async read(request, window) {
|
|
160
|
+
const table = this.services.table;
|
|
161
|
+
const path = stringArg(request.args, "path");
|
|
162
|
+
// The environment is read where a window sees it, without a workspace.
|
|
163
|
+
const env = this.envPath(path);
|
|
164
|
+
const workspace = env === undefined ? await this.mount(window) : undefined;
|
|
165
|
+
if (env === undefined && workspace === undefined)
|
|
166
|
+
return this.absent(request);
|
|
167
|
+
const text = env === undefined
|
|
168
|
+
? await this.readText(workspace, path)
|
|
169
|
+
: await readFile(env, "utf8").catch(() => null);
|
|
170
|
+
if (text === null)
|
|
171
|
+
return this.missing(request, "read.missing", path);
|
|
172
|
+
if (env !== undefined)
|
|
173
|
+
await this.noteEnvRead(window, path);
|
|
174
|
+
const lines = text.split("\n");
|
|
175
|
+
const offset = Math.max(0, numberArg(request.args, "offset", 0));
|
|
176
|
+
const limit = Math.min(500, Math.max(1, numberArg(request.args, "limit", 400)));
|
|
177
|
+
const body = await boundResult(lines.slice(offset, offset + limit).join("\n"), {
|
|
178
|
+
workspace: workspace?.path ?? path,
|
|
179
|
+
callId: request.callId,
|
|
180
|
+
table,
|
|
181
|
+
});
|
|
182
|
+
return {
|
|
183
|
+
callId: request.callId,
|
|
184
|
+
ok: true,
|
|
185
|
+
content: `${textOf(table, "result.read.file", { path, lines: lines.length })}\n${body}`,
|
|
186
|
+
};
|
|
187
|
+
}
|
|
188
|
+
async write(request, window) {
|
|
189
|
+
const table = this.services.table;
|
|
190
|
+
const path = stringArg(request.args, "path");
|
|
191
|
+
if (this.envPath(path) !== undefined)
|
|
192
|
+
return this.readonly(request, path);
|
|
193
|
+
const workspace = await this.mount(window);
|
|
194
|
+
if (workspace === undefined)
|
|
195
|
+
return this.absent(request);
|
|
196
|
+
const text = stringArg(request.args, "text");
|
|
197
|
+
await this.writeText(workspace, path, text);
|
|
198
|
+
return {
|
|
199
|
+
callId: request.callId,
|
|
200
|
+
ok: true,
|
|
201
|
+
content: textOf(table, "result.write.file", { path, bytes: Buffer.byteLength(text) }),
|
|
202
|
+
};
|
|
203
|
+
}
|
|
204
|
+
async edit(request, window) {
|
|
205
|
+
const table = this.services.table;
|
|
206
|
+
const path = stringArg(request.args, "path");
|
|
207
|
+
if (this.envPath(path) !== undefined)
|
|
208
|
+
return this.readonly(request, path);
|
|
209
|
+
const workspace = await this.mount(window);
|
|
210
|
+
if (workspace === undefined)
|
|
211
|
+
return this.absent(request);
|
|
212
|
+
const old = stringArg(request.args, "old");
|
|
213
|
+
const next = stringArg(request.args, "new");
|
|
214
|
+
const text = await this.readText(workspace, path);
|
|
215
|
+
if (text === null)
|
|
216
|
+
return this.missing(request, "edit.missing", path);
|
|
217
|
+
const count = text.split(old).length - 1;
|
|
218
|
+
if (count === 0)
|
|
219
|
+
return this.noMatch(request, path, old);
|
|
220
|
+
await this.writeText(workspace, path, text.split(old).join(next));
|
|
221
|
+
return {
|
|
222
|
+
callId: request.callId,
|
|
223
|
+
ok: true,
|
|
224
|
+
content: textOf(table, "result.edit.file", { path, replacements: count }),
|
|
225
|
+
};
|
|
226
|
+
}
|
|
227
|
+
/**
|
|
228
|
+
* One file's text: the mount's own capability when it has one, the host path
|
|
229
|
+
* when it does not. `null` means the file is not there.
|
|
230
|
+
*/
|
|
231
|
+
async readText(workspace, path) {
|
|
232
|
+
if (workspace.readFile !== undefined)
|
|
233
|
+
return workspace.readFile(path);
|
|
234
|
+
return readFile(hostPath(workspace.path, path), "utf8").catch(() => null);
|
|
235
|
+
}
|
|
236
|
+
/** Write one file, creating its directory. */
|
|
237
|
+
async writeText(workspace, path, text) {
|
|
238
|
+
if (workspace.writeFile !== undefined) {
|
|
239
|
+
await workspace.writeFile(path, text);
|
|
240
|
+
return;
|
|
241
|
+
}
|
|
242
|
+
const target = hostPath(workspace.path, path);
|
|
243
|
+
await mkdir(dirname(target), { recursive: true });
|
|
244
|
+
await writeFile(target, text, "utf8");
|
|
245
|
+
}
|
|
246
|
+
start(command, cwd) {
|
|
247
|
+
const id = `b${this.nextJob}`;
|
|
248
|
+
this.nextJob += 1;
|
|
249
|
+
const child = spawn("/bin/sh", ["-c", command], { cwd });
|
|
250
|
+
let output = "";
|
|
251
|
+
const append = (chunk) => {
|
|
252
|
+
output = (output + chunk.toString("utf8")).slice(-JOB_BUFFER);
|
|
253
|
+
};
|
|
254
|
+
child.stdout.on("data", append);
|
|
255
|
+
child.stderr.on("data", append);
|
|
256
|
+
const job = {
|
|
257
|
+
id,
|
|
258
|
+
output: "",
|
|
259
|
+
done: false,
|
|
260
|
+
code: null,
|
|
261
|
+
stop: () => {
|
|
262
|
+
child.kill("SIGTERM");
|
|
263
|
+
},
|
|
264
|
+
};
|
|
265
|
+
child.on("close", (code) => {
|
|
266
|
+
job.done = true;
|
|
267
|
+
job.code = code;
|
|
268
|
+
job.output = output;
|
|
269
|
+
});
|
|
270
|
+
child.on("error", (error) => {
|
|
271
|
+
job.done = true;
|
|
272
|
+
job.code = 1;
|
|
273
|
+
job.output = output + error.message;
|
|
274
|
+
});
|
|
275
|
+
this.jobs.set(id, job);
|
|
276
|
+
return job;
|
|
277
|
+
}
|
|
278
|
+
absent(request) {
|
|
279
|
+
return this.refusal(request, "workspace.absent", "result.no_workspace", {});
|
|
280
|
+
}
|
|
281
|
+
jobMissing(request, id) {
|
|
282
|
+
return this.refusal(request, "bash.job.not_found", "bash.notfound", { job: id });
|
|
283
|
+
}
|
|
284
|
+
missing(request, key, path) {
|
|
285
|
+
return this.refusal(request, key === "read.missing" ? "file.not_found" : "file.not_found", key, { path });
|
|
286
|
+
}
|
|
287
|
+
noMatch(request, path, old) {
|
|
288
|
+
return this.refusal(request, "edit.no_match", "edit.nomatch", { path, old });
|
|
289
|
+
}
|
|
290
|
+
/** The environment is mounted read-only, so nothing there is written. */
|
|
291
|
+
readonly(request, path) {
|
|
292
|
+
return this.refusal(request, "env.readonly_path", "env.readonly_path", { path });
|
|
293
|
+
}
|
|
294
|
+
refusal(request, rule, key, args) {
|
|
295
|
+
const table = this.services.table;
|
|
296
|
+
return {
|
|
297
|
+
callId: request.callId,
|
|
298
|
+
ok: false,
|
|
299
|
+
content: "",
|
|
300
|
+
error: {
|
|
301
|
+
rule,
|
|
302
|
+
message: textOf(table, `refused.${key}`, args, key),
|
|
303
|
+
nextAction: textOf(table, `next.${key}`, args) || undefined,
|
|
304
|
+
},
|
|
305
|
+
};
|
|
306
|
+
}
|
|
307
|
+
notOwned(request) {
|
|
308
|
+
return this.refusal(request, "tool.not_owned", "unknown_tool", { name: request.name });
|
|
309
|
+
}
|
|
310
|
+
}
|
|
311
|
+
/**
|
|
312
|
+
* Where a path names on this host: itself when it is absolute, under the
|
|
313
|
+
* workspace when it is not. Nothing is refused, because the owner asked for no
|
|
314
|
+
* restriction on where a window reads or writes
|
|
315
|
+
* (docs/workspaces.md#another-windows-directory).
|
|
316
|
+
*/
|
|
317
|
+
function hostPath(root, path) {
|
|
318
|
+
return isAbsolute(path) ? normalize(path) : resolve(root, path);
|
|
319
|
+
}
|
|
320
|
+
/**
|
|
321
|
+
* One command to its end in a mount that owns its execution environment, or
|
|
322
|
+
* the failure it was answered with - so a command the implementation could not
|
|
323
|
+
* run at all is reported the way one that ran and failed is.
|
|
324
|
+
*/
|
|
325
|
+
function runToEnd(exec, command) {
|
|
326
|
+
return exec(command).catch((error) => ({
|
|
327
|
+
exitCode: 1,
|
|
328
|
+
stdout: "",
|
|
329
|
+
stderr: error instanceof Error ? error.message : String(error),
|
|
330
|
+
truncated: false,
|
|
331
|
+
}));
|
|
332
|
+
}
|
|
333
|
+
function stringArg(args, name) {
|
|
334
|
+
const value = args[name];
|
|
335
|
+
return typeof value === "string" ? value : "";
|
|
336
|
+
}
|
|
337
|
+
function numberArg(args, name, fallback) {
|
|
338
|
+
const value = args[name];
|
|
339
|
+
return typeof value === "number" && Number.isFinite(value) ? value : fallback;
|
|
340
|
+
}
|
|
341
|
+
function sleep(ms) {
|
|
342
|
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
343
|
+
}
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The note tools: `note_write`, `note_replace`, `note_delete`
|
|
3
|
+
* (docs/tools.md#direct-tools, docs/memory.md).
|
|
4
|
+
*
|
|
5
|
+
* Writing memory is a decision, so it is a direct call. A write names the
|
|
6
|
+
* revision it read; a stale one is refused with what changed since, and the
|
|
7
|
+
* model merges and writes again. The host records where the write happened -
|
|
8
|
+
* window, event, evidence, range - and the model never fills an anchor in.
|
|
9
|
+
*/
|
|
10
|
+
import type { DirectToolSpec, ToolCall, ToolGroup, ToolResult, WindowRef } from "@4fu/arbor-interfaces";
|
|
11
|
+
import { type ToolServices } from "../host.ts";
|
|
12
|
+
export declare class NoteToolGroup implements ToolGroup {
|
|
13
|
+
readonly id = "notes";
|
|
14
|
+
private readonly services;
|
|
15
|
+
constructor(services: ToolServices);
|
|
16
|
+
tools(): readonly DirectToolSpec[];
|
|
17
|
+
modules(): readonly [];
|
|
18
|
+
call(request: ToolCall, context: {
|
|
19
|
+
window: WindowRef;
|
|
20
|
+
}): Promise<ToolResult>;
|
|
21
|
+
private write;
|
|
22
|
+
private replace;
|
|
23
|
+
private remove;
|
|
24
|
+
/**
|
|
25
|
+
* One write, with the anchor the host fills in. A refused write changes
|
|
26
|
+
* nothing; the answer carries the rule, the next action and the revisions
|
|
27
|
+
* that changed since the one the writer read.
|
|
28
|
+
*/
|
|
29
|
+
private record;
|
|
30
|
+
private answer;
|
|
31
|
+
/** One refusal the note store returned, in the core's error shape. */
|
|
32
|
+
private refusalOf;
|
|
33
|
+
private refusal;
|
|
34
|
+
private absent;
|
|
35
|
+
private notOwned;
|
|
36
|
+
private store;
|
|
37
|
+
}
|
package/groups/notes.js
ADDED
|
@@ -0,0 +1,176 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The note tools: `note_write`, `note_replace`, `note_delete`
|
|
3
|
+
* (docs/tools.md#direct-tools, docs/memory.md).
|
|
4
|
+
*
|
|
5
|
+
* Writing memory is a decision, so it is a direct call. A write names the
|
|
6
|
+
* revision it read; a stale one is refused with what changed since, and the
|
|
7
|
+
* model merges and writes again. The host records where the write happened -
|
|
8
|
+
* window, event, evidence, range - and the model never fills an anchor in.
|
|
9
|
+
*/
|
|
10
|
+
import { textOf } from "@4fu/arbor-prompt";
|
|
11
|
+
import { toolSpec } from "../host.js";
|
|
12
|
+
export class NoteToolGroup {
|
|
13
|
+
id = "notes";
|
|
14
|
+
services;
|
|
15
|
+
constructor(services) {
|
|
16
|
+
this.services = services;
|
|
17
|
+
}
|
|
18
|
+
tools() {
|
|
19
|
+
if (this.services.notes === undefined)
|
|
20
|
+
return [];
|
|
21
|
+
const table = this.services.table;
|
|
22
|
+
return [
|
|
23
|
+
toolSpec(table, "note_write", {
|
|
24
|
+
title: { type: "string", required: true },
|
|
25
|
+
summary: { type: "string" },
|
|
26
|
+
body: { type: "string", required: true },
|
|
27
|
+
}),
|
|
28
|
+
toolSpec(table, "note_replace", {
|
|
29
|
+
id: { type: "string", required: true },
|
|
30
|
+
revision: { type: "number", required: true },
|
|
31
|
+
title: { type: "string" },
|
|
32
|
+
summary: { type: "string" },
|
|
33
|
+
body: { type: "string", required: true },
|
|
34
|
+
}),
|
|
35
|
+
toolSpec(table, "note_delete", {
|
|
36
|
+
id: { type: "string", required: true },
|
|
37
|
+
revision: { type: "number", required: true },
|
|
38
|
+
}),
|
|
39
|
+
];
|
|
40
|
+
}
|
|
41
|
+
modules() {
|
|
42
|
+
return [];
|
|
43
|
+
}
|
|
44
|
+
async call(request, context) {
|
|
45
|
+
const store = this.store();
|
|
46
|
+
if (store === undefined)
|
|
47
|
+
return this.absent(request);
|
|
48
|
+
switch (request.name) {
|
|
49
|
+
case "note_write":
|
|
50
|
+
return this.write(request, store, context.window);
|
|
51
|
+
case "note_replace":
|
|
52
|
+
return this.replace(request, store, context.window);
|
|
53
|
+
case "note_delete":
|
|
54
|
+
return this.remove(request, store, context.window);
|
|
55
|
+
default:
|
|
56
|
+
return this.notOwned(request);
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
async write(request, store, window) {
|
|
60
|
+
const title = stringArg(request.args, "title");
|
|
61
|
+
const body = stringArg(request.args, "body");
|
|
62
|
+
if (title === "" || body === "")
|
|
63
|
+
return this.refusal(request, "note.write.empty");
|
|
64
|
+
return this.record(request, store, window, {
|
|
65
|
+
title,
|
|
66
|
+
body,
|
|
67
|
+
...(stringArg(request.args, "summary") === "" ? {} : { summary: stringArg(request.args, "summary") }),
|
|
68
|
+
});
|
|
69
|
+
}
|
|
70
|
+
async replace(request, store, window) {
|
|
71
|
+
const id = stringArg(request.args, "id");
|
|
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");
|
|
76
|
+
return this.record(request, store, window, {
|
|
77
|
+
id: id,
|
|
78
|
+
title: stringArg(request.args, "title"),
|
|
79
|
+
body,
|
|
80
|
+
replace: true,
|
|
81
|
+
expectedRevision: revision,
|
|
82
|
+
...(stringArg(request.args, "summary") === "" ? {} : { summary: stringArg(request.args, "summary") }),
|
|
83
|
+
});
|
|
84
|
+
}
|
|
85
|
+
async remove(request, store, window) {
|
|
86
|
+
const id = stringArg(request.args, "id");
|
|
87
|
+
if (id === "")
|
|
88
|
+
return this.refusal(request, "note.write.empty");
|
|
89
|
+
return this.record(request, store, window, {
|
|
90
|
+
id: id,
|
|
91
|
+
title: "",
|
|
92
|
+
body: "",
|
|
93
|
+
delete: true,
|
|
94
|
+
expectedRevision: numberArg(request.args, "revision"),
|
|
95
|
+
});
|
|
96
|
+
}
|
|
97
|
+
/**
|
|
98
|
+
* One write, with the anchor the host fills in. A refused write changes
|
|
99
|
+
* nothing; the answer carries the rule, the next action and the revisions
|
|
100
|
+
* that changed since the one the writer read.
|
|
101
|
+
*/
|
|
102
|
+
async record(request, _store, window, input) {
|
|
103
|
+
try {
|
|
104
|
+
const note = await this.services.writeNote(window, input);
|
|
105
|
+
return this.answer(request, note.ref, this.services.table);
|
|
106
|
+
}
|
|
107
|
+
catch (error) {
|
|
108
|
+
return this.refusalOf(request, error);
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
answer(request, ref, table) {
|
|
112
|
+
return { callId: request.callId, ok: true, content: textOf(table, "result.note_written", { ref }) };
|
|
113
|
+
}
|
|
114
|
+
/** One refusal the note store returned, in the core's error shape. */
|
|
115
|
+
refusalOf(request, error) {
|
|
116
|
+
const table = this.services.table;
|
|
117
|
+
const info = error;
|
|
118
|
+
const revisions = info.details?.revisions ?? [];
|
|
119
|
+
const lines = [info.message ?? String(error)];
|
|
120
|
+
if (info.nextAction !== undefined)
|
|
121
|
+
lines.push(`${textOf(table, "error.next", {})}${info.nextAction}`.trim());
|
|
122
|
+
for (const revision of revisions) {
|
|
123
|
+
lines.push(`${revision.ref} ${revision.window ?? ""}`);
|
|
124
|
+
}
|
|
125
|
+
return {
|
|
126
|
+
callId: request.callId,
|
|
127
|
+
ok: false,
|
|
128
|
+
content: "",
|
|
129
|
+
error: {
|
|
130
|
+
rule: info.rule ?? "note.write.failed",
|
|
131
|
+
message: lines.join("\n"),
|
|
132
|
+
...(info.nextAction === undefined ? {} : { nextAction: info.nextAction }),
|
|
133
|
+
},
|
|
134
|
+
};
|
|
135
|
+
}
|
|
136
|
+
refusal(request, rule) {
|
|
137
|
+
const table = this.services.table;
|
|
138
|
+
return {
|
|
139
|
+
callId: request.callId,
|
|
140
|
+
ok: false,
|
|
141
|
+
content: "",
|
|
142
|
+
error: {
|
|
143
|
+
rule,
|
|
144
|
+
message: textOf(table, `refused.${rule}`, {}, rule),
|
|
145
|
+
nextAction: textOf(table, `next.${rule}`) || undefined,
|
|
146
|
+
},
|
|
147
|
+
};
|
|
148
|
+
}
|
|
149
|
+
absent(request) {
|
|
150
|
+
const table = this.services.table;
|
|
151
|
+
return {
|
|
152
|
+
callId: request.callId,
|
|
153
|
+
ok: false,
|
|
154
|
+
content: "",
|
|
155
|
+
error: {
|
|
156
|
+
rule: "notes.absent",
|
|
157
|
+
message: textOf(table, "refused.notes.absent"),
|
|
158
|
+
nextAction: textOf(table, "next.notes.absent"),
|
|
159
|
+
},
|
|
160
|
+
};
|
|
161
|
+
}
|
|
162
|
+
notOwned(request) {
|
|
163
|
+
return this.refusal(request, "unknown_tool");
|
|
164
|
+
}
|
|
165
|
+
store() {
|
|
166
|
+
return this.services.notes;
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
function stringArg(args, name) {
|
|
170
|
+
const value = args[name];
|
|
171
|
+
return typeof value === "string" ? value : "";
|
|
172
|
+
}
|
|
173
|
+
function numberArg(args, name) {
|
|
174
|
+
const value = args[name];
|
|
175
|
+
return typeof value === "number" && Number.isFinite(value) ? value : undefined;
|
|
176
|
+
}
|