@4fu/arbor-workspace-dir 0.0.0-stage → 0.1.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/directory-workspace.d.ts +74 -0
- package/directory-workspace.js +224 -0
- package/index.d.ts +10 -0
- package/index.js +10 -0
- package/package.json +37 -4
- package/path-module.d.ts +25 -0
- package/path-module.js +80 -0
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).
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The plain-directory `Workspace` (docs/workspaces.md).
|
|
3
|
+
*
|
|
4
|
+
* A workspace is a plain directory, not bound to any repository: the framework
|
|
5
|
+
* gives isolation and continuity, and the agent does the rest. This
|
|
6
|
+
* implementation keeps every window's directory under one root:
|
|
7
|
+
*
|
|
8
|
+
* ```text
|
|
9
|
+
* <root>/ws3/ the window's own directory
|
|
10
|
+
* ```
|
|
11
|
+
*
|
|
12
|
+
* A new window gets a directory of its own, created on first use. A
|
|
13
|
+
* continuation works in its predecessor's directory in place, under the same
|
|
14
|
+
* ref: no copy, no link, no fork. Only one window in a chain is live at a time,
|
|
15
|
+
* so in place is safe, and the chain is in the journal, so a restart recovers
|
|
16
|
+
* which directory a continuation uses.
|
|
17
|
+
*
|
|
18
|
+
* It isolates by directory only. Another window's directory is a path on this
|
|
19
|
+
* host, which a command may name and `read`, `write` and `edit` may open and
|
|
20
|
+
* change; nothing here refuses a path outside the window's own directory, and
|
|
21
|
+
* nothing here stops `bash`. What keeps one window out of another's directory
|
|
22
|
+
* is the prompt text `workspace.path` answers with, not a rule this
|
|
23
|
+
* implementation enforces. That is not a security boundary, and the docs say
|
|
24
|
+
* so: real isolation comes from an implementation that owns its execution
|
|
25
|
+
* environment.
|
|
26
|
+
*/
|
|
27
|
+
import type { TextTable, ToolGroup, WindowRef, Workspace, WorkspaceMount, WorkspaceQuota, WorkspaceRef } from "@4fu/arbor-interfaces";
|
|
28
|
+
export interface DirectoryWorkspaceOptions {
|
|
29
|
+
/** The root every workspace lives under. */
|
|
30
|
+
root: string;
|
|
31
|
+
}
|
|
32
|
+
export declare class DirectoryWorkspace implements Workspace {
|
|
33
|
+
private readonly root;
|
|
34
|
+
constructor(options: DirectoryWorkspaceOptions);
|
|
35
|
+
/**
|
|
36
|
+
* The directory a window works in, created on first use. Idempotent: the
|
|
37
|
+
* window that already has one gets the same ref back, which is how a
|
|
38
|
+
* continuation - which asks for the window that opened its chain - lands in
|
|
39
|
+
* its predecessor's directory, in place, under the same ref.
|
|
40
|
+
*/
|
|
41
|
+
create(window: WindowRef): Promise<WorkspaceRef>;
|
|
42
|
+
/**
|
|
43
|
+
* The directory a window's workspace has, or `undefined` when the window
|
|
44
|
+
* never touched files. Another window's directory is a path on this host,
|
|
45
|
+
* which is what a window reads it by.
|
|
46
|
+
*/
|
|
47
|
+
pathOfWindow(window: WindowRef): Promise<string | undefined>;
|
|
48
|
+
mount(ref: WorkspaceRef): Promise<WorkspaceMount>;
|
|
49
|
+
quota(workspace: WorkspaceRef): Promise<WorkspaceQuota>;
|
|
50
|
+
reclaim(workspace: WorkspaceRef): Promise<void>;
|
|
51
|
+
/**
|
|
52
|
+
* The workspace a window's chain already has, or `undefined` when it has
|
|
53
|
+
* none. The host asks for it to reclaim a chain's directory after the
|
|
54
|
+
* retention period (docs/workspaces.md#lifecycle).
|
|
55
|
+
*/
|
|
56
|
+
refOf(window: WindowRef): Promise<WorkspaceRef | undefined>;
|
|
57
|
+
/** The directory a window works in, for the file tools. */
|
|
58
|
+
pathOf(ref: WorkspaceRef): string;
|
|
59
|
+
/**
|
|
60
|
+
* What this implementation adds to the catalog: another window's directory,
|
|
61
|
+
* by its window. The core declares no such capability of its own
|
|
62
|
+
* (docs/workspaces.md).
|
|
63
|
+
*/
|
|
64
|
+
groups(table: TextTable): readonly ToolGroup[];
|
|
65
|
+
private workspacePath;
|
|
66
|
+
/**
|
|
67
|
+
* The ref a window's directory has: the one it already has, or the next
|
|
68
|
+
* number nothing has taken. A window's number only grows, so a directory is
|
|
69
|
+
* never handed to a second window.
|
|
70
|
+
*/
|
|
71
|
+
private allocate;
|
|
72
|
+
/** Resolve a ref a model may pass back into a path on this host. */
|
|
73
|
+
private resolve;
|
|
74
|
+
}
|
|
@@ -0,0 +1,224 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The plain-directory `Workspace` (docs/workspaces.md).
|
|
3
|
+
*
|
|
4
|
+
* A workspace is a plain directory, not bound to any repository: the framework
|
|
5
|
+
* gives isolation and continuity, and the agent does the rest. This
|
|
6
|
+
* implementation keeps every window's directory under one root:
|
|
7
|
+
*
|
|
8
|
+
* ```text
|
|
9
|
+
* <root>/ws3/ the window's own directory
|
|
10
|
+
* ```
|
|
11
|
+
*
|
|
12
|
+
* A new window gets a directory of its own, created on first use. A
|
|
13
|
+
* continuation works in its predecessor's directory in place, under the same
|
|
14
|
+
* ref: no copy, no link, no fork. Only one window in a chain is live at a time,
|
|
15
|
+
* so in place is safe, and the chain is in the journal, so a restart recovers
|
|
16
|
+
* which directory a continuation uses.
|
|
17
|
+
*
|
|
18
|
+
* It isolates by directory only. Another window's directory is a path on this
|
|
19
|
+
* host, which a command may name and `read`, `write` and `edit` may open and
|
|
20
|
+
* change; nothing here refuses a path outside the window's own directory, and
|
|
21
|
+
* nothing here stops `bash`. What keeps one window out of another's directory
|
|
22
|
+
* is the prompt text `workspace.path` answers with, not a rule this
|
|
23
|
+
* implementation enforces. That is not a security boundary, and the docs say
|
|
24
|
+
* so: real isolation comes from an implementation that owns its execution
|
|
25
|
+
* environment.
|
|
26
|
+
*/
|
|
27
|
+
import { mkdir, readFile, readdir, rm, stat, writeFile } from "node:fs/promises";
|
|
28
|
+
import { dirname, isAbsolute, join, normalize, resolve } from "node:path";
|
|
29
|
+
import { arborError } from "@4fu/arbor-errors";
|
|
30
|
+
import { WorkspacePathGroup } from "./path-module.js";
|
|
31
|
+
export class DirectoryWorkspace {
|
|
32
|
+
root;
|
|
33
|
+
constructor(options) {
|
|
34
|
+
this.root = options.root;
|
|
35
|
+
}
|
|
36
|
+
/**
|
|
37
|
+
* The directory a window works in, created on first use. Idempotent: the
|
|
38
|
+
* window that already has one gets the same ref back, which is how a
|
|
39
|
+
* continuation - which asks for the window that opened its chain - lands in
|
|
40
|
+
* its predecessor's directory, in place, under the same ref.
|
|
41
|
+
*/
|
|
42
|
+
async create(window) {
|
|
43
|
+
const ref = await this.allocate(window);
|
|
44
|
+
await mkdir(this.workspacePath(ref), { recursive: true });
|
|
45
|
+
return ref;
|
|
46
|
+
}
|
|
47
|
+
/**
|
|
48
|
+
* The directory a window's workspace has, or `undefined` when the window
|
|
49
|
+
* never touched files. Another window's directory is a path on this host,
|
|
50
|
+
* which is what a window reads it by.
|
|
51
|
+
*/
|
|
52
|
+
async pathOfWindow(window) {
|
|
53
|
+
const ref = await this.refOf(window);
|
|
54
|
+
return ref === undefined ? undefined : this.workspacePath(ref);
|
|
55
|
+
}
|
|
56
|
+
async mount(ref) {
|
|
57
|
+
const resolved = await this.resolve(ref);
|
|
58
|
+
return {
|
|
59
|
+
ref,
|
|
60
|
+
path: resolved,
|
|
61
|
+
readFile: async (path) => {
|
|
62
|
+
// A relative path is inside this workspace. An absolute one is read
|
|
63
|
+
// where it is on this host, which is how another window's directory is
|
|
64
|
+
// read (docs/workspaces.md).
|
|
65
|
+
return readFile(hostPath(resolved, path), "utf8").catch(() => null);
|
|
66
|
+
},
|
|
67
|
+
writeFile: async (path, text) => {
|
|
68
|
+
// Nothing is refused: a relative path is this workspace's own, an
|
|
69
|
+
// absolute one is written where it names on this host
|
|
70
|
+
// (docs/workspaces.md#another-windows-directory).
|
|
71
|
+
const target = hostPath(resolved, path);
|
|
72
|
+
await mkdir(dirname(target), { recursive: true });
|
|
73
|
+
await writeFile(target, text, "utf8");
|
|
74
|
+
},
|
|
75
|
+
release: async () => { },
|
|
76
|
+
};
|
|
77
|
+
}
|
|
78
|
+
async quota(workspace) {
|
|
79
|
+
const resolved = await this.resolve(workspace);
|
|
80
|
+
const files = await listFiles(resolved);
|
|
81
|
+
let bytes = 0;
|
|
82
|
+
for (const size of files.values())
|
|
83
|
+
bytes += size;
|
|
84
|
+
return { bytes, used: bytes, files: files.size };
|
|
85
|
+
}
|
|
86
|
+
async reclaim(workspace) {
|
|
87
|
+
await rm(this.workspacePath(workspace), { recursive: true, force: true });
|
|
88
|
+
}
|
|
89
|
+
/**
|
|
90
|
+
* The workspace a window's chain already has, or `undefined` when it has
|
|
91
|
+
* none. The host asks for it to reclaim a chain's directory after the
|
|
92
|
+
* retention period (docs/workspaces.md#lifecycle).
|
|
93
|
+
*/
|
|
94
|
+
async refOf(window) {
|
|
95
|
+
const wanted = windowNumber(window);
|
|
96
|
+
if (wanted <= 0)
|
|
97
|
+
return undefined;
|
|
98
|
+
const ref = `ws${wanted}`;
|
|
99
|
+
return (await isDirectory(this.workspacePath(ref))) ? ref : undefined;
|
|
100
|
+
}
|
|
101
|
+
/** The directory a window works in, for the file tools. */
|
|
102
|
+
pathOf(ref) {
|
|
103
|
+
return this.workspacePath(ref);
|
|
104
|
+
}
|
|
105
|
+
/**
|
|
106
|
+
* What this implementation adds to the catalog: another window's directory,
|
|
107
|
+
* by its window. The core declares no such capability of its own
|
|
108
|
+
* (docs/workspaces.md).
|
|
109
|
+
*/
|
|
110
|
+
groups(table) {
|
|
111
|
+
return [new WorkspacePathGroup(this, table)];
|
|
112
|
+
}
|
|
113
|
+
workspacePath(ref) {
|
|
114
|
+
return join(this.root, ref);
|
|
115
|
+
}
|
|
116
|
+
/**
|
|
117
|
+
* The ref a window's directory has: the one it already has, or the next
|
|
118
|
+
* number nothing has taken. A window's number only grows, so a directory is
|
|
119
|
+
* never handed to a second window.
|
|
120
|
+
*/
|
|
121
|
+
async allocate(window) {
|
|
122
|
+
const wanted = windowNumber(window);
|
|
123
|
+
const own = wanted > 0 ? `ws${wanted}` : undefined;
|
|
124
|
+
if (own !== undefined && (await isDirectory(this.workspacePath(own))))
|
|
125
|
+
return own;
|
|
126
|
+
let entries = [];
|
|
127
|
+
try {
|
|
128
|
+
entries = await readdir(this.root);
|
|
129
|
+
}
|
|
130
|
+
catch {
|
|
131
|
+
entries = [];
|
|
132
|
+
}
|
|
133
|
+
let highest = 0;
|
|
134
|
+
for (const entry of entries) {
|
|
135
|
+
const number = /^ws(\d+)$/.exec(entry)?.[1];
|
|
136
|
+
if (number === undefined)
|
|
137
|
+
continue;
|
|
138
|
+
highest = Math.max(highest, Number(number));
|
|
139
|
+
}
|
|
140
|
+
return `ws${Math.max(highest + 1, wanted)}`;
|
|
141
|
+
}
|
|
142
|
+
/** Resolve a ref a model may pass back into a path on this host. */
|
|
143
|
+
async resolve(ref) {
|
|
144
|
+
if (!/^ws\d+$/.test(ref)) {
|
|
145
|
+
throw arborError({
|
|
146
|
+
code: "not_found",
|
|
147
|
+
rule: "workspace.ref.unknown",
|
|
148
|
+
message: `workspace: ${ref} 不是工作区引用`,
|
|
149
|
+
nextAction: "使用 ws3 形式的引用。",
|
|
150
|
+
});
|
|
151
|
+
}
|
|
152
|
+
const path = this.workspacePath(ref);
|
|
153
|
+
await assertDirectory(path, ref);
|
|
154
|
+
return path;
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
async function isDirectory(path) {
|
|
158
|
+
try {
|
|
159
|
+
return (await stat(path)).isDirectory();
|
|
160
|
+
}
|
|
161
|
+
catch {
|
|
162
|
+
return false;
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
function windowNumber(window) {
|
|
166
|
+
return Number(/^c(\d+)$/.exec(window)?.[1] ?? 0);
|
|
167
|
+
}
|
|
168
|
+
/**
|
|
169
|
+
* Where a path names on this host: itself when it is absolute, under `root`
|
|
170
|
+
* when it is not. Nothing is refused, because the owner asked for no
|
|
171
|
+
* restriction on where a window reads or writes
|
|
172
|
+
* (docs/workspaces.md#another-windows-directory).
|
|
173
|
+
*/
|
|
174
|
+
function hostPath(root, path) {
|
|
175
|
+
return isAbsolute(path) ? normalize(path) : resolve(root, path);
|
|
176
|
+
}
|
|
177
|
+
async function assertDirectory(path, ref) {
|
|
178
|
+
let info;
|
|
179
|
+
try {
|
|
180
|
+
info = await stat(path);
|
|
181
|
+
}
|
|
182
|
+
catch {
|
|
183
|
+
throw arborError({
|
|
184
|
+
code: "not_found",
|
|
185
|
+
rule: "workspace.not_found",
|
|
186
|
+
message: `workspace: ${ref} 不存在`,
|
|
187
|
+
nextAction: "先用工作区创建它,或换一个存在的引用。",
|
|
188
|
+
});
|
|
189
|
+
}
|
|
190
|
+
if (!info.isDirectory()) {
|
|
191
|
+
throw arborError({
|
|
192
|
+
code: "not_found",
|
|
193
|
+
rule: "workspace.not_directory",
|
|
194
|
+
message: `workspace: ${ref} 不是目录`,
|
|
195
|
+
nextAction: "换一个工作区引用。",
|
|
196
|
+
});
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
/** Every file under a directory, with its size in bytes. */
|
|
200
|
+
async function listFiles(root) {
|
|
201
|
+
const files = new Map();
|
|
202
|
+
const walk = async (directory) => {
|
|
203
|
+
let entries;
|
|
204
|
+
try {
|
|
205
|
+
entries = await readdir(directory, { withFileTypes: true });
|
|
206
|
+
}
|
|
207
|
+
catch {
|
|
208
|
+
return;
|
|
209
|
+
}
|
|
210
|
+
for (const entry of entries) {
|
|
211
|
+
const path = join(directory, entry.name);
|
|
212
|
+
if (entry.isDirectory()) {
|
|
213
|
+
await walk(path);
|
|
214
|
+
continue;
|
|
215
|
+
}
|
|
216
|
+
if (!entry.isFile())
|
|
217
|
+
continue;
|
|
218
|
+
const info = await stat(path);
|
|
219
|
+
files.set(path.slice(root.length + 1), info.size);
|
|
220
|
+
}
|
|
221
|
+
};
|
|
222
|
+
await walk(root);
|
|
223
|
+
return files;
|
|
224
|
+
}
|
package/index.d.ts
ADDED
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @4fu/arbor-workspace-dir - the plain-directory `Workspace`.
|
|
3
|
+
*
|
|
4
|
+
* One directory per window, under one root, created on first use; a
|
|
5
|
+
* continuation works in its predecessor's directory in place, under the same
|
|
6
|
+
* ref. It is the only `Workspace` the framework ships, and it needs nothing a
|
|
7
|
+
* host does not already have (docs/workspaces.md).
|
|
8
|
+
*/
|
|
9
|
+
export { DirectoryWorkspace, type DirectoryWorkspaceOptions } from "./directory-workspace.ts";
|
|
10
|
+
export { WorkspacePathGroup } from "./path-module.ts";
|
package/index.js
ADDED
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @4fu/arbor-workspace-dir - the plain-directory `Workspace`.
|
|
3
|
+
*
|
|
4
|
+
* One directory per window, under one root, created on first use; a
|
|
5
|
+
* continuation works in its predecessor's directory in place, under the same
|
|
6
|
+
* ref. It is the only `Workspace` the framework ships, and it needs nothing a
|
|
7
|
+
* host does not already have (docs/workspaces.md).
|
|
8
|
+
*/
|
|
9
|
+
export { DirectoryWorkspace } from "./directory-workspace.js";
|
|
10
|
+
export { WorkspacePathGroup } from "./path-module.js";
|
package/package.json
CHANGED
|
@@ -1,6 +1,39 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@4fu/arbor-workspace-dir",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"
|
|
5
|
-
"description": "
|
|
6
|
-
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"type": "module",
|
|
5
|
+
"description": "The plain-directory Workspace: one directory per window, with a continuation working in its predecessor's directory in place.",
|
|
6
|
+
"exports": {
|
|
7
|
+
".": {
|
|
8
|
+
"types": "./index.d.ts",
|
|
9
|
+
"default": "./index.js"
|
|
10
|
+
}
|
|
11
|
+
},
|
|
12
|
+
"types": "./index.d.ts",
|
|
13
|
+
"main": "./index.js",
|
|
14
|
+
"files": [
|
|
15
|
+
"directory-workspace.d.ts",
|
|
16
|
+
"directory-workspace.js",
|
|
17
|
+
"index.d.ts",
|
|
18
|
+
"index.js",
|
|
19
|
+
"path-module.d.ts",
|
|
20
|
+
"path-module.js"
|
|
21
|
+
],
|
|
22
|
+
"engines": {
|
|
23
|
+
"node": ">=24"
|
|
24
|
+
},
|
|
25
|
+
"license": "MIT",
|
|
26
|
+
"repository": {
|
|
27
|
+
"type": "git",
|
|
28
|
+
"url": "git+https://github.com/4fuu/arbor.git",
|
|
29
|
+
"directory": "packages/workspace-dir"
|
|
30
|
+
},
|
|
31
|
+
"publishConfig": {
|
|
32
|
+
"access": "public"
|
|
33
|
+
},
|
|
34
|
+
"dependencies": {
|
|
35
|
+
"@4fu/arbor-errors": "0.1.0",
|
|
36
|
+
"@4fu/arbor-interfaces": "0.1.0",
|
|
37
|
+
"@4fu/arbor-prompt": "0.1.0"
|
|
38
|
+
}
|
|
39
|
+
}
|
package/path-module.d.ts
ADDED
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The capability the built-in directory workspace contributes: another
|
|
3
|
+
* window's directory, by its window (docs/workspaces.md).
|
|
4
|
+
*
|
|
5
|
+
* The core knows nothing about it. A window reads another window's directory
|
|
6
|
+
* through `read`, by the path this answers, and both the description and the
|
|
7
|
+
* result say the directory belongs to another window and must not be modified.
|
|
8
|
+
* Nothing here enforces that, because the built-in workspace isolates by
|
|
9
|
+
* directory only: another window's directory is a path on the same host, which
|
|
10
|
+
* a command may name. It is not a security boundary, and real isolation comes
|
|
11
|
+
* from an implementation that owns its execution environment.
|
|
12
|
+
*/
|
|
13
|
+
import type { ModuleSpec, TextTable, ToolCall, ToolGroup, ToolResult } from "@4fu/arbor-interfaces";
|
|
14
|
+
import type { DirectoryWorkspace } from "./directory-workspace.ts";
|
|
15
|
+
/** `workspace.path`: the directory of a window's workspace, by its window. */
|
|
16
|
+
export declare class WorkspacePathGroup implements ToolGroup {
|
|
17
|
+
readonly id = "workspace.path";
|
|
18
|
+
private readonly workspace;
|
|
19
|
+
private readonly table;
|
|
20
|
+
constructor(workspace: DirectoryWorkspace, table: TextTable);
|
|
21
|
+
tools(): readonly [];
|
|
22
|
+
modules(): readonly ModuleSpec[];
|
|
23
|
+
call(request: ToolCall): Promise<ToolResult>;
|
|
24
|
+
private notOwned;
|
|
25
|
+
}
|
package/path-module.js
ADDED
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The capability the built-in directory workspace contributes: another
|
|
3
|
+
* window's directory, by its window (docs/workspaces.md).
|
|
4
|
+
*
|
|
5
|
+
* The core knows nothing about it. A window reads another window's directory
|
|
6
|
+
* through `read`, by the path this answers, and both the description and the
|
|
7
|
+
* result say the directory belongs to another window and must not be modified.
|
|
8
|
+
* Nothing here enforces that, because the built-in workspace isolates by
|
|
9
|
+
* directory only: another window's directory is a path on the same host, which
|
|
10
|
+
* a command may name. It is not a security boundary, and real isolation comes
|
|
11
|
+
* from an implementation that owns its execution environment.
|
|
12
|
+
*/
|
|
13
|
+
import { textOf } from "@4fu/arbor-prompt";
|
|
14
|
+
/** `workspace.path`: the directory of a window's workspace, by its window. */
|
|
15
|
+
export class WorkspacePathGroup {
|
|
16
|
+
id = "workspace.path";
|
|
17
|
+
workspace;
|
|
18
|
+
table;
|
|
19
|
+
constructor(workspace, table) {
|
|
20
|
+
this.workspace = workspace;
|
|
21
|
+
this.table = table;
|
|
22
|
+
}
|
|
23
|
+
tools() {
|
|
24
|
+
return [];
|
|
25
|
+
}
|
|
26
|
+
modules() {
|
|
27
|
+
return [
|
|
28
|
+
{
|
|
29
|
+
name: "workspace",
|
|
30
|
+
description: textOf(this.table, "module.workspace"),
|
|
31
|
+
functions: [
|
|
32
|
+
{
|
|
33
|
+
name: "workspace.path",
|
|
34
|
+
signature: "workspace.path(window)",
|
|
35
|
+
description: textOf(this.table, "function.workspace.path"),
|
|
36
|
+
},
|
|
37
|
+
],
|
|
38
|
+
},
|
|
39
|
+
];
|
|
40
|
+
}
|
|
41
|
+
async call(request) {
|
|
42
|
+
if (request.name !== "workspace.path")
|
|
43
|
+
return this.notOwned(request);
|
|
44
|
+
const window = windowArg(request.args.window);
|
|
45
|
+
const path = await this.workspace.pathOfWindow(window);
|
|
46
|
+
if (path === undefined) {
|
|
47
|
+
return {
|
|
48
|
+
callId: request.callId,
|
|
49
|
+
ok: false,
|
|
50
|
+
content: "",
|
|
51
|
+
error: {
|
|
52
|
+
rule: "workspace.not_found",
|
|
53
|
+
message: textOf(this.table, "refused.workspace.path", { window }),
|
|
54
|
+
nextAction: textOf(this.table, "next.workspace.path"),
|
|
55
|
+
},
|
|
56
|
+
};
|
|
57
|
+
}
|
|
58
|
+
return {
|
|
59
|
+
callId: request.callId,
|
|
60
|
+
ok: true,
|
|
61
|
+
content: textOf(this.table, "result.workspace.path", { window, path }),
|
|
62
|
+
};
|
|
63
|
+
}
|
|
64
|
+
notOwned(request) {
|
|
65
|
+
return {
|
|
66
|
+
callId: request.callId,
|
|
67
|
+
ok: false,
|
|
68
|
+
content: "",
|
|
69
|
+
error: {
|
|
70
|
+
rule: "module.function.unknown",
|
|
71
|
+
message: textOf(this.table, "refused.unknown_function", { module: "workspace", name: request.name }),
|
|
72
|
+
nextAction: textOf(this.table, "next.unknown_function"),
|
|
73
|
+
},
|
|
74
|
+
};
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
/** The window an argument names, or `c0` when it names none. */
|
|
78
|
+
function windowArg(value) {
|
|
79
|
+
return typeof value === "string" && /^c\d+$/.test(value) ? value : "c0";
|
|
80
|
+
}
|