@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 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 directory per window.** Each window gets its own directory, and a
31
- continuation keeps working in its predecessor's directory in place. It is not
32
- bound to a repository; the agent clones and pushes what it needs.
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 journal.
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-workspace-dir`](packages/workspace-dir) | the default `Workspace`: one directory per window, with a continuation working in its predecessor's directory in place |
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 directories: isolation, continuity, what the agent does |
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 a direct tool.
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
- private readonly groups;
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
- * `tool_search`: keywords, an exact `module.function`, or nothing. An exact
43
- * module name answers with that module's functions, and a skill's name or
44
- * keywords answer with its path and how to read it
45
- * (docs/environment.md#using-a-skill-or-a-server).
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 a direct tool.
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.groups = [
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
- const entryPoints = [
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.tools().map((tool) => tool.name);
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
- /** One `module.function` call from a script: the same path as a direct call. */
139
- async callModule(name, args, context) {
140
- return this.call({ name, args, callId: context.call ?? "script" }, { window: context.window });
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`, or nothing. An exact
144
- * module name answers with that module's functions, and a skill's name or
145
- * keywords answer with its path and how to read it
146
- * (docs/environment.md#using-a-skill-or-a-server).
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
- const found = functions.filter((fn) => fn.name === wanted);
159
- return { modules: named.map((module) => module.name), functions: found.map((fn) => fn.name), skills: [] };
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
- .filter((module) => module.name.includes(wanted) || module.description.includes(wanted))
164
- .map((module) => module.name),
165
- // A server's name answers with its functions, not only the module's line.
166
- functions: functions
167
- .filter((fn) => fn.name.includes(wanted) ||
168
- fn.description.includes(wanted) ||
169
- named.some((module) => module.functions.includes(fn)))
170
- .map((fn) => fn.name),
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
- return {
202
- callId: request.callId,
203
- ok: true,
204
- content: lines.length === 0 ? textOf(table, "result.tool_search.none") : lines.join("\n"),
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 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.
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
- /** The direct tool names a script may not call. */
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 a decision out of a script, and the host still enforces every rule.
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 };