@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/code-exec.js
ADDED
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `code_exec`: everything that is not a decision runs from a script
|
|
3
|
+
* (docs/tools.md#rules).
|
|
4
|
+
*
|
|
5
|
+
* A script that names a direct tool is refused with the rule and never reaches
|
|
6
|
+
* the host. Calls from a script go through the same hooks as direct calls, so
|
|
7
|
+
* anchors, watermarks, quotas, validation and logging behave the same: every
|
|
8
|
+
* module function is the same service call the direct tool makes.
|
|
9
|
+
*
|
|
10
|
+
* Two lookups are script globals: `tool_search` takes keywords, an exact
|
|
11
|
+
* `module.function` name, or nothing for every module; `tool_describe` returns
|
|
12
|
+
* one signature. Both read the catalog frozen with the window plus the
|
|
13
|
+
* environment deltas since.
|
|
14
|
+
*/
|
|
15
|
+
import vm from "node:vm";
|
|
16
|
+
import { textOf } from "@4fu/arbor-prompt";
|
|
17
|
+
import { boundResult } from "./bound.js";
|
|
18
|
+
/** How long one script may run before it is stopped. */
|
|
19
|
+
const SCRIPT_TIMEOUT_MS = 60000;
|
|
20
|
+
/** Run one script in its own context, with the modules as globals. */
|
|
21
|
+
export async function runScript(script, catalog, services, context) {
|
|
22
|
+
const table = services.table;
|
|
23
|
+
const named = directToolIn(script, catalog.directTools());
|
|
24
|
+
if (named !== undefined) {
|
|
25
|
+
return {
|
|
26
|
+
callId: "",
|
|
27
|
+
ok: false,
|
|
28
|
+
content: "",
|
|
29
|
+
error: {
|
|
30
|
+
rule: "script.decision_tool",
|
|
31
|
+
message: textOf(table, "refused.decision_in_script", { name: named }),
|
|
32
|
+
nextAction: textOf(table, "next.decision_in_script"),
|
|
33
|
+
},
|
|
34
|
+
};
|
|
35
|
+
}
|
|
36
|
+
const globals = {
|
|
37
|
+
tool_search: (keywords) => catalog.search(keywords),
|
|
38
|
+
tool_describe: (name) => catalog.describe(name),
|
|
39
|
+
console: { log: () => { }, error: () => { } },
|
|
40
|
+
};
|
|
41
|
+
for (const module of catalog.modules()) {
|
|
42
|
+
globals[module.name] = moduleFunctions(module, catalog, context);
|
|
43
|
+
}
|
|
44
|
+
const sandbox = vm.createContext(globals);
|
|
45
|
+
const wrapped = `(async () => {\n${script}\n})()`;
|
|
46
|
+
let value;
|
|
47
|
+
try {
|
|
48
|
+
const compiled = new vm.Script(wrapped, { filename: "code_exec.js" });
|
|
49
|
+
value = await compiled.runInContext(sandbox, { timeout: SCRIPT_TIMEOUT_MS });
|
|
50
|
+
}
|
|
51
|
+
catch (error) {
|
|
52
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
53
|
+
return {
|
|
54
|
+
callId: "",
|
|
55
|
+
ok: false,
|
|
56
|
+
content: "",
|
|
57
|
+
error: {
|
|
58
|
+
rule: "script.failed",
|
|
59
|
+
message: textOf(table, "refused.script_failed", { message }),
|
|
60
|
+
nextAction: textOf(table, "next.script_failed"),
|
|
61
|
+
},
|
|
62
|
+
};
|
|
63
|
+
}
|
|
64
|
+
const text = value === undefined ? "" : typeof value === "string" ? value : JSON.stringify(value);
|
|
65
|
+
return {
|
|
66
|
+
callId: "",
|
|
67
|
+
ok: true,
|
|
68
|
+
content: await boundResult(text ?? "", {
|
|
69
|
+
workspace: context.workspace,
|
|
70
|
+
callId: context.call,
|
|
71
|
+
table,
|
|
72
|
+
}),
|
|
73
|
+
};
|
|
74
|
+
}
|
|
75
|
+
/** One module's functions, as the globals a script calls. */
|
|
76
|
+
function moduleFunctions(module, catalog, context) {
|
|
77
|
+
const functions = {};
|
|
78
|
+
for (const spec of module.functions) {
|
|
79
|
+
functions[spec.name.slice(module.name.length + 1)] = async (args = {}) => {
|
|
80
|
+
const result = await catalog.callModule(spec.name, args, context);
|
|
81
|
+
if (!result.ok)
|
|
82
|
+
throw new Error(result.error?.message ?? result.content);
|
|
83
|
+
return result.content;
|
|
84
|
+
};
|
|
85
|
+
}
|
|
86
|
+
return functions;
|
|
87
|
+
}
|
|
88
|
+
/**
|
|
89
|
+
* The direct tool a script names, if any. This is a guard, not a sandbox: it
|
|
90
|
+
* keeps a decision out of a script, and the host still enforces every rule.
|
|
91
|
+
*/
|
|
92
|
+
export function directToolIn(script, tools) {
|
|
93
|
+
for (const tool of tools) {
|
|
94
|
+
const pattern = new RegExp(`(^|[^\\w.$])${tool.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}\\s*\\(`);
|
|
95
|
+
if (pattern.test(script))
|
|
96
|
+
return tool;
|
|
97
|
+
}
|
|
98
|
+
return undefined;
|
|
99
|
+
}
|
|
100
|
+
export { SCRIPT_TIMEOUT_MS };
|
package/env-stage.d.ts
ADDED
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The environment as a command sees it (docs/environment.md#layout).
|
|
3
|
+
*
|
|
4
|
+
* A window reads the environment at `/env`, and it lives on the host. Where a
|
|
5
|
+
* command runs decides how the prefix reaches it:
|
|
6
|
+
*
|
|
7
|
+
* - A plain-directory workspace runs the command on the host, so the prefix is
|
|
8
|
+
* rewritten onto the directory itself.
|
|
9
|
+
* - A workspace that owns its execution environment runs the command where the
|
|
10
|
+
* host's directory is not visible, so the files are staged into that
|
|
11
|
+
* environment through the mount's own capability and the prefix is rewritten
|
|
12
|
+
* onto where they landed. It is read-only, so a change goes through the
|
|
13
|
+
* environment's own draft and `reload`.
|
|
14
|
+
*
|
|
15
|
+
* Staging is per mount and per revision: a `reload` lands as a new revision,
|
|
16
|
+
* and the next command stages it again before it runs, so a live window always
|
|
17
|
+
* reads what the environment holds now.
|
|
18
|
+
*/
|
|
19
|
+
import type { WorkspaceMount } from "@4fu/arbor-interfaces";
|
|
20
|
+
/** The environment as the file tools read it: the prefix and the host directory. */
|
|
21
|
+
export interface EnvPaths {
|
|
22
|
+
prefix: string;
|
|
23
|
+
directory: string;
|
|
24
|
+
}
|
|
25
|
+
/**
|
|
26
|
+
* One command as the execution environment sees it: the environment staged
|
|
27
|
+
* where the command can read it, and the prefix rewritten onto where it landed.
|
|
28
|
+
*/
|
|
29
|
+
export declare function envCommand(workspace: WorkspaceMount, command: string, env: EnvPaths | undefined, revision: string | undefined): Promise<string>;
|
package/env-stage.js
ADDED
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The environment as a command sees it (docs/environment.md#layout).
|
|
3
|
+
*
|
|
4
|
+
* A window reads the environment at `/env`, and it lives on the host. Where a
|
|
5
|
+
* command runs decides how the prefix reaches it:
|
|
6
|
+
*
|
|
7
|
+
* - A plain-directory workspace runs the command on the host, so the prefix is
|
|
8
|
+
* rewritten onto the directory itself.
|
|
9
|
+
* - A workspace that owns its execution environment runs the command where the
|
|
10
|
+
* host's directory is not visible, so the files are staged into that
|
|
11
|
+
* environment through the mount's own capability and the prefix is rewritten
|
|
12
|
+
* onto where they landed. It is read-only, so a change goes through the
|
|
13
|
+
* environment's own draft and `reload`.
|
|
14
|
+
*
|
|
15
|
+
* Staging is per mount and per revision: a `reload` lands as a new revision,
|
|
16
|
+
* and the next command stages it again before it runs, so a live window always
|
|
17
|
+
* reads what the environment holds now.
|
|
18
|
+
*/
|
|
19
|
+
import { readFile, readdir } from "node:fs/promises";
|
|
20
|
+
import { join, relative } from "node:path";
|
|
21
|
+
/** What each mount has staged, and for which revision of the environment. */
|
|
22
|
+
const staged = new WeakMap();
|
|
23
|
+
/**
|
|
24
|
+
* One command as the execution environment sees it: the environment staged
|
|
25
|
+
* where the command can read it, and the prefix rewritten onto where it landed.
|
|
26
|
+
*/
|
|
27
|
+
export async function envCommand(workspace, command, env, revision) {
|
|
28
|
+
if (env === undefined || !command.includes(env.prefix))
|
|
29
|
+
return command;
|
|
30
|
+
const stage = workspace.stage;
|
|
31
|
+
// A mount whose `path` is a host path already reaches the directory.
|
|
32
|
+
if (stage === undefined)
|
|
33
|
+
return command.split(env.prefix).join(env.directory);
|
|
34
|
+
const previous = staged.get(workspace);
|
|
35
|
+
// No revision is no cache: a command then always reads what the environment
|
|
36
|
+
// holds, rather than what was staged for a revision nobody named.
|
|
37
|
+
const at = revision !== undefined && previous?.revision === revision
|
|
38
|
+
? previous.at
|
|
39
|
+
: await stage(env.prefix, await envFiles(env.directory));
|
|
40
|
+
staged.set(workspace, { revision, at });
|
|
41
|
+
return command.split(env.prefix).join(at);
|
|
42
|
+
}
|
|
43
|
+
/** Every file under the environment's directory, as the text a command reads. */
|
|
44
|
+
async function envFiles(directory) {
|
|
45
|
+
const files = [];
|
|
46
|
+
const walk = async (at) => {
|
|
47
|
+
for (const entry of await readdir(at, { withFileTypes: true }).catch(() => [])) {
|
|
48
|
+
const path = join(at, entry.name);
|
|
49
|
+
if (entry.isDirectory()) {
|
|
50
|
+
await walk(path);
|
|
51
|
+
continue;
|
|
52
|
+
}
|
|
53
|
+
if (!entry.isFile())
|
|
54
|
+
continue;
|
|
55
|
+
const text = await readFile(path, "utf8").catch(() => null);
|
|
56
|
+
if (text === null)
|
|
57
|
+
continue;
|
|
58
|
+
files.push({ path: relative(directory, path), text });
|
|
59
|
+
}
|
|
60
|
+
};
|
|
61
|
+
await walk(directory);
|
|
62
|
+
return files;
|
|
63
|
+
}
|
package/groups/core.d.ts
ADDED
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The decision tools: `send`, `end` and `search`
|
|
3
|
+
* (docs/tools.md#direct-tools, docs/prompting.md#rules-surface-where-they-bite).
|
|
4
|
+
*
|
|
5
|
+
* Anything that speaks, ends a window or writes memory is a direct call. The
|
|
6
|
+
* one guard rule stated up front is `end` with unfinished work, because the
|
|
7
|
+
* handoff is the cost of the decision to end; every other rule lives in the
|
|
8
|
+
* refusal the model meets.
|
|
9
|
+
*/
|
|
10
|
+
import type { CallContext, DirectToolSpec, Handoff, ToolCall, ToolGroup, ToolResult } from "@4fu/arbor-interfaces";
|
|
11
|
+
import type { ToolServices } from "../host.ts";
|
|
12
|
+
export declare class CoreToolGroup implements ToolGroup {
|
|
13
|
+
readonly id = "core";
|
|
14
|
+
private readonly services;
|
|
15
|
+
constructor(services: ToolServices);
|
|
16
|
+
tools(): readonly DirectToolSpec[];
|
|
17
|
+
modules(): readonly [];
|
|
18
|
+
call(request: ToolCall, context: CallContext): Promise<ToolResult>;
|
|
19
|
+
private send;
|
|
20
|
+
/**
|
|
21
|
+
* `end` with unfinished work needs a handoff: current topics, progress,
|
|
22
|
+
* pending items and agreements. The host knows whether work is open; the
|
|
23
|
+
* tool does not guess.
|
|
24
|
+
*/
|
|
25
|
+
private end;
|
|
26
|
+
private search;
|
|
27
|
+
private notOwned;
|
|
28
|
+
}
|
|
29
|
+
/** The four parts of a handoff, each with its refs. */
|
|
30
|
+
export declare function handoffOf(value: unknown): Handoff | undefined;
|
package/groups/core.js
ADDED
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The decision tools: `send`, `end` and `search`
|
|
3
|
+
* (docs/tools.md#direct-tools, docs/prompting.md#rules-surface-where-they-bite).
|
|
4
|
+
*
|
|
5
|
+
* Anything that speaks, ends a window or writes memory is a direct call. The
|
|
6
|
+
* one guard rule stated up front is `end` with unfinished work, because the
|
|
7
|
+
* handoff is the cost of the decision to end; every other rule lives in the
|
|
8
|
+
* refusal the model meets.
|
|
9
|
+
*/
|
|
10
|
+
import { textOf } from "@4fu/arbor-prompt";
|
|
11
|
+
import { refused, toolSpec } from "../host.js";
|
|
12
|
+
export class CoreToolGroup {
|
|
13
|
+
id = "core";
|
|
14
|
+
services;
|
|
15
|
+
constructor(services) {
|
|
16
|
+
this.services = services;
|
|
17
|
+
}
|
|
18
|
+
tools() {
|
|
19
|
+
const table = this.services.table;
|
|
20
|
+
return [
|
|
21
|
+
toolSpec(table, "send", {
|
|
22
|
+
to: { type: "string", required: true },
|
|
23
|
+
content: { type: "string", required: true },
|
|
24
|
+
reply_to: { type: "ref" },
|
|
25
|
+
cites: { type: "refs" },
|
|
26
|
+
}),
|
|
27
|
+
toolSpec(table, "end", { handoff: { type: "object" } }),
|
|
28
|
+
toolSpec(table, "search", {
|
|
29
|
+
query: { type: "string", required: true },
|
|
30
|
+
scope: { type: "string" },
|
|
31
|
+
}),
|
|
32
|
+
];
|
|
33
|
+
}
|
|
34
|
+
modules() {
|
|
35
|
+
return [];
|
|
36
|
+
}
|
|
37
|
+
async call(request, context) {
|
|
38
|
+
switch (request.name) {
|
|
39
|
+
case "send":
|
|
40
|
+
return this.send(request, context.window);
|
|
41
|
+
case "end":
|
|
42
|
+
return this.end(request, context.window);
|
|
43
|
+
case "search":
|
|
44
|
+
return this.search(request, context.window);
|
|
45
|
+
default:
|
|
46
|
+
return this.notOwned(request);
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
async send(request, window) {
|
|
50
|
+
const table = this.services.table;
|
|
51
|
+
const to = stringArg(request.args, "to");
|
|
52
|
+
const content = stringArg(request.args, "content");
|
|
53
|
+
if (to === "")
|
|
54
|
+
return refused("send.target.empty", table, "send.target");
|
|
55
|
+
if (content === "")
|
|
56
|
+
return refused("send.content.empty", table, "send.content");
|
|
57
|
+
const outcome = await this.services.send(window, {
|
|
58
|
+
to,
|
|
59
|
+
content,
|
|
60
|
+
...(stringArg(request.args, "reply_to") === "" ? {} : { replyTo: stringArg(request.args, "reply_to") }),
|
|
61
|
+
...(refsArg(request.args, "cites").length === 0 ? {} : { cites: refsArg(request.args, "cites") }),
|
|
62
|
+
});
|
|
63
|
+
const lines = [textOf(table, "result.sent", { ref: outcome.event })];
|
|
64
|
+
if (outcome.behind > 0) {
|
|
65
|
+
lines.push(textOf(table, "result.send.behind", { count: outcome.behind }));
|
|
66
|
+
}
|
|
67
|
+
return { callId: request.callId, ok: true, content: lines.join("\n") };
|
|
68
|
+
}
|
|
69
|
+
/**
|
|
70
|
+
* `end` with unfinished work needs a handoff: current topics, progress,
|
|
71
|
+
* pending items and agreements. The host knows whether work is open; the
|
|
72
|
+
* tool does not guess.
|
|
73
|
+
*/
|
|
74
|
+
async end(request, window) {
|
|
75
|
+
const table = this.services.table;
|
|
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
|
+
const result = await this.services.endWindow(window, handoff);
|
|
81
|
+
return {
|
|
82
|
+
...result,
|
|
83
|
+
callId: request.callId,
|
|
84
|
+
content: textOf(table, "result.ended", { window }),
|
|
85
|
+
};
|
|
86
|
+
}
|
|
87
|
+
async search(request, window) {
|
|
88
|
+
const table = this.services.table;
|
|
89
|
+
const query = stringArg(request.args, "query");
|
|
90
|
+
const scope = stringArg(request.args, "scope");
|
|
91
|
+
const outcome = await this.services.search(window, query, scope === "" ? undefined : scope.split(",").map((part) => part.trim()));
|
|
92
|
+
const lines = [];
|
|
93
|
+
for (const hit of outcome.hits) {
|
|
94
|
+
lines.push(`${hit.ref} ${hit.kind} ${hit.summary}`);
|
|
95
|
+
}
|
|
96
|
+
for (const note of outcome.notes) {
|
|
97
|
+
lines.push(`${note.ref} ${note.title}:${note.summary}`);
|
|
98
|
+
}
|
|
99
|
+
for (const live of outcome.windows) {
|
|
100
|
+
lines.push(`${live.window} ${live.doing}`);
|
|
101
|
+
}
|
|
102
|
+
const body = lines.length === 0 ? textOf(table, "result.search.empty") : lines.join("\n");
|
|
103
|
+
const total = textOf(table, "result.search.total", { total: outcome.total });
|
|
104
|
+
return { callId: request.callId, ok: true, content: `${body}\n${total}\n${outcome.nextCall}` };
|
|
105
|
+
}
|
|
106
|
+
notOwned(request) {
|
|
107
|
+
return {
|
|
108
|
+
callId: request.callId,
|
|
109
|
+
ok: false,
|
|
110
|
+
content: "",
|
|
111
|
+
error: {
|
|
112
|
+
rule: "tool.not_owned",
|
|
113
|
+
message: textOf(this.services.table, "refused.unknown_tool", { name: request.name }),
|
|
114
|
+
nextAction: textOf(this.services.table, "next.unknown_tool"),
|
|
115
|
+
},
|
|
116
|
+
};
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
/** The four parts of a handoff, each with its refs. */
|
|
120
|
+
export function handoffOf(value) {
|
|
121
|
+
if (typeof value !== "object" || value === null)
|
|
122
|
+
return undefined;
|
|
123
|
+
const raw = value;
|
|
124
|
+
const parts = (key) => Array.isArray(raw[key]) ? raw[key].filter((item) => typeof item === "string") : [];
|
|
125
|
+
const refs = Array.isArray(raw.refs) ? raw.refs.filter((item) => typeof item === "string") : [];
|
|
126
|
+
const handoff = {
|
|
127
|
+
topics: parts("topics"),
|
|
128
|
+
progress: parts("progress"),
|
|
129
|
+
pending: parts("pending"),
|
|
130
|
+
agreements: parts("agreements"),
|
|
131
|
+
refs,
|
|
132
|
+
};
|
|
133
|
+
const empty = handoff.topics.length === 0 &&
|
|
134
|
+
handoff.progress.length === 0 &&
|
|
135
|
+
handoff.pending.length === 0 &&
|
|
136
|
+
handoff.agreements.length === 0;
|
|
137
|
+
return empty ? undefined : handoff;
|
|
138
|
+
}
|
|
139
|
+
function stringArg(args, name) {
|
|
140
|
+
const value = args[name];
|
|
141
|
+
return typeof value === "string" ? value : "";
|
|
142
|
+
}
|
|
143
|
+
function refsArg(args, name) {
|
|
144
|
+
const value = args[name];
|
|
145
|
+
if (!Array.isArray(value))
|
|
146
|
+
return [];
|
|
147
|
+
return value.filter((item) => typeof item === "string");
|
|
148
|
+
}
|
package/groups/env.d.ts
ADDED
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The environment tools: `reload` (docs/tools.md#direct-tools,
|
|
3
|
+
* docs/environment.md#changing-the-environment).
|
|
4
|
+
*
|
|
5
|
+
* Changing the environment is a decision, so it is one direct call. The three
|
|
6
|
+
* steps a model takes - copy, edit, `reload` - are the model's; the host's are
|
|
7
|
+
* validation, the revision check, replacing the item as a whole, recording an
|
|
8
|
+
* environment revision and broadcasting the delta to every live window.
|
|
9
|
+
*
|
|
10
|
+
* Every rule lives in the refusal the model meets, and every refusal names the
|
|
11
|
+
* section of the built-in skill that covers it, so the loop closes without a
|
|
12
|
+
* rule written into the prompt.
|
|
13
|
+
*/
|
|
14
|
+
import type { CallContext, DirectToolSpec, ToolCall, ToolGroup, ToolResult } from "@4fu/arbor-interfaces";
|
|
15
|
+
import type { ToolServices } from "../host.ts";
|
|
16
|
+
export declare class EnvToolGroup implements ToolGroup {
|
|
17
|
+
readonly id = "env";
|
|
18
|
+
private readonly services;
|
|
19
|
+
constructor(services: ToolServices);
|
|
20
|
+
tools(): readonly DirectToolSpec[];
|
|
21
|
+
modules(): readonly [];
|
|
22
|
+
call(request: ToolCall, context: CallContext): Promise<ToolResult>;
|
|
23
|
+
/**
|
|
24
|
+
* `reload(install?, remove?)`. `install` names drafts in the window's
|
|
25
|
+
* workspace; `remove` names items. Both are optional, but not both empty.
|
|
26
|
+
*/
|
|
27
|
+
private reload;
|
|
28
|
+
/**
|
|
29
|
+
* One draft: its kind is read off the workspace - a directory with a SKILL.md
|
|
30
|
+
* is a skill, one with a `svc.json` a service, a `.json` file an MCP server
|
|
31
|
+
* or the settings - and its name is the directory's or the file's.
|
|
32
|
+
*/
|
|
33
|
+
private draftOf;
|
|
34
|
+
/** One file of a draft, through the mount's own capability or the host path. */
|
|
35
|
+
private readDraft;
|
|
36
|
+
/** One refusal about the call itself, before the service layer saw it. */
|
|
37
|
+
private refusal;
|
|
38
|
+
/**
|
|
39
|
+
* One refusal the service layer returned, in the core's error shape. It names
|
|
40
|
+
* the rule and the section of the built-in skill that covers it.
|
|
41
|
+
*/
|
|
42
|
+
private refusalOf;
|
|
43
|
+
private absent;
|
|
44
|
+
private notOwned;
|
|
45
|
+
}
|
package/groups/env.js
ADDED
|
@@ -0,0 +1,190 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The environment tools: `reload` (docs/tools.md#direct-tools,
|
|
3
|
+
* docs/environment.md#changing-the-environment).
|
|
4
|
+
*
|
|
5
|
+
* Changing the environment is a decision, so it is one direct call. The three
|
|
6
|
+
* steps a model takes - copy, edit, `reload` - are the model's; the host's are
|
|
7
|
+
* validation, the revision check, replacing the item as a whole, recording an
|
|
8
|
+
* environment revision and broadcasting the delta to every live window.
|
|
9
|
+
*
|
|
10
|
+
* Every rule lives in the refusal the model meets, and every refusal names the
|
|
11
|
+
* section of the built-in skill that covers it, so the loop closes without a
|
|
12
|
+
* rule written into the prompt.
|
|
13
|
+
*/
|
|
14
|
+
import { readFile } from "node:fs/promises";
|
|
15
|
+
import { join } from "node:path";
|
|
16
|
+
import { textOf } from "@4fu/arbor-prompt";
|
|
17
|
+
import { refused, toolSpec } from "../host.js";
|
|
18
|
+
export class EnvToolGroup {
|
|
19
|
+
id = "env";
|
|
20
|
+
services;
|
|
21
|
+
constructor(services) {
|
|
22
|
+
this.services = services;
|
|
23
|
+
}
|
|
24
|
+
tools() {
|
|
25
|
+
if (this.services.environment === undefined)
|
|
26
|
+
return [];
|
|
27
|
+
const table = this.services.table;
|
|
28
|
+
return [
|
|
29
|
+
toolSpec(table, "reload", {
|
|
30
|
+
install: { type: "array" },
|
|
31
|
+
remove: { type: "array" },
|
|
32
|
+
}),
|
|
33
|
+
];
|
|
34
|
+
}
|
|
35
|
+
modules() {
|
|
36
|
+
return [];
|
|
37
|
+
}
|
|
38
|
+
async call(request, context) {
|
|
39
|
+
if (this.services.environment === undefined)
|
|
40
|
+
return this.absent();
|
|
41
|
+
switch (request.name) {
|
|
42
|
+
case "reload":
|
|
43
|
+
return this.reload(request, context.window, context.workspace);
|
|
44
|
+
default:
|
|
45
|
+
return this.notOwned(request);
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
/**
|
|
49
|
+
* `reload(install?, remove?)`. `install` names drafts in the window's
|
|
50
|
+
* workspace; `remove` names items. Both are optional, but not both empty.
|
|
51
|
+
*/
|
|
52
|
+
async reload(request, window, workspace) {
|
|
53
|
+
const table = this.services.table;
|
|
54
|
+
const install = listArg(request.args, "install");
|
|
55
|
+
const remove = stringsArg(request.args, "remove");
|
|
56
|
+
if (install.length === 0 && remove.length === 0)
|
|
57
|
+
return this.refusal(request, "empty");
|
|
58
|
+
if (workspace === undefined && install.length > 0) {
|
|
59
|
+
return this.refusal(request, "workspace");
|
|
60
|
+
}
|
|
61
|
+
const drafts = [];
|
|
62
|
+
for (const entry of install) {
|
|
63
|
+
const draft = await this.draftOf(entry, workspace);
|
|
64
|
+
if (typeof draft === "string")
|
|
65
|
+
return this.refusal(request, "draft", { path: pathOf(entry) });
|
|
66
|
+
drafts.push(draft.draft);
|
|
67
|
+
}
|
|
68
|
+
let outcome;
|
|
69
|
+
try {
|
|
70
|
+
outcome = await this.services.reload(window, { drafts, remove: remove });
|
|
71
|
+
}
|
|
72
|
+
catch (error) {
|
|
73
|
+
return this.refusalOf(request, error);
|
|
74
|
+
}
|
|
75
|
+
const lines = [textOf(table, "result.env.revision", { revision: outcome.revision })];
|
|
76
|
+
for (const change of outcome.changes) {
|
|
77
|
+
lines.push(textOf(table, "result.env.change", {
|
|
78
|
+
change: change.change === "added" ? "+" : change.change === "removed" ? "-" : "~",
|
|
79
|
+
kind: change.item.kind,
|
|
80
|
+
name: change.item.name,
|
|
81
|
+
description: change.item.description,
|
|
82
|
+
}));
|
|
83
|
+
}
|
|
84
|
+
if (outcome.delivered > 0) {
|
|
85
|
+
lines.push(textOf(table, "result.env.delivered", { count: outcome.delivered }));
|
|
86
|
+
}
|
|
87
|
+
return { callId: request.callId, ok: true, content: lines.join("\n") };
|
|
88
|
+
}
|
|
89
|
+
/**
|
|
90
|
+
* One draft: its kind is read off the workspace - a directory with a SKILL.md
|
|
91
|
+
* is a skill, one with a `svc.json` a service, a `.json` file an MCP server
|
|
92
|
+
* or the settings - and its name is the directory's or the file's.
|
|
93
|
+
*/
|
|
94
|
+
async draftOf(entry, workspace) {
|
|
95
|
+
const path = typeof entry === "string" ? entry : stringField(entry, "path");
|
|
96
|
+
if (path === "")
|
|
97
|
+
return "env.empty";
|
|
98
|
+
const copiedFrom = typeof entry === "string" ? undefined : numberField(entry, "copiedFrom");
|
|
99
|
+
const name = baseName(path);
|
|
100
|
+
const draft = { name, path, ...(copiedFrom === undefined ? {} : { copiedFrom }) };
|
|
101
|
+
// The kind is read off the workspace: a directory with a SKILL.md is a skill,
|
|
102
|
+
// one with a `svc.json` a service, one with `<name>.json` an MCP server that
|
|
103
|
+
// brought its own code, and a `.json` file an MCP server or the settings.
|
|
104
|
+
const skill = await this.readDraft(workspace, join(path, "SKILL.md"));
|
|
105
|
+
if (skill !== null)
|
|
106
|
+
return { draft: { ...draft, kind: "skill" } };
|
|
107
|
+
const service = await this.readDraft(workspace, join(path, "svc.json"));
|
|
108
|
+
if (service !== null)
|
|
109
|
+
return { draft: { ...draft, kind: "service" } };
|
|
110
|
+
if (path.endsWith(".json")) {
|
|
111
|
+
if (name === "settings" && (await this.readDraft(workspace, path)) === null)
|
|
112
|
+
return "env.draft";
|
|
113
|
+
const kind = name === "settings" ? "settings" : "mcp";
|
|
114
|
+
return { draft: { ...draft, kind } };
|
|
115
|
+
}
|
|
116
|
+
const server = await this.readDraft(workspace, join(path, `${name}.json`));
|
|
117
|
+
if (server !== null)
|
|
118
|
+
return { draft: { ...draft, kind: "mcp" } };
|
|
119
|
+
return "env.draft";
|
|
120
|
+
}
|
|
121
|
+
/** One file of a draft, through the mount's own capability or the host path. */
|
|
122
|
+
async readDraft(workspace, path) {
|
|
123
|
+
if (workspace.readFile !== undefined)
|
|
124
|
+
return workspace.readFile(path).catch(() => null);
|
|
125
|
+
return readFile(join(workspace.path, path), "utf8").catch(() => null);
|
|
126
|
+
}
|
|
127
|
+
/** One refusal about the call itself, before the service layer saw it. */
|
|
128
|
+
refusal(_request, key, args = {}) {
|
|
129
|
+
return refused(`env.${key}`, this.services.table, `env.${key}`, args);
|
|
130
|
+
}
|
|
131
|
+
/**
|
|
132
|
+
* One refusal the service layer returned, in the core's error shape. It names
|
|
133
|
+
* the rule and the section of the built-in skill that covers it.
|
|
134
|
+
*/
|
|
135
|
+
refusalOf(request, error) {
|
|
136
|
+
const info = error;
|
|
137
|
+
const next = info.nextAction ?? textOf(this.services.table, "next.env.draft");
|
|
138
|
+
return {
|
|
139
|
+
callId: request.callId,
|
|
140
|
+
ok: false,
|
|
141
|
+
content: "",
|
|
142
|
+
error: {
|
|
143
|
+
rule: info.rule ?? "env.draft",
|
|
144
|
+
message: info.message ?? String(error),
|
|
145
|
+
nextAction: `${next}`,
|
|
146
|
+
},
|
|
147
|
+
};
|
|
148
|
+
}
|
|
149
|
+
absent() {
|
|
150
|
+
return refused("env.absent", this.services.table, "env.absent");
|
|
151
|
+
}
|
|
152
|
+
notOwned(request) {
|
|
153
|
+
return refused("tool.not_owned", this.services.table, "unknown_tool", { name: request.name });
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
/** The last segment of a draft's path, which is the item's name. */
|
|
157
|
+
function baseName(path) {
|
|
158
|
+
const clean = path.replace(/\/+$/, "");
|
|
159
|
+
const name = clean.slice(clean.lastIndexOf("/") + 1);
|
|
160
|
+
return name.endsWith(".json") ? name.slice(0, -".json".length) : name;
|
|
161
|
+
}
|
|
162
|
+
/** What the model gave for one draft, as a path. */
|
|
163
|
+
function pathOf(entry) {
|
|
164
|
+
if (typeof entry === "string")
|
|
165
|
+
return entry;
|
|
166
|
+
return stringField(entry, "path");
|
|
167
|
+
}
|
|
168
|
+
function listArg(args, name) {
|
|
169
|
+
const value = args[name];
|
|
170
|
+
return Array.isArray(value) ? value : value === undefined ? [] : [value];
|
|
171
|
+
}
|
|
172
|
+
/** One list of strings, as `remove` names items. */
|
|
173
|
+
function stringsArg(args, name) {
|
|
174
|
+
const value = args[name];
|
|
175
|
+
if (!Array.isArray(value))
|
|
176
|
+
return value === undefined ? [] : [String(value)];
|
|
177
|
+
return value.filter((item) => typeof item === "string");
|
|
178
|
+
}
|
|
179
|
+
function stringField(value, key) {
|
|
180
|
+
if (typeof value !== "object" || value === null)
|
|
181
|
+
return "";
|
|
182
|
+
const found = value[key];
|
|
183
|
+
return typeof found === "string" ? found : "";
|
|
184
|
+
}
|
|
185
|
+
function numberField(value, key) {
|
|
186
|
+
if (typeof value !== "object" || value === null)
|
|
187
|
+
return undefined;
|
|
188
|
+
const found = value[key];
|
|
189
|
+
return typeof found === "number" && Number.isFinite(found) ? found : undefined;
|
|
190
|
+
}
|
|
@@ -0,0 +1,63 @@
|
|
|
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 type { CallContext, DirectToolSpec, ToolCall, ToolGroup, ToolResult } from "@4fu/arbor-interfaces";
|
|
20
|
+
export declare class FileToolGroup implements ToolGroup {
|
|
21
|
+
readonly id = "files";
|
|
22
|
+
private readonly services;
|
|
23
|
+
private readonly jobs;
|
|
24
|
+
private nextJob;
|
|
25
|
+
constructor(services: import("../host.ts").ToolServices);
|
|
26
|
+
tools(): readonly DirectToolSpec[];
|
|
27
|
+
modules(): readonly [];
|
|
28
|
+
call(request: ToolCall, context: CallContext): Promise<ToolResult>;
|
|
29
|
+
/** The window's own directory, created on first use. */
|
|
30
|
+
private mount;
|
|
31
|
+
/**
|
|
32
|
+
* The environment, at the path a window reads it at. It is mounted read-only,
|
|
33
|
+
* so a window without a workspace still reads `/env/skills/<name>/SKILL.md`
|
|
34
|
+
* (docs/environment.md#layout), and a write to it is refused.
|
|
35
|
+
*/
|
|
36
|
+
private envPath;
|
|
37
|
+
/** Record which revision of an item a window read, for `reload`'s check. */
|
|
38
|
+
private noteEnvRead;
|
|
39
|
+
private bash;
|
|
40
|
+
/** One command that ran to its end: what it wrote, and whether it succeeded. */
|
|
41
|
+
private ran;
|
|
42
|
+
private bashOutput;
|
|
43
|
+
private bashStop;
|
|
44
|
+
private read;
|
|
45
|
+
private write;
|
|
46
|
+
private edit;
|
|
47
|
+
/**
|
|
48
|
+
* One file's text: the mount's own capability when it has one, the host path
|
|
49
|
+
* when it does not. `null` means the file is not there.
|
|
50
|
+
*/
|
|
51
|
+
private readText;
|
|
52
|
+
/** Write one file, creating its directory. */
|
|
53
|
+
private writeText;
|
|
54
|
+
private start;
|
|
55
|
+
private absent;
|
|
56
|
+
private jobMissing;
|
|
57
|
+
private missing;
|
|
58
|
+
private noMatch;
|
|
59
|
+
/** The environment is mounted read-only, so nothing there is written. */
|
|
60
|
+
private readonly;
|
|
61
|
+
private refusal;
|
|
62
|
+
private notOwned;
|
|
63
|
+
}
|