@4fu/arbor-tools 0.0.0-stage → 2026.1106.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +187 -2
- package/bound.d.ts +34 -0
- package/bound.js +55 -0
- package/catalog.d.ts +60 -0
- package/catalog.js +224 -0
- package/code-exec.d.ts +55 -0
- package/code-exec.js +100 -0
- package/env-stage.d.ts +29 -0
- package/env-stage.js +63 -0
- package/groups/core.d.ts +30 -0
- package/groups/core.js +148 -0
- package/groups/env.d.ts +45 -0
- package/groups/env.js +190 -0
- package/groups/files.d.ts +63 -0
- package/groups/files.js +343 -0
- package/groups/notes.d.ts +37 -0
- package/groups/notes.js +176 -0
- package/host.d.ts +199 -0
- package/host.js +70 -0
- package/index.d.ts +18 -0
- package/index.js +18 -0
- package/modules/env.d.ts +42 -0
- package/modules/env.js +237 -0
- package/modules/modules.d.ts +121 -0
- package/modules/modules.js +1060 -0
- package/package.json +55 -4
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 4fuu
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
CHANGED
|
@@ -1,3 +1,188 @@
|
|
|
1
|
-
#
|
|
1
|
+
# arbor
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
arbor is a framework for an agent without sessions: many context windows run
|
|
4
|
+
at once over one shared record, so work moves in parallel, replies come fast,
|
|
5
|
+
and every window can find everything that was ever done.
|
|
6
|
+
|
|
7
|
+
A deployment composes the pieces it wants and registers plugins for the domains
|
|
8
|
+
it serves. The framework guarantees what an agent cannot guarantee for itself -
|
|
9
|
+
the record, isolation, exactly-once effects, the checks, dispatch - and leaves
|
|
10
|
+
everything that takes judgment, such as git, merges and conflicts, to the agent.
|
|
11
|
+
|
|
12
|
+
## Core ideas
|
|
13
|
+
|
|
14
|
+
- **No sessions to manage.** Work runs in disposable context windows. Any
|
|
15
|
+
number run at the same time; none of them owns anything, and a window that
|
|
16
|
+
ends loses nothing. The model looks up what it needs instead of carrying it.
|
|
17
|
+
- **One record, shared live.** Every input, tool call, result, send and note
|
|
18
|
+
revision goes into one journal and is visible to every window as
|
|
19
|
+
soon as it is written. Records never merge and are never lost.
|
|
20
|
+
- **Dispatch by a judge.** A judge (Jev by default, replaceable) decides which
|
|
21
|
+
live window takes a new input, or opens a new one. There is no router session
|
|
22
|
+
and no main line.
|
|
23
|
+
- **Current without locks.** Windows run in parallel. New events on a
|
|
24
|
+
conversation a window is in are steered into it as they arrive; only a note
|
|
25
|
+
write is checked against the revision the window read.
|
|
26
|
+
- **Mechanisms in the framework, policy in the agent.** The framework
|
|
27
|
+
guarantees what an agent cannot guarantee for itself - the record, isolation,
|
|
28
|
+
exactly-once effects, the checks, dispatch. Everything that takes judgment,
|
|
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.
|
|
33
|
+
- **A self-changing environment.** The model adds and changes skills, MCP
|
|
34
|
+
servers and resident services itself; a `reload` validates the change and
|
|
35
|
+
broadcasts it to every live window as a delta of names and descriptions.
|
|
36
|
+
- **Minimal limits.** The framework sets no policy. Permissions, privacy
|
|
37
|
+
review, budgets and approvals belong to deployments built on it, through
|
|
38
|
+
hooks.
|
|
39
|
+
- **Direct tools plus code mode.** Decisions are direct tools; everything else
|
|
40
|
+
is a module called from `code_exec`. There is no CLI.
|
|
41
|
+
- **Every module is replaceable.** The agent backend (pi by default), the
|
|
42
|
+
journal, notes, workspaces, the judge, channels and the rest are interfaces
|
|
43
|
+
([docs/architecture.md](docs/architecture.md#interfaces)).
|
|
44
|
+
|
|
45
|
+
## Install
|
|
46
|
+
|
|
47
|
+
Node 24 or newer. Every package is published separately, so a deployment
|
|
48
|
+
installs the core and the defaults it wants:
|
|
49
|
+
|
|
50
|
+
```sh
|
|
51
|
+
npm install @4fu/arbor-core @4fu/arbor-journal-sqlite @4fu/arbor-notes-sqlite \
|
|
52
|
+
@4fu/arbor-environment-dir @4fu/arbor-supervisor-process @4fu/arbor-agent-pi \
|
|
53
|
+
@4fu/arbor-workspace-dir
|
|
54
|
+
```
|
|
55
|
+
|
|
56
|
+
`@4fu/arbor-plugin-http` is optional; it is the example plugin below.
|
|
57
|
+
`@4fu/arbor-segmenter-jieba` is an optional peer of the journal.
|
|
58
|
+
|
|
59
|
+
## A minimal host
|
|
60
|
+
|
|
61
|
+
The smallest host needs a journal and a backend. Adding the defaults and one
|
|
62
|
+
plugin is what a deployment usually starts from:
|
|
63
|
+
|
|
64
|
+
```js
|
|
65
|
+
import { mkdtemp } from "node:fs/promises";
|
|
66
|
+
import { tmpdir } from "node:os";
|
|
67
|
+
import { join } from "node:path";
|
|
68
|
+
import { Arbor } from "@4fu/arbor-core";
|
|
69
|
+
import { DirectoryEnvironment } from "@4fu/arbor-environment-dir";
|
|
70
|
+
import { SqliteJournal } from "@4fu/arbor-journal-sqlite";
|
|
71
|
+
import { SqliteNoteStore } from "@4fu/arbor-notes-sqlite";
|
|
72
|
+
import { ProcessSupervisor } from "@4fu/arbor-supervisor-process";
|
|
73
|
+
import { DirectoryWorkspace } from "@4fu/arbor-workspace-dir";
|
|
74
|
+
import { PiAgentBackend } from "@4fu/arbor-agent-pi";
|
|
75
|
+
import { HttpChannel, httpPlugin } from "@4fu/arbor-plugin-http";
|
|
76
|
+
|
|
77
|
+
const root = await mkdtemp(join(tmpdir(), "arbor-"));
|
|
78
|
+
|
|
79
|
+
// Every module below is a default implementation of an interface. Replace any
|
|
80
|
+
// one by passing your own; the host does not change.
|
|
81
|
+
const journal = new SqliteJournal({ path: join(root, "journal.sqlite") });
|
|
82
|
+
const notes = new SqliteNoteStore({ path: join(root, "notes.sqlite"), journal });
|
|
83
|
+
const supervisor = new ProcessSupervisor({ root: join(root, "services") });
|
|
84
|
+
const environment = new DirectoryEnvironment({ root: join(root, "env"), supervisor });
|
|
85
|
+
const workspace = new DirectoryWorkspace({ root: join(root, "workspaces") });
|
|
86
|
+
|
|
87
|
+
// A plugin adds; it never patches the core. This one is a chat channel: a
|
|
88
|
+
// webhook in, a window's sends out, and a page that drives the whole host.
|
|
89
|
+
let arbor;
|
|
90
|
+
const channel = new HttpChannel({ host: "127.0.0.1", port: 8787, board: () => arbor.boardLines() });
|
|
91
|
+
arbor = new Arbor({
|
|
92
|
+
journal,
|
|
93
|
+
notes,
|
|
94
|
+
backend: new PiAgentBackend(),
|
|
95
|
+
workspace,
|
|
96
|
+
environment,
|
|
97
|
+
supervisor,
|
|
98
|
+
plugins: [httpPlugin({ channel })],
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
await arbor.start();
|
|
102
|
+
await channel.listen();
|
|
103
|
+
```
|
|
104
|
+
|
|
105
|
+
`arbor.open(task)` dispatches one task, and the journal is where everything that
|
|
106
|
+
happened can be read back ([docs/journal.md](docs/journal.md)). The workspace a
|
|
107
|
+
window gets, and how a continuation keeps working in it, are in
|
|
108
|
+
[docs/workspaces.md](docs/workspaces.md).
|
|
109
|
+
|
|
110
|
+
## Packages
|
|
111
|
+
|
|
112
|
+
| Package | What it is |
|
|
113
|
+
| --- | --- |
|
|
114
|
+
| [`@4fu/arbor-interfaces`](packages/interfaces) | every replaceable interface, plus the core's own shapes. Types only. |
|
|
115
|
+
| [`@4fu/arbor-errors`](packages/errors) | the error shape every refusal uses: the rule and the next action |
|
|
116
|
+
| [`@4fu/arbor-prompt`](packages/prompt) | typed blocks, the one deterministic renderer and the replaceable text table |
|
|
117
|
+
| [`@4fu/arbor-tools`](packages/tools) | the entry points: direct tools, code mode modules, the host API |
|
|
118
|
+
| [`@4fu/arbor-journal-sqlite`](packages/journal-sqlite) | the default `Journal` and `BlobStore` on SQLite |
|
|
119
|
+
| [`@4fu/arbor-notes-sqlite`](packages/notes-sqlite) | the default `NoteStore` on SQLite, beside the journal |
|
|
120
|
+
| [`@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 |
|
|
122
|
+
| [`@4fu/arbor-environment-dir`](packages/environment-dir) | the default `Environment`: one `/env` directory, a revision per item, validation and deltas |
|
|
123
|
+
| [`@4fu/arbor-supervisor-process`](packages/supervisor-process) | the default `Supervisor`: one host process per service, restart with backoff, logs |
|
|
124
|
+
| [`@4fu/arbor-judge-jev`](packages/judge-jev) | the default `Judge`: Jev, TypeSafe AI's System One classifier, answering the dispatch decisions |
|
|
125
|
+
| [`@4fu/arbor-agent-pi`](packages/agent-pi) | the default `AgentBackend`: pi-agent-core in process |
|
|
126
|
+
| [`@4fu/arbor-plugin-http`](packages/plugin-http) | the HTTP test channel: a webhook in, sends out, a page that drives the whole host |
|
|
127
|
+
| [`@4fu/arbor-core`](packages/core) | the service layer: one implementation of every operation - the window lifecycle, dispatch, coordination, the board, notes access, workspaces, effects and schedules |
|
|
128
|
+
|
|
129
|
+
### Defaults are examples
|
|
130
|
+
|
|
131
|
+
Every default above is a reference implementation of its interface, written to
|
|
132
|
+
be read and replaced. A real deployment is expected to swap out the ones that do
|
|
133
|
+
not fit: the SQLite journal and notes, the directory workspace, the process
|
|
134
|
+
supervisor, the environment directory, the HTTP test plugin and the Jev judge
|
|
135
|
+
are all examples of what an interface can be satisfied by, not commitments.
|
|
136
|
+
What a deployment keeps is its own choice, and the interfaces are the contract
|
|
137
|
+
([docs/architecture.md](docs/architecture.md#interfaces)).
|
|
138
|
+
|
|
139
|
+
Each package's own documentation holds the limits of that default, so a
|
|
140
|
+
deployment can see what it would be taking on before it adopts one.
|
|
141
|
+
|
|
142
|
+
## Documents
|
|
143
|
+
|
|
144
|
+
| Page | Contents |
|
|
145
|
+
| --- | --- |
|
|
146
|
+
| [docs/overview.md](docs/overview.md) | goals, concepts, principles, routes considered |
|
|
147
|
+
| [docs/architecture.md](docs/architecture.md) | layers, interfaces, stack, models, plugins, hooks, data placement |
|
|
148
|
+
| [docs/journal.md](docs/journal.md) | events and their order, targets, watermarks, external ids, refs, tracing, media, redaction |
|
|
149
|
+
| [docs/windows.md](docs/windows.md) | window lifecycle, prompt layout, reminders, continuation |
|
|
150
|
+
| [docs/dispatch.md](docs/dispatch.md) | task sources, judge decisions, insertion timing, backpressure, scheduled tasks |
|
|
151
|
+
| [docs/coordination.md](docs/coordination.md) | the board, expectations, deliveries and broadcasts, staying current, effects |
|
|
152
|
+
| [docs/environment.md](docs/environment.md) | skills, MCP servers and services: using, changing, reload, built-in skills, deltas |
|
|
153
|
+
| [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 |
|
|
155
|
+
| [docs/tools.md](docs/tools.md) | direct tools and code mode modules |
|
|
156
|
+
| [docs/prompting.md](docs/prompting.md) | how every model-facing text is written: layers, notes, deliveries, errors, judge questions, terms |
|
|
157
|
+
| [docs/release.md](docs/release.md) | calendar versioning, the manual release steps, how a package is published |
|
|
158
|
+
|
|
159
|
+
## Repository layout
|
|
160
|
+
|
|
161
|
+
`pnpm check` typechecks and tests every package on its TypeScript sources.
|
|
162
|
+
`pnpm build` emits the `dist` publishing needs, and `pnpm pack` shows what each
|
|
163
|
+
tarball would contain ([docs/release.md](docs/release.md)).
|
|
164
|
+
|
|
165
|
+
| Path | Layer |
|
|
166
|
+
| --- | --- |
|
|
167
|
+
| `packages/interfaces` | the interfaces layer: every replaceable interface, plus the core's own shapes. Types only. |
|
|
168
|
+
| `packages/errors` | the error shape every refusal uses: the rule and the next action |
|
|
169
|
+
| `packages/journal-sqlite` | the default `Journal` and `BlobStore` on SQLite |
|
|
170
|
+
| `packages/segmenter-jieba` | the default `Segmenter`: jieba's dictionary segmentation for the word index, optional |
|
|
171
|
+
| `packages/notes-sqlite` | the default `NoteStore` on SQLite, beside the journal |
|
|
172
|
+
| `packages/judge-jev` | the default `Judge`: Jev, TypeSafe AI's System One classifier, answering the dispatch decisions |
|
|
173
|
+
| `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 |
|
|
174
|
+
| `packages/environment-dir` | the default `Environment`: one `/env` directory, a revision per item, validation and deltas |
|
|
175
|
+
| `packages/supervisor-process` | the default `Supervisor`: one host process per service, restart with backoff, logs |
|
|
176
|
+
| `packages/plugin-http` | the HTTP test channel: a webhook in, sends out, a page that drives the whole host |
|
|
177
|
+
| `packages/prompt` | typed blocks, the one deterministic renderer and the replaceable text table |
|
|
178
|
+
| `packages/agent-pi` | the default `AgentBackend`: pi-agent-core in process |
|
|
179
|
+
| `packages/tools` | the entry points: direct tools, code mode modules, the host API |
|
|
180
|
+
| `packages/core` | the service layer: one implementation of every operation - the window lifecycle, dispatch, coordination, the board, notes access, workspaces, effects, schedules and the snapshot epochs |
|
|
181
|
+
|
|
182
|
+
`mise.toml` pins the toolchain - Node and pnpm - installed under
|
|
183
|
+
`~/.local/share/mise`, and `.agents/setup` prepares a machine from it. A machine
|
|
184
|
+
without mise is expected to carry Node 24 or newer and pnpm 12 already.
|
|
185
|
+
|
|
186
|
+
## License
|
|
187
|
+
|
|
188
|
+
MIT, see [LICENSE](LICENSE).
|
package/bound.d.ts
ADDED
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Bounded results (docs/prompting.md#information-density).
|
|
3
|
+
*
|
|
4
|
+
* Every read is bounded by one head-and-tail cut in the tool pipeline, with a
|
|
5
|
+
* marker that says how much was cut and where the whole is kept in the
|
|
6
|
+
* workspace. A limit that shapes a result without refusing is reported by the
|
|
7
|
+
* result every time.
|
|
8
|
+
*/
|
|
9
|
+
import type { TextTable } from "@4fu/arbor-interfaces";
|
|
10
|
+
/** How much of one result the model reads, after the cut. */
|
|
11
|
+
export declare const RESULT_LIMIT = 20000;
|
|
12
|
+
export interface Bound {
|
|
13
|
+
text: string;
|
|
14
|
+
/** True when the cut removed something. */
|
|
15
|
+
truncated: boolean;
|
|
16
|
+
/** The whole result's length, before the cut. */
|
|
17
|
+
total: number;
|
|
18
|
+
head: number;
|
|
19
|
+
tail: number;
|
|
20
|
+
}
|
|
21
|
+
/** One head-and-tail cut. */
|
|
22
|
+
export declare function boundText(text: string, limit?: number): Bound;
|
|
23
|
+
/**
|
|
24
|
+
* Bound one result and keep the whole of it in the workspace, so the marker can
|
|
25
|
+
* name where. A window without a workspace gets the cut with no path.
|
|
26
|
+
*/
|
|
27
|
+
export declare function boundResult(text: string, options: {
|
|
28
|
+
limit?: number;
|
|
29
|
+
workspace?: string;
|
|
30
|
+
callId?: string;
|
|
31
|
+
table?: TextTable;
|
|
32
|
+
}): Promise<string>;
|
|
33
|
+
/** The exact call that pages the rest of a read (docs/prompting.md). */
|
|
34
|
+
export declare function nextCall(name: string, offset: number, limit: number): string;
|
package/bound.js
ADDED
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Bounded results (docs/prompting.md#information-density).
|
|
3
|
+
*
|
|
4
|
+
* Every read is bounded by one head-and-tail cut in the tool pipeline, with a
|
|
5
|
+
* marker that says how much was cut and where the whole is kept in the
|
|
6
|
+
* workspace. A limit that shapes a result without refusing is reported by the
|
|
7
|
+
* result every time.
|
|
8
|
+
*/
|
|
9
|
+
import { mkdir, writeFile } from "node:fs/promises";
|
|
10
|
+
import { join } from "node:path";
|
|
11
|
+
import { textOf } from "@4fu/arbor-prompt";
|
|
12
|
+
/** How much of one result the model reads, after the cut. */
|
|
13
|
+
export const RESULT_LIMIT = 20000;
|
|
14
|
+
/** How much of what is kept comes from the tail, so the end is not lost. */
|
|
15
|
+
const TAIL_SHARE = 0.25;
|
|
16
|
+
/** One head-and-tail cut. */
|
|
17
|
+
export function boundText(text, limit = RESULT_LIMIT) {
|
|
18
|
+
if (text.length <= limit) {
|
|
19
|
+
return { text, truncated: false, total: text.length, head: text.length, tail: 0 };
|
|
20
|
+
}
|
|
21
|
+
const head = Math.floor(limit * (1 - TAIL_SHARE));
|
|
22
|
+
const tail = limit - head;
|
|
23
|
+
return {
|
|
24
|
+
text: `${text.slice(0, head)}\n${text.slice(text.length - tail)}`,
|
|
25
|
+
truncated: true,
|
|
26
|
+
total: text.length,
|
|
27
|
+
head,
|
|
28
|
+
tail,
|
|
29
|
+
};
|
|
30
|
+
}
|
|
31
|
+
/**
|
|
32
|
+
* Bound one result and keep the whole of it in the workspace, so the marker can
|
|
33
|
+
* name where. A window without a workspace gets the cut with no path.
|
|
34
|
+
*/
|
|
35
|
+
export async function boundResult(text, options) {
|
|
36
|
+
const bound = boundText(text, options.limit);
|
|
37
|
+
if (!bound.truncated)
|
|
38
|
+
return bound.text;
|
|
39
|
+
const table = options.table ?? {};
|
|
40
|
+
const marker = textOf(table, "result.bounded", {
|
|
41
|
+
head: bound.head,
|
|
42
|
+
tail: bound.tail,
|
|
43
|
+
total: bound.total,
|
|
44
|
+
});
|
|
45
|
+
if (options.workspace === undefined || options.callId === undefined)
|
|
46
|
+
return `${bound.text}\n${marker}`;
|
|
47
|
+
const path = join(options.workspace, ".arbor", "outputs", `${options.callId}.txt`);
|
|
48
|
+
await mkdir(join(options.workspace, ".arbor", "outputs"), { recursive: true });
|
|
49
|
+
await writeFile(path, text, "utf8");
|
|
50
|
+
return `${bound.text}\n${marker}\n${textOf(table, "result.bounded.path", { path })}`;
|
|
51
|
+
}
|
|
52
|
+
/** The exact call that pages the rest of a read (docs/prompting.md). */
|
|
53
|
+
export function nextCall(name, offset, limit) {
|
|
54
|
+
return `${name}({ offset: ${offset + limit}, limit: ${limit} })`;
|
|
55
|
+
}
|
package/catalog.d.ts
ADDED
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The one tool catalog: every direct tool and every code mode module, and the
|
|
3
|
+
* two paths that reach the service layer (docs/tools.md).
|
|
4
|
+
*
|
|
5
|
+
* A direct tool and a code mode function are thin wrappers of one service call:
|
|
6
|
+
* same arguments, same behaviour, same errors. `tool_search` and `tool_describe`
|
|
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.
|
|
9
|
+
*/
|
|
10
|
+
import type { CallContext, DirectToolSpec, EnvItem, FunctionSpec, ModuleSpec, ToolCall, ToolGroup, ToolResult } from "@4fu/arbor-interfaces";
|
|
11
|
+
import { type CodeExecContext, type ScriptCatalog } from "./code-exec.ts";
|
|
12
|
+
import type { ToolServices } from "./host.ts";
|
|
13
|
+
export interface ToolCatalogOptions {
|
|
14
|
+
services: ToolServices;
|
|
15
|
+
/** Extra groups, from a plugin. */
|
|
16
|
+
groups?: readonly ToolGroup[];
|
|
17
|
+
}
|
|
18
|
+
export declare class ToolCatalog implements ToolGroup, ScriptCatalog {
|
|
19
|
+
readonly id = "catalog";
|
|
20
|
+
private readonly services;
|
|
21
|
+
private readonly groups;
|
|
22
|
+
constructor(options: ToolCatalogOptions);
|
|
23
|
+
/** Every direct tool, one line each in the prompt. */
|
|
24
|
+
tools(): readonly DirectToolSpec[];
|
|
25
|
+
/**
|
|
26
|
+
* Every module, one line each in the prompt. A name is declared once: every
|
|
27
|
+
* group that declares it lists its functions together, so a group that adds a
|
|
28
|
+
* function to a module the core already declares - an implementation's own
|
|
29
|
+
* capability, or an MCP server named after a built-in module - adds no second
|
|
30
|
+
* line for it.
|
|
31
|
+
*/
|
|
32
|
+
modules(): readonly ModuleSpec[];
|
|
33
|
+
/** Every function the modules declare. */
|
|
34
|
+
functions(): readonly FunctionSpec[];
|
|
35
|
+
/** The direct tool names, which a script may not call. */
|
|
36
|
+
directTools(): readonly string[];
|
|
37
|
+
/** One call, through the group that owns it. */
|
|
38
|
+
call(request: ToolCall, context: CallContext): Promise<ToolResult>;
|
|
39
|
+
/** One `module.function` call from a script: the same path as a direct call. */
|
|
40
|
+
callModule(name: string, args: Record<string, unknown>, context: CodeExecContext): Promise<ToolResult>;
|
|
41
|
+
/**
|
|
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).
|
|
46
|
+
*/
|
|
47
|
+
search(keywords?: string): {
|
|
48
|
+
modules: readonly string[];
|
|
49
|
+
functions: readonly string[];
|
|
50
|
+
skills: readonly EnvItem[];
|
|
51
|
+
};
|
|
52
|
+
/** `tool_describe`: one function's signature and description. */
|
|
53
|
+
describe(name: string): string | undefined;
|
|
54
|
+
/** `code_exec`: run one script against this catalog. */
|
|
55
|
+
codeExec(script: string, context: CodeExecContext): Promise<ToolResult>;
|
|
56
|
+
/** `tool_search` as a direct call: one block per name it answers. */
|
|
57
|
+
private toolSearch;
|
|
58
|
+
/** The guard a script goes through, exposed for tests. */
|
|
59
|
+
decisionIn(script: string): string | undefined;
|
|
60
|
+
}
|
package/catalog.js
ADDED
|
@@ -0,0 +1,224 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The one tool catalog: every direct tool and every code mode module, and the
|
|
3
|
+
* two paths that reach the service layer (docs/tools.md).
|
|
4
|
+
*
|
|
5
|
+
* A direct tool and a code mode function are thin wrappers of one service call:
|
|
6
|
+
* same arguments, same behaviour, same errors. `tool_search` and `tool_describe`
|
|
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.
|
|
9
|
+
*/
|
|
10
|
+
import { textOf } from "@4fu/arbor-prompt";
|
|
11
|
+
import { directToolIn, runScript } from "./code-exec.js";
|
|
12
|
+
import { CoreToolGroup } from "./groups/core.js";
|
|
13
|
+
import { EnvToolGroup } from "./groups/env.js";
|
|
14
|
+
import { FileToolGroup } from "./groups/files.js";
|
|
15
|
+
import { NoteToolGroup } from "./groups/notes.js";
|
|
16
|
+
import { BoardModuleGroup, ChannelsModuleGroup, EnvModuleGroup, JournalModuleGroup, NotesModuleGroup, ScheduleModuleGroup, TasksModuleGroup, WindowsModuleGroup, WorkspaceModuleGroup, } from "./modules/modules.js";
|
|
17
|
+
export class ToolCatalog {
|
|
18
|
+
id = "catalog";
|
|
19
|
+
services;
|
|
20
|
+
groups;
|
|
21
|
+
constructor(options) {
|
|
22
|
+
this.services = options.services;
|
|
23
|
+
this.groups = [
|
|
24
|
+
new CoreToolGroup(options.services),
|
|
25
|
+
new FileToolGroup(options.services),
|
|
26
|
+
new NoteToolGroup(options.services),
|
|
27
|
+
new JournalModuleGroup(options.services),
|
|
28
|
+
new WindowsModuleGroup(options.services),
|
|
29
|
+
new BoardModuleGroup(options.services),
|
|
30
|
+
new NotesModuleGroup(options.services),
|
|
31
|
+
new ChannelsModuleGroup(options.services),
|
|
32
|
+
new TasksModuleGroup(options.services),
|
|
33
|
+
new ScheduleModuleGroup(options.services),
|
|
34
|
+
new WorkspaceModuleGroup(options.services),
|
|
35
|
+
new EnvToolGroup(options.services),
|
|
36
|
+
new EnvModuleGroup(options.services),
|
|
37
|
+
// What the workspace implementation contributes: a capability the core
|
|
38
|
+
// does not name, such as showing another window's directory.
|
|
39
|
+
...(options.services.workspace?.groups?.(options.services.table) ?? []),
|
|
40
|
+
...(options.groups ?? []),
|
|
41
|
+
];
|
|
42
|
+
}
|
|
43
|
+
/** Every direct tool, one line each in the prompt. */
|
|
44
|
+
tools() {
|
|
45
|
+
const table = this.services.table;
|
|
46
|
+
const entryPoints = [
|
|
47
|
+
{
|
|
48
|
+
name: "code_exec",
|
|
49
|
+
description: lookup(table, "tool.code_exec", {}),
|
|
50
|
+
parameters: { script: { type: "string", description: lookup(table, "param.script", {}), required: true } },
|
|
51
|
+
},
|
|
52
|
+
{
|
|
53
|
+
name: "tool_search",
|
|
54
|
+
description: lookup(table, "tool.tool_search", {}),
|
|
55
|
+
parameters: { keywords: { type: "string", description: lookup(table, "param.keywords", {}) } },
|
|
56
|
+
},
|
|
57
|
+
];
|
|
58
|
+
return [...entryPoints, ...this.groups.flatMap((group) => group.tools())];
|
|
59
|
+
}
|
|
60
|
+
/**
|
|
61
|
+
* Every module, one line each in the prompt. A name is declared once: every
|
|
62
|
+
* group that declares it lists its functions together, so a group that adds a
|
|
63
|
+
* function to a module the core already declares - an implementation's own
|
|
64
|
+
* capability, or an MCP server named after a built-in module - adds no second
|
|
65
|
+
* line for it.
|
|
66
|
+
*/
|
|
67
|
+
modules() {
|
|
68
|
+
const merged = new Map();
|
|
69
|
+
for (const module of this.groups.flatMap((group) => group.modules())) {
|
|
70
|
+
const found = merged.get(module.name);
|
|
71
|
+
if (found === undefined) {
|
|
72
|
+
merged.set(module.name, module);
|
|
73
|
+
continue;
|
|
74
|
+
}
|
|
75
|
+
const declared = new Set(found.functions.map((fn) => fn.name));
|
|
76
|
+
merged.set(module.name, {
|
|
77
|
+
...found,
|
|
78
|
+
functions: [...found.functions, ...module.functions.filter((fn) => !declared.has(fn.name))],
|
|
79
|
+
});
|
|
80
|
+
}
|
|
81
|
+
return [...merged.values()];
|
|
82
|
+
}
|
|
83
|
+
/** Every function the modules declare. */
|
|
84
|
+
functions() {
|
|
85
|
+
return this.modules().flatMap((module) => module.functions);
|
|
86
|
+
}
|
|
87
|
+
/** The direct tool names, which a script may not call. */
|
|
88
|
+
directTools() {
|
|
89
|
+
return this.tools().map((tool) => tool.name);
|
|
90
|
+
}
|
|
91
|
+
/** One call, through the group that owns it. */
|
|
92
|
+
async call(request, context) {
|
|
93
|
+
// A removal never changes a prompt, but a call into what it removed is
|
|
94
|
+
// refused with the ref of the change that removed it
|
|
95
|
+
// (docs/environment.md#deltas-in-a-window).
|
|
96
|
+
const removed = this.services.environment === undefined
|
|
97
|
+
? undefined
|
|
98
|
+
: this.services.envCatalog().removed.find((one) => one.name === request.name);
|
|
99
|
+
if (removed !== undefined) {
|
|
100
|
+
return {
|
|
101
|
+
callId: request.callId,
|
|
102
|
+
ok: false,
|
|
103
|
+
content: "",
|
|
104
|
+
error: {
|
|
105
|
+
rule: "env.removed",
|
|
106
|
+
message: textOf(this.services.table, "refused.env.removed", { name: request.name, ref: removed.ref }),
|
|
107
|
+
nextAction: textOf(this.services.table, "next.env.removed"),
|
|
108
|
+
},
|
|
109
|
+
};
|
|
110
|
+
}
|
|
111
|
+
if (request.name === "code_exec") {
|
|
112
|
+
return this.codeExec(stringArg(request.args, "script"), {
|
|
113
|
+
window: context.window,
|
|
114
|
+
...(context.task === undefined ? {} : { task: context.task }),
|
|
115
|
+
...(context.workspace === undefined ? {} : { workspace: context.workspace.path }),
|
|
116
|
+
...(context.envRevision === undefined ? {} : { envRevision: context.envRevision }),
|
|
117
|
+
...(context.call === undefined ? {} : { call: context.call }),
|
|
118
|
+
});
|
|
119
|
+
}
|
|
120
|
+
if (request.name === "tool_search")
|
|
121
|
+
return this.toolSearch(request);
|
|
122
|
+
const owner = this.groups.find((group) => group.tools().some((tool) => tool.name === request.name) ||
|
|
123
|
+
group.modules().some((module) => module.functions.some((fn) => fn.name === request.name)));
|
|
124
|
+
if (owner === undefined) {
|
|
125
|
+
return {
|
|
126
|
+
callId: request.callId,
|
|
127
|
+
ok: false,
|
|
128
|
+
content: "",
|
|
129
|
+
error: {
|
|
130
|
+
rule: "tool.unknown",
|
|
131
|
+
message: textOf(this.services.table, "refused.unknown_tool", { name: request.name }),
|
|
132
|
+
nextAction: textOf(this.services.table, "next.unknown_tool"),
|
|
133
|
+
},
|
|
134
|
+
};
|
|
135
|
+
}
|
|
136
|
+
return owner.call(request, context);
|
|
137
|
+
}
|
|
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 });
|
|
141
|
+
}
|
|
142
|
+
/**
|
|
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).
|
|
147
|
+
*/
|
|
148
|
+
search(keywords) {
|
|
149
|
+
const modules = this.modules();
|
|
150
|
+
const functions = this.functions();
|
|
151
|
+
const skills = this.services.environment === undefined ? [] : this.services.envCatalog().items.filter((item) => item.kind === "skill");
|
|
152
|
+
if (keywords === undefined || keywords.trim() === "") {
|
|
153
|
+
return { modules: modules.map((module) => module.name), functions: [], skills };
|
|
154
|
+
}
|
|
155
|
+
const wanted = keywords.trim();
|
|
156
|
+
const named = modules.filter((module) => module.name === wanted);
|
|
157
|
+
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: [] };
|
|
160
|
+
}
|
|
161
|
+
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)),
|
|
172
|
+
};
|
|
173
|
+
}
|
|
174
|
+
/** `tool_describe`: one function's signature and description. */
|
|
175
|
+
describe(name) {
|
|
176
|
+
const found = this.functions().find((fn) => fn.name === name);
|
|
177
|
+
return found === undefined ? undefined : `${found.signature} - ${found.description}`;
|
|
178
|
+
}
|
|
179
|
+
/** `code_exec`: run one script against this catalog. */
|
|
180
|
+
async codeExec(script, context) {
|
|
181
|
+
return runScript(script, this, this.services, context);
|
|
182
|
+
}
|
|
183
|
+
/** `tool_search` as a direct call: one block per name it answers. */
|
|
184
|
+
toolSearch(request) {
|
|
185
|
+
const table = this.services.table;
|
|
186
|
+
const keywords = stringArg(request.args, "keywords");
|
|
187
|
+
const found = this.search(keywords === "" ? undefined : keywords);
|
|
188
|
+
const lines = [];
|
|
189
|
+
if (found.modules.length > 0) {
|
|
190
|
+
lines.push(textOf(table, "result.tool_search.modules", { names: found.modules.join(", ") }));
|
|
191
|
+
}
|
|
192
|
+
for (const name of found.functions) {
|
|
193
|
+
lines.push(this.describe(name) ?? name);
|
|
194
|
+
}
|
|
195
|
+
// A skill is used by reading it, so the answer names its path.
|
|
196
|
+
for (const skill of found.skills) {
|
|
197
|
+
if (skill.path === undefined)
|
|
198
|
+
continue;
|
|
199
|
+
lines.push(textOf(table, "result.tool_search.skill", { name: skill.name, description: skill.description, path: skill.path }));
|
|
200
|
+
}
|
|
201
|
+
return {
|
|
202
|
+
callId: request.callId,
|
|
203
|
+
ok: true,
|
|
204
|
+
content: lines.length === 0 ? textOf(table, "result.tool_search.none") : lines.join("\n"),
|
|
205
|
+
};
|
|
206
|
+
}
|
|
207
|
+
/** The guard a script goes through, exposed for tests. */
|
|
208
|
+
decisionIn(script) {
|
|
209
|
+
return directToolIn(script, this.directTools());
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
function stringArg(args, name) {
|
|
213
|
+
const value = args[name];
|
|
214
|
+
return typeof value === "string" ? value : "";
|
|
215
|
+
}
|
|
216
|
+
function lookup(table, key, args) {
|
|
217
|
+
const template = table[key];
|
|
218
|
+
if (template === undefined)
|
|
219
|
+
return "";
|
|
220
|
+
return template.replace(/\{(\w+)\}/g, (match, name) => {
|
|
221
|
+
const value = args[name];
|
|
222
|
+
return value === undefined ? match : String(value);
|
|
223
|
+
});
|
|
224
|
+
}
|
package/code-exec.d.ts
ADDED
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `code_exec`: everything that is not a decision runs from a script
|
|
3
|
+
* (docs/tools.md#rules).
|
|
4
|
+
*
|
|
5
|
+
* A script that names a direct tool is refused with the rule and never reaches
|
|
6
|
+
* the host. Calls from a script go through the same hooks as direct calls, so
|
|
7
|
+
* anchors, watermarks, quotas, validation and logging behave the same: every
|
|
8
|
+
* module function is the same service call the direct tool makes.
|
|
9
|
+
*
|
|
10
|
+
* Two lookups are script globals: `tool_search` takes keywords, an exact
|
|
11
|
+
* `module.function` name, or nothing for every module; `tool_describe` returns
|
|
12
|
+
* one signature. Both read the catalog frozen with the window plus the
|
|
13
|
+
* environment deltas since.
|
|
14
|
+
*/
|
|
15
|
+
import type { ModuleSpec, ToolResult, WindowRef } from "@4fu/arbor-interfaces";
|
|
16
|
+
import type { ToolServices } from "./host.ts";
|
|
17
|
+
/** How long one script may run before it is stopped. */
|
|
18
|
+
declare const SCRIPT_TIMEOUT_MS = 60000;
|
|
19
|
+
export interface CodeExecContext {
|
|
20
|
+
window: WindowRef;
|
|
21
|
+
workspace?: string;
|
|
22
|
+
task?: string;
|
|
23
|
+
envRevision?: string;
|
|
24
|
+
/** The event the `code_exec` call itself is recorded at. */
|
|
25
|
+
call?: string;
|
|
26
|
+
}
|
|
27
|
+
/** The catalog a script calls through. */
|
|
28
|
+
export interface ScriptCatalog {
|
|
29
|
+
modules(): readonly ModuleSpec[];
|
|
30
|
+
/** Run one `module.function` call, the same path a direct call takes. */
|
|
31
|
+
callModule(name: string, args: Record<string, unknown>, context: CodeExecContext): Promise<ToolResult>;
|
|
32
|
+
/** `tool_search`: keywords, an exact name, or nothing for every module. */
|
|
33
|
+
search(keywords?: string): {
|
|
34
|
+
modules: readonly string[];
|
|
35
|
+
functions: readonly string[];
|
|
36
|
+
skills: readonly {
|
|
37
|
+
name: string;
|
|
38
|
+
description: string;
|
|
39
|
+
path?: string;
|
|
40
|
+
}[];
|
|
41
|
+
};
|
|
42
|
+
/** `tool_describe`: one function's signature. */
|
|
43
|
+
describe(name: string): string | undefined;
|
|
44
|
+
/** The direct tool names a script may not call. */
|
|
45
|
+
directTools(): readonly string[];
|
|
46
|
+
}
|
|
47
|
+
/** Run one script in its own context, with the modules as globals. */
|
|
48
|
+
export declare function runScript(script: string, catalog: ScriptCatalog, services: ToolServices, context: CodeExecContext): Promise<ToolResult>;
|
|
49
|
+
/**
|
|
50
|
+
* 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.
|
|
52
|
+
*/
|
|
53
|
+
export declare function directToolIn(script: string, tools: readonly string[]): string | undefined;
|
|
54
|
+
export { SCRIPT_TIMEOUT_MS };
|
|
55
|
+
export type { WindowRef };
|