@4fu/arbor-tools 0.1.0 → 2026.1106.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md 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.
@@ -118,7 +120,8 @@ window gets, and how a continuation keeps working in it, are in
118
120
  | [`@4fu/arbor-journal-sqlite`](packages/journal-sqlite) | the default `Journal` and `BlobStore` on SQLite |
119
121
  | [`@4fu/arbor-notes-sqlite`](packages/notes-sqlite) | the default `NoteStore` on SQLite, beside the journal |
120
122
  | [`@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 |
123
+ | [`@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 |
124
+ | [`@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
125
  | [`@4fu/arbor-environment-dir`](packages/environment-dir) | the default `Environment`: one `/env` directory, a revision per item, validation and deltas |
123
126
  | [`@4fu/arbor-supervisor-process`](packages/supervisor-process) | the default `Supervisor`: one host process per service, restart with backoff, logs |
124
127
  | [`@4fu/arbor-judge-jev`](packages/judge-jev) | the default `Judge`: Jev, TypeSafe AI's System One classifier, answering the dispatch decisions |
@@ -151,7 +154,7 @@ deployment can see what it would be taking on before it adopts one.
151
154
  | [docs/coordination.md](docs/coordination.md) | the board, expectations, deliveries and broadcasts, staying current, effects |
152
155
  | [docs/environment.md](docs/environment.md) | skills, MCP servers and services: using, changing, reload, built-in skills, deltas |
153
156
  | [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 |
157
+ | [docs/workspaces.md](docs/workspaces.md) | the shared folder and per-window checkouts: isolation, continuity, what the agent does |
155
158
  | [docs/tools.md](docs/tools.md) | direct tools and code mode modules |
156
159
  | [docs/prompting.md](docs/prompting.md) | how every model-facing text is written: layers, notes, deliveries, errors, judge questions, terms |
157
160
  | [docs/release.md](docs/release.md) | calendar versioning, the manual release steps, how a package is published |
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,52 @@ 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;
50
+ /** The groups the core declares itself, which no external tool may shadow. */
51
+ private readonly own;
52
+ /** Every group, the core's own first, then what a deployment registered. */
21
53
  private readonly groups;
22
54
  constructor(options: ToolCatalogOptions);
55
+ /**
56
+ * Register direct tools declared from outside the core - a plugin's own, or a
57
+ * backend's injection of the tools an outside harness registered
58
+ * (docs/tools.md#external-tools).
59
+ *
60
+ * A name the model already has is not registered: a core tool always wins,
61
+ * and so does the external tool that arrived first. The collisions come back
62
+ * with the rule that refused them, so the host records them for the operator
63
+ * instead of throwing.
64
+ */
65
+ external(tools: readonly ExternalTool[], source: string): readonly ExternalToolRefusal[];
23
66
  /** Every direct tool, one line each in the prompt. */
24
67
  tools(): readonly DirectToolSpec[];
68
+ /** The two entry points to the modules, which every window has. */
69
+ private entryPoints;
25
70
  /**
26
71
  * Every module, one line each in the prompt. A name is declared once: every
27
72
  * group that declares it lists its functions together, so a group that adds a
@@ -34,21 +79,36 @@ export declare class ToolCatalog implements ToolGroup, ScriptCatalog {
34
79
  functions(): readonly FunctionSpec[];
35
80
  /** The direct tool names, which a script may not call. */
36
81
  directTools(): readonly string[];
82
+ /**
83
+ * The direct tools a script may call: every one the core does not declare
84
+ * itself (docs/tools.md#rules). A plugin's or a backend's tool is a
85
+ * capability of its own, like a module function, so a script reaches it the
86
+ * same way; arbor's own decisions and commands stay in the turn.
87
+ */
88
+ scriptTools(): readonly DirectToolSpec[];
89
+ /** The direct tools arbor declares itself: its decisions and its commands. */
90
+ private ownTools;
37
91
  /** One call, through the group that owns it. */
38
92
  call(request: ToolCall, context: CallContext): Promise<ToolResult>;
39
93
  /** One `module.function` call from a script: the same path as a direct call. */
40
94
  callModule(name: string, args: Record<string, unknown>, context: CodeExecContext): Promise<ToolResult>;
41
95
  /**
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).
96
+ * The call itself: the removal check, the two entry points, then the group
97
+ * that owns the name. A name no group owns is refused, because the prompt
98
+ * promises `tool_search` answers it (docs/tools.md#rules).
99
+ */
100
+ private route;
101
+ /** The result as it leaves: the tool that was called, and the call's own id. */
102
+ private named;
103
+ /**
104
+ * `tool_search`: keywords, an exact `module.function` or tool name, or
105
+ * nothing. An exact module name answers with that module's functions, a
106
+ * direct tool answers with its description, and a skill's name or keywords
107
+ * answer with its path and how to read it
108
+ * (docs/environment.md#using-a-skill-or-a-server). A name is matched exactly
109
+ * first, then by prefix, then by description.
46
110
  */
47
- search(keywords?: string): {
48
- modules: readonly string[];
49
- functions: readonly string[];
50
- skills: readonly EnvItem[];
51
- };
111
+ search(keywords?: string): SearchAnswer;
52
112
  /** `tool_describe`: one function's signature and description. */
53
113
  describe(name: string): string | undefined;
54
114
  /** `code_exec`: run one script against this catalog. */
package/catalog.js CHANGED
@@ -5,22 +5,35 @@
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
16
  import { textOf } 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;
30
+ /** The groups the core declares itself, which no external tool may shadow. */
31
+ own;
32
+ /** Every group, the core's own first, then what a deployment registered. */
20
33
  groups;
21
34
  constructor(options) {
22
35
  this.services = options.services;
23
- this.groups = [
36
+ this.own = [
24
37
  new CoreToolGroup(options.services),
25
38
  new FileToolGroup(options.services),
26
39
  new NoteToolGroup(options.services),
@@ -34,16 +47,49 @@ export class ToolCatalog {
34
47
  new WorkspaceModuleGroup(options.services),
35
48
  new EnvToolGroup(options.services),
36
49
  new EnvModuleGroup(options.services),
50
+ ];
51
+ this.groups = [
52
+ ...this.own,
37
53
  // What the workspace implementation contributes: a capability the core
38
54
  // does not name, such as showing another window's directory.
39
55
  ...(options.services.workspace?.groups?.(options.services.table) ?? []),
40
56
  ...(options.groups ?? []),
41
57
  ];
42
58
  }
59
+ /**
60
+ * Register direct tools declared from outside the core - a plugin's own, or a
61
+ * backend's injection of the tools an outside harness registered
62
+ * (docs/tools.md#external-tools).
63
+ *
64
+ * A name the model already has is not registered: a core tool always wins,
65
+ * and so does the external tool that arrived first. The collisions come back
66
+ * with the rule that refused them, so the host records them for the operator
67
+ * instead of throwing.
68
+ */
69
+ external(tools, source) {
70
+ const taken = new Set([...this.tools().map((tool) => tool.name), ...this.modules().map((module) => module.name)]);
71
+ const refused = [];
72
+ const declared = [];
73
+ for (const tool of tools) {
74
+ if (taken.has(tool.name)) {
75
+ refused.push({ name: tool.name, source, rule: "tool.name_taken" });
76
+ continue;
77
+ }
78
+ taken.add(tool.name);
79
+ declared.push(tool);
80
+ }
81
+ if (declared.length > 0)
82
+ this.groups.push(new ExternalToolGroup(declared, source, this.services));
83
+ return refused;
84
+ }
43
85
  /** Every direct tool, one line each in the prompt. */
44
86
  tools() {
87
+ return [...this.entryPoints(), ...this.groups.flatMap((group) => group.tools())];
88
+ }
89
+ /** The two entry points to the modules, which every window has. */
90
+ entryPoints() {
45
91
  const table = this.services.table;
46
- const entryPoints = [
92
+ return [
47
93
  {
48
94
  name: "code_exec",
49
95
  description: lookup(table, "tool.code_exec", {}),
@@ -55,7 +101,6 @@ export class ToolCatalog {
55
101
  parameters: { keywords: { type: "string", description: lookup(table, "param.keywords", {}) } },
56
102
  },
57
103
  ];
58
- return [...entryPoints, ...this.groups.flatMap((group) => group.tools())];
59
104
  }
60
105
  /**
61
106
  * Every module, one line each in the prompt. A name is declared once: every
@@ -86,10 +131,54 @@ export class ToolCatalog {
86
131
  }
87
132
  /** The direct tool names, which a script may not call. */
88
133
  directTools() {
89
- return this.tools().map((tool) => tool.name);
134
+ return this.ownTools().map((tool) => tool.name);
135
+ }
136
+ /**
137
+ * The direct tools a script may call: every one the core does not declare
138
+ * itself (docs/tools.md#rules). A plugin's or a backend's tool is a
139
+ * capability of its own, like a module function, so a script reaches it the
140
+ * same way; arbor's own decisions and commands stay in the turn.
141
+ */
142
+ scriptTools() {
143
+ const own = new Set(this.directTools());
144
+ return this.tools().filter((tool) => !own.has(tool.name));
145
+ }
146
+ /** The direct tools arbor declares itself: its decisions and its commands. */
147
+ ownTools() {
148
+ return [...this.entryPoints(), ...this.own.flatMap((group) => group.tools())];
90
149
  }
91
150
  /** One call, through the group that owns it. */
92
151
  async call(request, context) {
152
+ const result = this.named(request, await this.route(request, context));
153
+ // The one exit every direct call leaves by: one head-and-tail cut, for
154
+ // every tool, with the whole kept in the workspace
155
+ // (docs/prompting.md#information-density).
156
+ if (!result.ok)
157
+ return result;
158
+ return {
159
+ ...result,
160
+ content: await boundResult(result.content, {
161
+ ...(context.workspace === undefined ? {} : { workspace: context.workspace.path }),
162
+ ...(context.call === undefined ? {} : { callId: context.call }),
163
+ table: this.services.table,
164
+ }),
165
+ };
166
+ }
167
+ /** One `module.function` call from a script: the same path as a direct call. */
168
+ async callModule(name, args, context) {
169
+ // No cut here: the script reads the whole answer and cuts what it returns.
170
+ return this.named({ name, args, callId: context.call ?? "script" }, await this.route({
171
+ name,
172
+ args,
173
+ callId: context.call ?? "script",
174
+ }, { window: context.window }));
175
+ }
176
+ /**
177
+ * The call itself: the removal check, the two entry points, then the group
178
+ * that owns the name. A name no group owns is refused, because the prompt
179
+ * promises `tool_search` answers it (docs/tools.md#rules).
180
+ */
181
+ async route(request, context) {
93
182
  // A removal never changes a prompt, but a call into what it removed is
94
183
  // refused with the ref of the change that removed it
95
184
  // (docs/environment.md#deltas-in-a-window).
@@ -135,40 +224,51 @@ export class ToolCatalog {
135
224
  }
136
225
  return owner.call(request, context);
137
226
  }
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 });
227
+ /** The result as it leaves: the tool that was called, and the call's own id. */
228
+ named(request, result) {
229
+ return {
230
+ ...result,
231
+ callId: result.callId === "" ? request.callId : result.callId,
232
+ tool: result.tool ?? request.name,
233
+ };
141
234
  }
142
235
  /**
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).
236
+ * `tool_search`: keywords, an exact `module.function` or tool name, or
237
+ * nothing. An exact module name answers with that module's functions, a
238
+ * direct tool answers with its description, and a skill's name or keywords
239
+ * answer with its path and how to read it
240
+ * (docs/environment.md#using-a-skill-or-a-server). A name is matched exactly
241
+ * first, then by prefix, then by description.
147
242
  */
148
243
  search(keywords) {
149
244
  const modules = this.modules();
150
245
  const functions = this.functions();
246
+ const tools = this.tools();
151
247
  const skills = this.services.environment === undefined ? [] : this.services.envCatalog().items.filter((item) => item.kind === "skill");
152
248
  if (keywords === undefined || keywords.trim() === "") {
153
- return { modules: modules.map((module) => module.name), functions: [], skills };
249
+ return { modules: modules.map((module) => module.name), functions: [], skills, tools: [] };
154
250
  }
155
251
  const wanted = keywords.trim();
156
252
  const named = modules.filter((module) => module.name === wanted);
157
253
  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: [] };
254
+ return {
255
+ modules: named.map((module) => module.name),
256
+ functions: functions.filter((fn) => fn.name === wanted).map((fn) => fn.name),
257
+ skills: [],
258
+ tools: tools.filter((tool) => tool.name === wanted),
259
+ };
160
260
  }
261
+ const rankedFunctions = ranked(wanted, functions, (fn) => [shortName(fn.name), fn.description]);
161
262
  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)),
263
+ modules: ranked(wanted, modules, (module) => [module.name, module.description]).map((module) => module.name),
264
+ // A server's name answers with its functions, not only the module's line;
265
+ // any other keyword answers with the functions whose own name or
266
+ // description holds it.
267
+ functions: (named.length === 0
268
+ ? rankedFunctions
269
+ : functions.filter((fn) => named.some((module) => module.functions.includes(fn)))).map((fn) => fn.name),
270
+ skills: ranked(wanted, skills, (skill) => [skill.name, skill.description]),
271
+ tools: ranked(wanted, tools, (tool) => [tool.name, tool.description]),
172
272
  };
173
273
  }
174
274
  /** `tool_describe`: one function's signature and description. */
@@ -185,10 +285,13 @@ export class ToolCatalog {
185
285
  const table = this.services.table;
186
286
  const keywords = stringArg(request.args, "keywords");
187
287
  const found = this.search(keywords === "" ? undefined : keywords);
188
- const lines = [];
288
+ const lines = [textOf(table, "result.tool_search.lead")];
189
289
  if (found.modules.length > 0) {
190
290
  lines.push(textOf(table, "result.tool_search.modules", { names: found.modules.join(", ") }));
191
291
  }
292
+ for (const tool of found.tools) {
293
+ lines.push(toolLine(table, tool));
294
+ }
192
295
  for (const name of found.functions) {
193
296
  lines.push(this.describe(name) ?? name);
194
297
  }
@@ -198,11 +301,22 @@ export class ToolCatalog {
198
301
  continue;
199
302
  lines.push(textOf(table, "result.tool_search.skill", { name: skill.name, description: skill.description, path: skill.path }));
200
303
  }
201
- return {
202
- callId: request.callId,
203
- ok: true,
204
- content: lines.length === 0 ? textOf(table, "result.tool_search.none") : lines.join("\n"),
205
- };
304
+ const listed = found.modules.length + found.tools.length + found.functions.length + found.skills.length;
305
+ if (listed === 0) {
306
+ lines.push(textOf(table, "result.tool_search.none"));
307
+ }
308
+ else {
309
+ // A module the answer names with no function to call is said out loud,
310
+ // so the model does not go looking for one.
311
+ if (found.modules.length > 0 && found.functions.length === 0 && found.tools.length === 0 && found.skills.length === 0) {
312
+ lines.push(textOf(table, "result.tool_search.none_functions"));
313
+ }
314
+ if (listed > SEARCH_CAP) {
315
+ lines.push(textOf(table, "result.tool_search.more", { count: listed - SEARCH_CAP }));
316
+ }
317
+ }
318
+ lines.push(textOf(table, "result.tool_search.hint"));
319
+ return { callId: request.callId, ok: true, content: lines.join("\n") };
206
320
  }
207
321
  /** The guard a script goes through, exposed for tests. */
208
322
  decisionIn(script) {
@@ -213,6 +327,38 @@ function stringArg(args, name) {
213
327
  const value = args[name];
214
328
  return typeof value === "string" ? value : "";
215
329
  }
330
+ /**
331
+ * One tool's line, the same one the tools list writes: a tool declared from
332
+ * outside the core says where it comes from, so the model knows whose it is
333
+ * wherever it meets the name (docs/tools.md#external-tools).
334
+ */
335
+ function toolLine(table, tool) {
336
+ const key = tool.source === undefined ? "system.tool" : "system.tool.source";
337
+ return lookup(table, key, { name: tool.name, description: tool.description, source: tool.source ?? "" });
338
+ }
339
+ /**
340
+ * The matches, strongest first: an exact name, then a name that starts with
341
+ * the query, then a description that holds it. Ties keep the order the catalog
342
+ * declares them in, so the answer is deterministic.
343
+ */
344
+ function ranked(wanted, items, keys) {
345
+ const found = [];
346
+ for (const item of items) {
347
+ const [name, description] = keys(item);
348
+ if (name === wanted)
349
+ found.push({ rank: 0, item });
350
+ else if (name.startsWith(wanted))
351
+ found.push({ rank: 1, item });
352
+ else if (description.includes(wanted))
353
+ found.push({ rank: 2, item });
354
+ }
355
+ return found.sort((left, right) => left.rank - right.rank).map((one) => one.item);
356
+ }
357
+ /** A function's own name, without the `module.` the catalog declares it with. */
358
+ function shortName(name) {
359
+ const dot = name.indexOf(".");
360
+ return dot === -1 ? name : name.slice(dot + 1);
361
+ }
216
362
  function lookup(table, key, args) {
217
363
  const template = table[key];
218
364
  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 };