@4fu/arbor-tools 2026.1106.0 → 2026.1107.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/README.md +14 -6
- package/catalog.d.ts +90 -12
- package/catalog.js +194 -33
- 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 +27 -0
- package/groups/external.js +51 -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/README.md
CHANGED
|
@@ -27,9 +27,11 @@ everything that takes judgment, such as git, merges and conflicts, to the agent.
|
|
|
27
27
|
guarantees what an agent cannot guarantee for itself - the record, isolation,
|
|
28
28
|
exactly-once effects, the checks, dispatch. Everything that takes judgment,
|
|
29
29
|
such as git, merges and conflicts, is the agent's.
|
|
30
|
-
- **A
|
|
31
|
-
|
|
32
|
-
|
|
30
|
+
- **A shared workspace with per-window checkouts.** The default workspace is
|
|
31
|
+
ovnfs: one shared folder every window reads, and a window opens its own
|
|
32
|
+
copy-on-write checkout of the project it needs, works there and merges back
|
|
33
|
+
with `apply`. It falls back to a plain directory per window when ovnfs is not
|
|
34
|
+
installed. It is not a sandbox: ovnfs isolates files, not processes.
|
|
33
35
|
- **A self-changing environment.** The model adds and changes skills, MCP
|
|
34
36
|
servers and resident services itself; a `reload` validates the change and
|
|
35
37
|
broadcasts it to every live window as a delta of names and descriptions.
|
|
@@ -54,7 +56,9 @@ npm install @4fu/arbor-core @4fu/arbor-journal-sqlite @4fu/arbor-notes-sqlite \
|
|
|
54
56
|
```
|
|
55
57
|
|
|
56
58
|
`@4fu/arbor-plugin-http` is optional; it is the example plugin below.
|
|
57
|
-
`@4fu/arbor-segmenter-jieba` is an optional peer of the
|
|
59
|
+
`@4fu/arbor-segmenter-jieba` is an optional peer of the lexical package, and
|
|
60
|
+
`@4fu/arbor-search-zvec` an optional peer of the journal: absent either one, the
|
|
61
|
+
journal searches lexically and nothing else changes.
|
|
58
62
|
|
|
59
63
|
## A minimal host
|
|
60
64
|
|
|
@@ -118,7 +122,10 @@ window gets, and how a continuation keeps working in it, are in
|
|
|
118
122
|
| [`@4fu/arbor-journal-sqlite`](packages/journal-sqlite) | the default `Journal` and `BlobStore` on SQLite |
|
|
119
123
|
| [`@4fu/arbor-notes-sqlite`](packages/notes-sqlite) | the default `NoteStore` on SQLite, beside the journal |
|
|
120
124
|
| [`@4fu/arbor-segmenter-jieba`](packages/segmenter-jieba) | the default `Segmenter`: jieba's dictionary segmentation for the word index, optional |
|
|
121
|
-
| [`@4fu/arbor-
|
|
125
|
+
| [`@4fu/arbor-lexical`](packages/lexical) | the lexical search the journal and notes share: word segmentation, the FTS5 helpers and the merge order |
|
|
126
|
+
| [`@4fu/arbor-search-zvec`](packages/search-zvec) | the optional semantic channel: event text embedded and searched by meaning with zvec |
|
|
127
|
+
| [`@4fu/arbor-workspace-dir`](packages/workspace-dir) | the fallback `Workspace`: one directory per window, with a continuation working in its predecessor's directory in place |
|
|
128
|
+
| [`@4fu/arbor-workspace-ovnfs`](packages/workspace-ovnfs) | the default `Workspace` when ovnfs is installed: one shared folder, per-window copy-on-write checkouts the model opens and applies back |
|
|
122
129
|
| [`@4fu/arbor-environment-dir`](packages/environment-dir) | the default `Environment`: one `/env` directory, a revision per item, validation and deltas |
|
|
123
130
|
| [`@4fu/arbor-supervisor-process`](packages/supervisor-process) | the default `Supervisor`: one host process per service, restart with backoff, logs |
|
|
124
131
|
| [`@4fu/arbor-judge-jev`](packages/judge-jev) | the default `Judge`: Jev, TypeSafe AI's System One classifier, answering the dispatch decisions |
|
|
@@ -151,7 +158,7 @@ deployment can see what it would be taking on before it adopts one.
|
|
|
151
158
|
| [docs/coordination.md](docs/coordination.md) | the board, expectations, deliveries and broadcasts, staying current, effects |
|
|
152
159
|
| [docs/environment.md](docs/environment.md) | skills, MCP servers and services: using, changing, reload, built-in skills, deltas |
|
|
153
160
|
| [docs/memory.md](docs/memory.md) | notes, the active set, anchors, links |
|
|
154
|
-
| [docs/workspaces.md](docs/workspaces.md) | per-window
|
|
161
|
+
| [docs/workspaces.md](docs/workspaces.md) | the shared folder and per-window checkouts: isolation, continuity, what the agent does |
|
|
155
162
|
| [docs/tools.md](docs/tools.md) | direct tools and code mode modules |
|
|
156
163
|
| [docs/prompting.md](docs/prompting.md) | how every model-facing text is written: layers, notes, deliveries, errors, judge questions, terms |
|
|
157
164
|
| [docs/release.md](docs/release.md) | calendar versioning, the manual release steps, how a package is published |
|
|
@@ -168,6 +175,7 @@ tarball would contain ([docs/release.md](docs/release.md)).
|
|
|
168
175
|
| `packages/errors` | the error shape every refusal uses: the rule and the next action |
|
|
169
176
|
| `packages/journal-sqlite` | the default `Journal` and `BlobStore` on SQLite |
|
|
170
177
|
| `packages/segmenter-jieba` | the default `Segmenter`: jieba's dictionary segmentation for the word index, optional |
|
|
178
|
+
| `packages/lexical` | the lexical search the journal and notes share: word segmentation, the FTS5 helpers and the merge order |
|
|
171
179
|
| `packages/notes-sqlite` | the default `NoteStore` on SQLite, beside the journal |
|
|
172
180
|
| `packages/judge-jev` | the default `Judge`: Jev, TypeSafe AI's System One classifier, answering the dispatch decisions |
|
|
173
181
|
| `packages/workspace-dir` | the default `Workspace`: one directory per window, with a continuation working in its predecessor's directory in place, and the lookup of another window's directory it contributes to the catalog |
|
package/catalog.d.ts
CHANGED
|
@@ -5,9 +5,15 @@
|
|
|
5
5
|
* A direct tool and a code mode function are thin wrappers of one service call:
|
|
6
6
|
* same arguments, same behaviour, same errors. `tool_search` and `tool_describe`
|
|
7
7
|
* read this catalog, so the lookup and the prompt never disagree. `code_exec`
|
|
8
|
-
* runs a script against it and refuses a script that names
|
|
8
|
+
* runs a script against it and refuses a script that names one of arbor's own
|
|
9
|
+
* direct tools.
|
|
10
|
+
*
|
|
11
|
+
* A tool declared from outside the core - a plugin's own, or a backend's
|
|
12
|
+
* injection of an outside harness's - is registered here too, and from that
|
|
13
|
+
* moment it is an ordinary direct tool: same declaration, same routing, same
|
|
14
|
+
* result cut (docs/tools.md#external-tools).
|
|
9
15
|
*/
|
|
10
|
-
import type { CallContext, DirectToolSpec, EnvItem, FunctionSpec, ModuleSpec, ToolCall, ToolGroup, ToolResult } from "@4fu/arbor-interfaces";
|
|
16
|
+
import type { CallContext, DirectToolSpec, EnvItem, ExternalTool, FunctionSpec, ModuleSpec, ToolCall, ToolGroup, ToolResult } from "@4fu/arbor-interfaces";
|
|
11
17
|
import { type CodeExecContext, type ScriptCatalog } from "./code-exec.ts";
|
|
12
18
|
import type { ToolServices } from "./host.ts";
|
|
13
19
|
export interface ToolCatalogOptions {
|
|
@@ -15,13 +21,70 @@ export interface ToolCatalogOptions {
|
|
|
15
21
|
/** Extra groups, from a plugin. */
|
|
16
22
|
groups?: readonly ToolGroup[];
|
|
17
23
|
}
|
|
24
|
+
/**
|
|
25
|
+
* One external tool the catalog did not register, because its name was already
|
|
26
|
+
* declared. A core tool always wins, and so does the external tool that arrived
|
|
27
|
+
* first; the collision is handed back rather than thrown, so the host records
|
|
28
|
+
* it with its rule and the operator sees it (docs/tools.md#external-tools).
|
|
29
|
+
*/
|
|
30
|
+
export interface ExternalToolRefusal {
|
|
31
|
+
/** The tool that was not registered. */
|
|
32
|
+
name: string;
|
|
33
|
+
/** Who declared it: the plugin's name, or the backend's. */
|
|
34
|
+
source: string;
|
|
35
|
+
/** The rule that refused it: `tool.name_taken`. */
|
|
36
|
+
rule: string;
|
|
37
|
+
}
|
|
38
|
+
/** What one `tool_search` answers: names, and the items a name belongs to. */
|
|
39
|
+
export interface SearchAnswer {
|
|
40
|
+
modules: readonly string[];
|
|
41
|
+
functions: readonly string[];
|
|
42
|
+
skills: readonly EnvItem[];
|
|
43
|
+
tools: readonly DirectToolSpec[];
|
|
44
|
+
}
|
|
45
|
+
/** How many names one `tool_search` answer lists before it says there are more. */
|
|
46
|
+
export declare const SEARCH_CAP = 20;
|
|
18
47
|
export declare class ToolCatalog implements ToolGroup, ScriptCatalog {
|
|
19
48
|
readonly id = "catalog";
|
|
20
49
|
private readonly services;
|
|
21
|
-
|
|
50
|
+
/** The groups the core declares itself, which no external tool may shadow. */
|
|
51
|
+
private readonly own;
|
|
52
|
+
/**
|
|
53
|
+
* The groups a deployment registered - the core's own, what the workspace
|
|
54
|
+
* implementation contributes, and a plugin's own groups - which no external
|
|
55
|
+
* tool may shadow either.
|
|
56
|
+
*/
|
|
57
|
+
private readonly base;
|
|
58
|
+
/**
|
|
59
|
+
* One group per source that declared external tools, the source's name as the
|
|
60
|
+
* key. A refresh replaces the group its source declared before, so a tool the
|
|
61
|
+
* source dropped is no longer declared and no longer callable
|
|
62
|
+
* (docs/tools.md#external-tools-that-change).
|
|
63
|
+
*/
|
|
64
|
+
private readonly externalGroups;
|
|
22
65
|
constructor(options: ToolCatalogOptions);
|
|
66
|
+
/** Every group: the base, then one per source that declared external tools. */
|
|
67
|
+
private get groups();
|
|
68
|
+
/**
|
|
69
|
+
* Register direct tools declared from outside the core - a plugin's own, or a
|
|
70
|
+
* backend's injection of the tools an outside harness registered
|
|
71
|
+
* (docs/tools.md#external-tools).
|
|
72
|
+
*
|
|
73
|
+
* A name the model already has is not registered: a core tool always wins,
|
|
74
|
+
* and so does the external tool that arrived first. The collisions come back
|
|
75
|
+
* with the rule that refused them, so the host records them for the operator
|
|
76
|
+
* instead of throwing. The rule runs on every call, so every refresh of the
|
|
77
|
+
* set is checked the same way.
|
|
78
|
+
*
|
|
79
|
+
* What this source declared before is replaced, so a refresh that drops a
|
|
80
|
+
* tool leaves it undeclared and uncallable - and a tool the source declares
|
|
81
|
+
* again is not a collision with itself.
|
|
82
|
+
*/
|
|
83
|
+
external(tools: readonly ExternalTool[], source: string): readonly ExternalToolRefusal[];
|
|
23
84
|
/** Every direct tool, one line each in the prompt. */
|
|
24
85
|
tools(): readonly DirectToolSpec[];
|
|
86
|
+
/** The two entry points to the modules, which every window has. */
|
|
87
|
+
private entryPoints;
|
|
25
88
|
/**
|
|
26
89
|
* Every module, one line each in the prompt. A name is declared once: every
|
|
27
90
|
* group that declares it lists its functions together, so a group that adds a
|
|
@@ -34,21 +97,36 @@ export declare class ToolCatalog implements ToolGroup, ScriptCatalog {
|
|
|
34
97
|
functions(): readonly FunctionSpec[];
|
|
35
98
|
/** The direct tool names, which a script may not call. */
|
|
36
99
|
directTools(): readonly string[];
|
|
100
|
+
/**
|
|
101
|
+
* The direct tools a script may call: every one the core does not declare
|
|
102
|
+
* itself (docs/tools.md#rules). A plugin's or a backend's tool is a
|
|
103
|
+
* capability of its own, like a module function, so a script reaches it the
|
|
104
|
+
* same way; arbor's own decisions and commands stay in the turn.
|
|
105
|
+
*/
|
|
106
|
+
scriptTools(): readonly DirectToolSpec[];
|
|
107
|
+
/** The direct tools arbor declares itself: its decisions and its commands. */
|
|
108
|
+
private ownTools;
|
|
37
109
|
/** One call, through the group that owns it. */
|
|
38
110
|
call(request: ToolCall, context: CallContext): Promise<ToolResult>;
|
|
39
111
|
/** One `module.function` call from a script: the same path as a direct call. */
|
|
40
112
|
callModule(name: string, args: Record<string, unknown>, context: CodeExecContext): Promise<ToolResult>;
|
|
41
113
|
/**
|
|
42
|
-
*
|
|
43
|
-
*
|
|
44
|
-
*
|
|
45
|
-
|
|
114
|
+
* The call itself: the removal check, the two entry points, then the group
|
|
115
|
+
* that owns the name. A name no group owns is refused, because the prompt
|
|
116
|
+
* promises `tool_search` answers it (docs/tools.md#rules).
|
|
117
|
+
*/
|
|
118
|
+
private route;
|
|
119
|
+
/** The result as it leaves: the tool that was called, and the call's own id. */
|
|
120
|
+
private named;
|
|
121
|
+
/**
|
|
122
|
+
* `tool_search`: keywords, an exact `module.function` or tool name, or
|
|
123
|
+
* nothing. An exact module name answers with that module's functions, a
|
|
124
|
+
* direct tool answers with its description, and a skill's name or keywords
|
|
125
|
+
* answer with its path and how to read it
|
|
126
|
+
* (docs/environment.md#using-a-skill-or-a-server). A name is matched exactly
|
|
127
|
+
* first, then by prefix, then by description.
|
|
46
128
|
*/
|
|
47
|
-
search(keywords?: string):
|
|
48
|
-
modules: readonly string[];
|
|
49
|
-
functions: readonly string[];
|
|
50
|
-
skills: readonly EnvItem[];
|
|
51
|
-
};
|
|
129
|
+
search(keywords?: string): SearchAnswer;
|
|
52
130
|
/** `tool_describe`: one function's signature and description. */
|
|
53
131
|
describe(name: string): string | undefined;
|
|
54
132
|
/** `code_exec`: run one script against this catalog. */
|
package/catalog.js
CHANGED
|
@@ -5,22 +5,46 @@
|
|
|
5
5
|
* A direct tool and a code mode function are thin wrappers of one service call:
|
|
6
6
|
* same arguments, same behaviour, same errors. `tool_search` and `tool_describe`
|
|
7
7
|
* read this catalog, so the lookup and the prompt never disagree. `code_exec`
|
|
8
|
-
* runs a script against it and refuses a script that names
|
|
8
|
+
* runs a script against it and refuses a script that names one of arbor's own
|
|
9
|
+
* direct tools.
|
|
10
|
+
*
|
|
11
|
+
* A tool declared from outside the core - a plugin's own, or a backend's
|
|
12
|
+
* injection of an outside harness's - is registered here too, and from that
|
|
13
|
+
* moment it is an ordinary direct tool: same declaration, same routing, same
|
|
14
|
+
* result cut (docs/tools.md#external-tools).
|
|
9
15
|
*/
|
|
10
|
-
import { textOf } from "@4fu/arbor-prompt";
|
|
16
|
+
import { textOf, toolLine } from "@4fu/arbor-prompt";
|
|
11
17
|
import { directToolIn, runScript } from "./code-exec.js";
|
|
18
|
+
import { boundResult } from "./bound.js";
|
|
12
19
|
import { CoreToolGroup } from "./groups/core.js";
|
|
13
20
|
import { EnvToolGroup } from "./groups/env.js";
|
|
21
|
+
import { ExternalToolGroup } from "./groups/external.js";
|
|
14
22
|
import { FileToolGroup } from "./groups/files.js";
|
|
15
23
|
import { NoteToolGroup } from "./groups/notes.js";
|
|
16
24
|
import { BoardModuleGroup, ChannelsModuleGroup, EnvModuleGroup, JournalModuleGroup, NotesModuleGroup, ScheduleModuleGroup, TasksModuleGroup, WindowsModuleGroup, WorkspaceModuleGroup, } from "./modules/modules.js";
|
|
25
|
+
/** How many names one `tool_search` answer lists before it says there are more. */
|
|
26
|
+
export const SEARCH_CAP = 20;
|
|
17
27
|
export class ToolCatalog {
|
|
18
28
|
id = "catalog";
|
|
19
29
|
services;
|
|
20
|
-
groups
|
|
30
|
+
/** The groups the core declares itself, which no external tool may shadow. */
|
|
31
|
+
own;
|
|
32
|
+
/**
|
|
33
|
+
* The groups a deployment registered - the core's own, what the workspace
|
|
34
|
+
* implementation contributes, and a plugin's own groups - which no external
|
|
35
|
+
* tool may shadow either.
|
|
36
|
+
*/
|
|
37
|
+
base;
|
|
38
|
+
/**
|
|
39
|
+
* One group per source that declared external tools, the source's name as the
|
|
40
|
+
* key. A refresh replaces the group its source declared before, so a tool the
|
|
41
|
+
* source dropped is no longer declared and no longer callable
|
|
42
|
+
* (docs/tools.md#external-tools-that-change).
|
|
43
|
+
*/
|
|
44
|
+
externalGroups = new Map();
|
|
21
45
|
constructor(options) {
|
|
22
46
|
this.services = options.services;
|
|
23
|
-
this.
|
|
47
|
+
this.own = [
|
|
24
48
|
new CoreToolGroup(options.services),
|
|
25
49
|
new FileToolGroup(options.services),
|
|
26
50
|
new NoteToolGroup(options.services),
|
|
@@ -34,16 +58,61 @@ export class ToolCatalog {
|
|
|
34
58
|
new WorkspaceModuleGroup(options.services),
|
|
35
59
|
new EnvToolGroup(options.services),
|
|
36
60
|
new EnvModuleGroup(options.services),
|
|
61
|
+
];
|
|
62
|
+
this.base = [
|
|
63
|
+
...this.own,
|
|
37
64
|
// What the workspace implementation contributes: a capability the core
|
|
38
65
|
// does not name, such as showing another window's directory.
|
|
39
66
|
...(options.services.workspace?.groups?.(options.services.table) ?? []),
|
|
40
67
|
...(options.groups ?? []),
|
|
41
68
|
];
|
|
42
69
|
}
|
|
70
|
+
/** Every group: the base, then one per source that declared external tools. */
|
|
71
|
+
get groups() {
|
|
72
|
+
return [...this.base, ...this.externalGroups.values()];
|
|
73
|
+
}
|
|
74
|
+
/**
|
|
75
|
+
* Register direct tools declared from outside the core - a plugin's own, or a
|
|
76
|
+
* backend's injection of the tools an outside harness registered
|
|
77
|
+
* (docs/tools.md#external-tools).
|
|
78
|
+
*
|
|
79
|
+
* A name the model already has is not registered: a core tool always wins,
|
|
80
|
+
* and so does the external tool that arrived first. The collisions come back
|
|
81
|
+
* with the rule that refused them, so the host records them for the operator
|
|
82
|
+
* instead of throwing. The rule runs on every call, so every refresh of the
|
|
83
|
+
* set is checked the same way.
|
|
84
|
+
*
|
|
85
|
+
* What this source declared before is replaced, so a refresh that drops a
|
|
86
|
+
* tool leaves it undeclared and uncallable - and a tool the source declares
|
|
87
|
+
* again is not a collision with itself.
|
|
88
|
+
*/
|
|
89
|
+
external(tools, source) {
|
|
90
|
+
const others = [...this.base, ...[...this.externalGroups].filter(([name]) => name !== source).map(([, group]) => group)];
|
|
91
|
+
const taken = new Set([
|
|
92
|
+
...others.flatMap((group) => group.tools()).map((tool) => tool.name),
|
|
93
|
+
...others.flatMap((group) => group.modules()).map((module) => module.name),
|
|
94
|
+
]);
|
|
95
|
+
const refused = [];
|
|
96
|
+
const declared = [];
|
|
97
|
+
for (const tool of tools) {
|
|
98
|
+
if (taken.has(tool.name)) {
|
|
99
|
+
refused.push({ name: tool.name, source, rule: "tool.name_taken" });
|
|
100
|
+
continue;
|
|
101
|
+
}
|
|
102
|
+
taken.add(tool.name);
|
|
103
|
+
declared.push(tool);
|
|
104
|
+
}
|
|
105
|
+
this.externalGroups.set(source, new ExternalToolGroup(declared, source, this.services));
|
|
106
|
+
return refused;
|
|
107
|
+
}
|
|
43
108
|
/** Every direct tool, one line each in the prompt. */
|
|
44
109
|
tools() {
|
|
110
|
+
return [...this.entryPoints(), ...this.groups.flatMap((group) => group.tools())];
|
|
111
|
+
}
|
|
112
|
+
/** The two entry points to the modules, which every window has. */
|
|
113
|
+
entryPoints() {
|
|
45
114
|
const table = this.services.table;
|
|
46
|
-
|
|
115
|
+
return [
|
|
47
116
|
{
|
|
48
117
|
name: "code_exec",
|
|
49
118
|
description: lookup(table, "tool.code_exec", {}),
|
|
@@ -55,7 +124,6 @@ export class ToolCatalog {
|
|
|
55
124
|
parameters: { keywords: { type: "string", description: lookup(table, "param.keywords", {}) } },
|
|
56
125
|
},
|
|
57
126
|
];
|
|
58
|
-
return [...entryPoints, ...this.groups.flatMap((group) => group.tools())];
|
|
59
127
|
}
|
|
60
128
|
/**
|
|
61
129
|
* Every module, one line each in the prompt. A name is declared once: every
|
|
@@ -86,10 +154,54 @@ export class ToolCatalog {
|
|
|
86
154
|
}
|
|
87
155
|
/** The direct tool names, which a script may not call. */
|
|
88
156
|
directTools() {
|
|
89
|
-
return this.
|
|
157
|
+
return this.ownTools().map((tool) => tool.name);
|
|
158
|
+
}
|
|
159
|
+
/**
|
|
160
|
+
* The direct tools a script may call: every one the core does not declare
|
|
161
|
+
* itself (docs/tools.md#rules). A plugin's or a backend's tool is a
|
|
162
|
+
* capability of its own, like a module function, so a script reaches it the
|
|
163
|
+
* same way; arbor's own decisions and commands stay in the turn.
|
|
164
|
+
*/
|
|
165
|
+
scriptTools() {
|
|
166
|
+
const own = new Set(this.directTools());
|
|
167
|
+
return this.tools().filter((tool) => !own.has(tool.name));
|
|
168
|
+
}
|
|
169
|
+
/** The direct tools arbor declares itself: its decisions and its commands. */
|
|
170
|
+
ownTools() {
|
|
171
|
+
return [...this.entryPoints(), ...this.own.flatMap((group) => group.tools())];
|
|
90
172
|
}
|
|
91
173
|
/** One call, through the group that owns it. */
|
|
92
174
|
async call(request, context) {
|
|
175
|
+
const result = this.named(request, await this.route(request, context));
|
|
176
|
+
// The one exit every direct call leaves by: one head-and-tail cut, for
|
|
177
|
+
// every tool, with the whole kept in the workspace
|
|
178
|
+
// (docs/prompting.md#information-density).
|
|
179
|
+
if (!result.ok)
|
|
180
|
+
return result;
|
|
181
|
+
return {
|
|
182
|
+
...result,
|
|
183
|
+
content: await boundResult(result.content, {
|
|
184
|
+
...(context.workspace === undefined ? {} : { workspace: context.workspace.path }),
|
|
185
|
+
...(context.call === undefined ? {} : { callId: context.call }),
|
|
186
|
+
table: this.services.table,
|
|
187
|
+
}),
|
|
188
|
+
};
|
|
189
|
+
}
|
|
190
|
+
/** One `module.function` call from a script: the same path as a direct call. */
|
|
191
|
+
async callModule(name, args, context) {
|
|
192
|
+
// No cut here: the script reads the whole answer and cuts what it returns.
|
|
193
|
+
return this.named({ name, args, callId: context.call ?? "script" }, await this.route({
|
|
194
|
+
name,
|
|
195
|
+
args,
|
|
196
|
+
callId: context.call ?? "script",
|
|
197
|
+
}, { window: context.window }));
|
|
198
|
+
}
|
|
199
|
+
/**
|
|
200
|
+
* The call itself: the removal check, the two entry points, then the group
|
|
201
|
+
* that owns the name. A name no group owns is refused, because the prompt
|
|
202
|
+
* promises `tool_search` answers it (docs/tools.md#rules).
|
|
203
|
+
*/
|
|
204
|
+
async route(request, context) {
|
|
93
205
|
// A removal never changes a prompt, but a call into what it removed is
|
|
94
206
|
// refused with the ref of the change that removed it
|
|
95
207
|
// (docs/environment.md#deltas-in-a-window).
|
|
@@ -135,40 +247,51 @@ export class ToolCatalog {
|
|
|
135
247
|
}
|
|
136
248
|
return owner.call(request, context);
|
|
137
249
|
}
|
|
138
|
-
/**
|
|
139
|
-
|
|
140
|
-
return
|
|
250
|
+
/** The result as it leaves: the tool that was called, and the call's own id. */
|
|
251
|
+
named(request, result) {
|
|
252
|
+
return {
|
|
253
|
+
...result,
|
|
254
|
+
callId: result.callId === "" ? request.callId : result.callId,
|
|
255
|
+
tool: result.tool ?? request.name,
|
|
256
|
+
};
|
|
141
257
|
}
|
|
142
258
|
/**
|
|
143
|
-
* `tool_search`: keywords, an exact `module.function
|
|
144
|
-
* module name answers with that module's functions,
|
|
145
|
-
*
|
|
146
|
-
*
|
|
259
|
+
* `tool_search`: keywords, an exact `module.function` or tool name, or
|
|
260
|
+
* nothing. An exact module name answers with that module's functions, a
|
|
261
|
+
* direct tool answers with its description, and a skill's name or keywords
|
|
262
|
+
* answer with its path and how to read it
|
|
263
|
+
* (docs/environment.md#using-a-skill-or-a-server). A name is matched exactly
|
|
264
|
+
* first, then by prefix, then by description.
|
|
147
265
|
*/
|
|
148
266
|
search(keywords) {
|
|
149
267
|
const modules = this.modules();
|
|
150
268
|
const functions = this.functions();
|
|
269
|
+
const tools = this.tools();
|
|
151
270
|
const skills = this.services.environment === undefined ? [] : this.services.envCatalog().items.filter((item) => item.kind === "skill");
|
|
152
271
|
if (keywords === undefined || keywords.trim() === "") {
|
|
153
|
-
return { modules: modules.map((module) => module.name), functions: [], skills };
|
|
272
|
+
return { modules: modules.map((module) => module.name), functions: [], skills, tools: [] };
|
|
154
273
|
}
|
|
155
274
|
const wanted = keywords.trim();
|
|
156
275
|
const named = modules.filter((module) => module.name === wanted);
|
|
157
276
|
if (wanted.includes(".")) {
|
|
158
|
-
|
|
159
|
-
|
|
277
|
+
return {
|
|
278
|
+
modules: named.map((module) => module.name),
|
|
279
|
+
functions: functions.filter((fn) => fn.name === wanted).map((fn) => fn.name),
|
|
280
|
+
skills: [],
|
|
281
|
+
tools: tools.filter((tool) => tool.name === wanted),
|
|
282
|
+
};
|
|
160
283
|
}
|
|
284
|
+
const rankedFunctions = ranked(wanted, functions, (fn) => [shortName(fn.name), fn.description]);
|
|
161
285
|
return {
|
|
162
|
-
modules: modules
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
//
|
|
166
|
-
functions:
|
|
167
|
-
|
|
168
|
-
fn.
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
skills: skills.filter((skill) => skill.name.includes(wanted) || skill.description.includes(wanted)),
|
|
286
|
+
modules: ranked(wanted, modules, (module) => [module.name, module.description]).map((module) => module.name),
|
|
287
|
+
// A server's name answers with its functions, not only the module's line;
|
|
288
|
+
// any other keyword answers with the functions whose own name or
|
|
289
|
+
// description holds it.
|
|
290
|
+
functions: (named.length === 0
|
|
291
|
+
? rankedFunctions
|
|
292
|
+
: functions.filter((fn) => named.some((module) => module.functions.includes(fn)))).map((fn) => fn.name),
|
|
293
|
+
skills: ranked(wanted, skills, (skill) => [skill.name, skill.description]),
|
|
294
|
+
tools: ranked(wanted, tools, (tool) => [tool.name, tool.description]),
|
|
172
295
|
};
|
|
173
296
|
}
|
|
174
297
|
/** `tool_describe`: one function's signature and description. */
|
|
@@ -185,10 +308,14 @@ export class ToolCatalog {
|
|
|
185
308
|
const table = this.services.table;
|
|
186
309
|
const keywords = stringArg(request.args, "keywords");
|
|
187
310
|
const found = this.search(keywords === "" ? undefined : keywords);
|
|
188
|
-
const lines = [];
|
|
311
|
+
const lines = [textOf(table, "result.tool_search.lead")];
|
|
189
312
|
if (found.modules.length > 0) {
|
|
190
313
|
lines.push(textOf(table, "result.tool_search.modules", { names: found.modules.join(", ") }));
|
|
191
314
|
}
|
|
315
|
+
for (const tool of found.tools) {
|
|
316
|
+
// The renderer's own rule, so the lookup and the prompt never disagree.
|
|
317
|
+
lines.push(toolLine(table, tool));
|
|
318
|
+
}
|
|
192
319
|
for (const name of found.functions) {
|
|
193
320
|
lines.push(this.describe(name) ?? name);
|
|
194
321
|
}
|
|
@@ -198,11 +325,22 @@ export class ToolCatalog {
|
|
|
198
325
|
continue;
|
|
199
326
|
lines.push(textOf(table, "result.tool_search.skill", { name: skill.name, description: skill.description, path: skill.path }));
|
|
200
327
|
}
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
328
|
+
const listed = found.modules.length + found.tools.length + found.functions.length + found.skills.length;
|
|
329
|
+
if (listed === 0) {
|
|
330
|
+
lines.push(textOf(table, "result.tool_search.none"));
|
|
331
|
+
}
|
|
332
|
+
else {
|
|
333
|
+
// A module the answer names with no function to call is said out loud,
|
|
334
|
+
// so the model does not go looking for one.
|
|
335
|
+
if (found.modules.length > 0 && found.functions.length === 0 && found.tools.length === 0 && found.skills.length === 0) {
|
|
336
|
+
lines.push(textOf(table, "result.tool_search.none_functions"));
|
|
337
|
+
}
|
|
338
|
+
if (listed > SEARCH_CAP) {
|
|
339
|
+
lines.push(textOf(table, "result.tool_search.more", { count: listed - SEARCH_CAP }));
|
|
340
|
+
}
|
|
341
|
+
}
|
|
342
|
+
lines.push(textOf(table, "result.tool_search.hint"));
|
|
343
|
+
return { callId: request.callId, ok: true, content: lines.join("\n") };
|
|
206
344
|
}
|
|
207
345
|
/** The guard a script goes through, exposed for tests. */
|
|
208
346
|
decisionIn(script) {
|
|
@@ -213,6 +351,29 @@ function stringArg(args, name) {
|
|
|
213
351
|
const value = args[name];
|
|
214
352
|
return typeof value === "string" ? value : "";
|
|
215
353
|
}
|
|
354
|
+
/**
|
|
355
|
+
* The matches, strongest first: an exact name, then a name that starts with
|
|
356
|
+
* the query, then a description that holds it. Ties keep the order the catalog
|
|
357
|
+
* declares them in, so the answer is deterministic.
|
|
358
|
+
*/
|
|
359
|
+
function ranked(wanted, items, keys) {
|
|
360
|
+
const found = [];
|
|
361
|
+
for (const item of items) {
|
|
362
|
+
const [name, description] = keys(item);
|
|
363
|
+
if (name === wanted)
|
|
364
|
+
found.push({ rank: 0, item });
|
|
365
|
+
else if (name.startsWith(wanted))
|
|
366
|
+
found.push({ rank: 1, item });
|
|
367
|
+
else if (description.includes(wanted))
|
|
368
|
+
found.push({ rank: 2, item });
|
|
369
|
+
}
|
|
370
|
+
return found.sort((left, right) => left.rank - right.rank).map((one) => one.item);
|
|
371
|
+
}
|
|
372
|
+
/** A function's own name, without the `module.` the catalog declares it with. */
|
|
373
|
+
function shortName(name) {
|
|
374
|
+
const dot = name.indexOf(".");
|
|
375
|
+
return dot === -1 ? name : name.slice(dot + 1);
|
|
376
|
+
}
|
|
216
377
|
function lookup(table, key, args) {
|
|
217
378
|
const template = table[key];
|
|
218
379
|
if (template === undefined)
|
package/child-env.d.ts
ADDED
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The environment a child process gets
|
|
3
|
+
* (docs/environment.md#what-a-subprocess-gets).
|
|
4
|
+
*
|
|
5
|
+
* Every MCP server, resident service and command a window runs inherits the
|
|
6
|
+
* host's whole environment, plus what its own configuration adds: the
|
|
7
|
+
* directory it works in, the item's own `env`, and the secrets it names by
|
|
8
|
+
* name. Nothing is withheld here on purpose: protection is one layer, the
|
|
9
|
+
* secret scrub, which replaces every configured secret value in whatever
|
|
10
|
+
* reaches the model or the journal (docs/environment.md#secrets).
|
|
11
|
+
*/
|
|
12
|
+
/** The variable that carries the directory a child process works in. */
|
|
13
|
+
export declare const WORKSPACE_ENV = "ARBOR_WORKSPACE";
|
|
14
|
+
/** What one child process is given beyond the host's own environment. */
|
|
15
|
+
export interface ChildEnvInput {
|
|
16
|
+
/** The directory it works in, when it has one. */
|
|
17
|
+
workspace?: string;
|
|
18
|
+
/** What its own configuration declares. */
|
|
19
|
+
env?: Record<string, string | undefined>;
|
|
20
|
+
/** The secrets its configuration names; their values are resolved by the caller. */
|
|
21
|
+
secrets?: Record<string, string | undefined>;
|
|
22
|
+
}
|
|
23
|
+
/** One child process's environment: the host's, then what its configuration adds. */
|
|
24
|
+
export declare function childEnvironment(input: ChildEnvInput): Record<string, string>;
|
package/child-env.js
ADDED
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The environment a child process gets
|
|
3
|
+
* (docs/environment.md#what-a-subprocess-gets).
|
|
4
|
+
*
|
|
5
|
+
* Every MCP server, resident service and command a window runs inherits the
|
|
6
|
+
* host's whole environment, plus what its own configuration adds: the
|
|
7
|
+
* directory it works in, the item's own `env`, and the secrets it names by
|
|
8
|
+
* name. Nothing is withheld here on purpose: protection is one layer, the
|
|
9
|
+
* secret scrub, which replaces every configured secret value in whatever
|
|
10
|
+
* reaches the model or the journal (docs/environment.md#secrets).
|
|
11
|
+
*/
|
|
12
|
+
/** The variable that carries the directory a child process works in. */
|
|
13
|
+
export const WORKSPACE_ENV = "ARBOR_WORKSPACE";
|
|
14
|
+
/** One child process's environment: the host's, then what its configuration adds. */
|
|
15
|
+
export function childEnvironment(input) {
|
|
16
|
+
const env = {};
|
|
17
|
+
for (const [key, value] of Object.entries(process.env)) {
|
|
18
|
+
if (value !== undefined)
|
|
19
|
+
env[key] = value;
|
|
20
|
+
}
|
|
21
|
+
if (input.workspace !== undefined && input.workspace !== "")
|
|
22
|
+
env[WORKSPACE_ENV] = input.workspace;
|
|
23
|
+
for (const [key, value] of Object.entries(input.env ?? {})) {
|
|
24
|
+
if (value !== undefined)
|
|
25
|
+
env[key] = value;
|
|
26
|
+
}
|
|
27
|
+
for (const [key, value] of Object.entries(input.secrets ?? {})) {
|
|
28
|
+
if (value !== undefined && value !== "")
|
|
29
|
+
env[key] = value;
|
|
30
|
+
}
|
|
31
|
+
return env;
|
|
32
|
+
}
|
package/code-exec.d.ts
CHANGED
|
@@ -2,17 +2,18 @@
|
|
|
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
|
|
12
13
|
* one signature. Both read the catalog frozen with the window plus the
|
|
13
14
|
* environment deltas since.
|
|
14
15
|
*/
|
|
15
|
-
import type { ModuleSpec, ToolResult, WindowRef } from "@4fu/arbor-interfaces";
|
|
16
|
+
import type { DirectToolSpec, ModuleSpec, ToolResult, WindowRef } from "@4fu/arbor-interfaces";
|
|
16
17
|
import type { ToolServices } from "./host.ts";
|
|
17
18
|
/** How long one script may run before it is stopped. */
|
|
18
19
|
declare const SCRIPT_TIMEOUT_MS = 60000;
|
|
@@ -38,17 +39,32 @@ export interface ScriptCatalog {
|
|
|
38
39
|
description: string;
|
|
39
40
|
path?: string;
|
|
40
41
|
}[];
|
|
42
|
+
tools: readonly {
|
|
43
|
+
name: string;
|
|
44
|
+
description: string;
|
|
45
|
+
}[];
|
|
41
46
|
};
|
|
42
47
|
/** `tool_describe`: one function's signature. */
|
|
43
48
|
describe(name: string): string | undefined;
|
|
44
|
-
/**
|
|
49
|
+
/**
|
|
50
|
+
* The direct tool names a script may not call: arbor's own decisions and
|
|
51
|
+
* commands, which belong in the turn (docs/tools.md#rules).
|
|
52
|
+
*/
|
|
45
53
|
directTools(): readonly string[];
|
|
54
|
+
/**
|
|
55
|
+
* The direct tools a script may call: a plugin's or a backend's, which are
|
|
56
|
+
* capabilities of their own like a module function (docs/tools.md#rules).
|
|
57
|
+
*/
|
|
58
|
+
scriptTools(): readonly DirectToolSpec[];
|
|
46
59
|
}
|
|
47
60
|
/** Run one script in its own context, with the modules as globals. */
|
|
48
61
|
export declare function runScript(script: string, catalog: ScriptCatalog, services: ToolServices, context: CodeExecContext): Promise<ToolResult>;
|
|
49
62
|
/**
|
|
50
63
|
* The direct tool a script names, if any. This is a guard, not a sandbox: it
|
|
51
|
-
* keeps
|
|
64
|
+
* keeps one of arbor's own decisions or commands out of a script, and the host
|
|
65
|
+
* still enforces every rule. A tool a plugin or a backend declared is not in
|
|
66
|
+
* the list - a script may call it, the way it calls a module function
|
|
67
|
+
* (docs/tools.md#rules).
|
|
52
68
|
*/
|
|
53
69
|
export declare function directToolIn(script: string, tools: readonly string[]): string | undefined;
|
|
54
70
|
export { SCRIPT_TIMEOUT_MS };
|