@4fu/arbor-workspace-dir 2026.1106.0 → 2026.1107.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -27,9 +27,11 @@ everything that takes judgment, such as git, merges and conflicts, to the agent.
27
27
  guarantees what an agent cannot guarantee for itself - the record, isolation,
28
28
  exactly-once effects, the checks, dispatch. Everything that takes judgment,
29
29
  such as git, merges and conflicts, is the agent's.
30
- - **A directory per window.** Each window gets its own directory, and a
31
- continuation keeps working in its predecessor's directory in place. It is not
32
- bound to a repository; the agent clones and pushes what it needs.
30
+ - **A shared workspace with per-window checkouts.** The default workspace is
31
+ ovnfs: one shared folder every window reads, and a window opens its own
32
+ copy-on-write checkout of the project it needs, works there and merges back
33
+ with `apply`. It falls back to a plain directory per window when ovnfs is not
34
+ installed. It is not a sandbox: ovnfs isolates files, not processes.
33
35
  - **A self-changing environment.** The model adds and changes skills, MCP
34
36
  servers and resident services itself; a `reload` validates the change and
35
37
  broadcasts it to every live window as a delta of names and descriptions.
@@ -54,7 +56,9 @@ npm install @4fu/arbor-core @4fu/arbor-journal-sqlite @4fu/arbor-notes-sqlite \
54
56
  ```
55
57
 
56
58
  `@4fu/arbor-plugin-http` is optional; it is the example plugin below.
57
- `@4fu/arbor-segmenter-jieba` is an optional peer of the journal.
59
+ `@4fu/arbor-segmenter-jieba` is an optional peer of the lexical package, and
60
+ `@4fu/arbor-search-zvec` an optional peer of the journal: absent either one, the
61
+ journal searches lexically and nothing else changes.
58
62
 
59
63
  ## A minimal host
60
64
 
@@ -118,7 +122,10 @@ window gets, and how a continuation keeps working in it, are in
118
122
  | [`@4fu/arbor-journal-sqlite`](packages/journal-sqlite) | the default `Journal` and `BlobStore` on SQLite |
119
123
  | [`@4fu/arbor-notes-sqlite`](packages/notes-sqlite) | the default `NoteStore` on SQLite, beside the journal |
120
124
  | [`@4fu/arbor-segmenter-jieba`](packages/segmenter-jieba) | the default `Segmenter`: jieba's dictionary segmentation for the word index, optional |
121
- | [`@4fu/arbor-workspace-dir`](packages/workspace-dir) | the default `Workspace`: one directory per window, with a continuation working in its predecessor's directory in place |
125
+ | [`@4fu/arbor-lexical`](packages/lexical) | the lexical search the journal and notes share: word segmentation, the FTS5 helpers and the merge order |
126
+ | [`@4fu/arbor-search-zvec`](packages/search-zvec) | the optional semantic channel: event text embedded and searched by meaning with zvec |
127
+ | [`@4fu/arbor-workspace-dir`](packages/workspace-dir) | the fallback `Workspace`: one directory per window, with a continuation working in its predecessor's directory in place |
128
+ | [`@4fu/arbor-workspace-ovnfs`](packages/workspace-ovnfs) | the default `Workspace` when ovnfs is installed: one shared folder, per-window copy-on-write checkouts the model opens and applies back |
122
129
  | [`@4fu/arbor-environment-dir`](packages/environment-dir) | the default `Environment`: one `/env` directory, a revision per item, validation and deltas |
123
130
  | [`@4fu/arbor-supervisor-process`](packages/supervisor-process) | the default `Supervisor`: one host process per service, restart with backoff, logs |
124
131
  | [`@4fu/arbor-judge-jev`](packages/judge-jev) | the default `Judge`: Jev, TypeSafe AI's System One classifier, answering the dispatch decisions |
@@ -151,7 +158,7 @@ deployment can see what it would be taking on before it adopts one.
151
158
  | [docs/coordination.md](docs/coordination.md) | the board, expectations, deliveries and broadcasts, staying current, effects |
152
159
  | [docs/environment.md](docs/environment.md) | skills, MCP servers and services: using, changing, reload, built-in skills, deltas |
153
160
  | [docs/memory.md](docs/memory.md) | notes, the active set, anchors, links |
154
- | [docs/workspaces.md](docs/workspaces.md) | per-window directories: isolation, continuity, what the agent does |
161
+ | [docs/workspaces.md](docs/workspaces.md) | the shared folder and per-window checkouts: isolation, continuity, what the agent does |
155
162
  | [docs/tools.md](docs/tools.md) | direct tools and code mode modules |
156
163
  | [docs/prompting.md](docs/prompting.md) | how every model-facing text is written: layers, notes, deliveries, errors, judge questions, terms |
157
164
  | [docs/release.md](docs/release.md) | calendar versioning, the manual release steps, how a package is published |
@@ -168,6 +175,7 @@ tarball would contain ([docs/release.md](docs/release.md)).
168
175
  | `packages/errors` | the error shape every refusal uses: the rule and the next action |
169
176
  | `packages/journal-sqlite` | the default `Journal` and `BlobStore` on SQLite |
170
177
  | `packages/segmenter-jieba` | the default `Segmenter`: jieba's dictionary segmentation for the word index, optional |
178
+ | `packages/lexical` | the lexical search the journal and notes share: word segmentation, the FTS5 helpers and the merge order |
171
179
  | `packages/notes-sqlite` | the default `NoteStore` on SQLite, beside the journal |
172
180
  | `packages/judge-jev` | the default `Judge`: Jev, TypeSafe AI's System One classifier, answering the dispatch decisions |
173
181
  | `packages/workspace-dir` | the default `Workspace`: one directory per window, with a continuation working in its predecessor's directory in place, and the lookup of another window's directory it contributes to the catalog |
@@ -3,10 +3,12 @@
3
3
  *
4
4
  * A workspace is a plain directory, not bound to any repository: the framework
5
5
  * gives isolation and continuity, and the agent does the rest. This
6
- * implementation keeps every window's directory under one root:
6
+ * implementation keeps every window's directory under one root, and beside
7
+ * them the persistent directories a window asks to keep:
7
8
  *
8
9
  * ```text
9
10
  * <root>/ws3/ the window's own directory
11
+ * <root>/keep/notes/ a persistent directory, kept past the window
10
12
  * ```
11
13
  *
12
14
  * A new window gets a directory of its own, created on first use. A
@@ -15,6 +17,14 @@
15
17
  * so in place is safe, and the chain is in the journal, so a restart recovers
16
18
  * which directory a continuation uses.
17
19
  *
20
+ * A window's own directory ends with its chain: after `workspaces.retentionMs`
21
+ * the host reclaims it. Content that has to outlive that is a **persistent
22
+ * directory** - `workspace.keep(name)`, or a plain `mkdir` under the same root -
23
+ * which lives outside the windows' own directories, so reclaim never touches it
24
+ * and a restart finds it where it was. It is recorded in the journal with the
25
+ * window that made it, so a continuation is told which ones its chain has and
26
+ * `journal.search` finds the rest.
27
+ *
18
28
  * It isolates by directory only. Another window's directory is a path on this
19
29
  * host, which a command may name and `read`, `write` and `edit` may open and
20
30
  * change; nothing here refuses a path outside the window's own directory, and
@@ -23,14 +33,26 @@
23
33
  * implementation enforces. That is not a security boundary, and the docs say
24
34
  * so: real isolation comes from an implementation that owns its execution
25
35
  * environment.
36
+ *
37
+ * It reports no open work: a window works in place and its continuation keeps
38
+ * working in the same directory, so there is no step between the two where work
39
+ * could be left unapplied, and `Workspace.openWork` is left out rather than
40
+ * answered empty (docs/workspaces.md#open-work).
26
41
  */
27
- import type { TextTable, ToolGroup, WindowRef, Workspace, WorkspaceMount, WorkspaceQuota, WorkspaceRef } from "@4fu/arbor-interfaces";
42
+ import type { Journal, PromptSection, TextTable, ToolGroup, WindowRef, Workspace, WorkspaceKeep, WorkspaceMount, WorkspaceQuota, WorkspaceRef } from "@4fu/arbor-interfaces";
28
43
  export interface DirectoryWorkspaceOptions {
29
44
  /** The root every workspace lives under. */
30
45
  root: string;
46
+ /**
47
+ * The journal, for the events a persistent directory records and for chain
48
+ * resolution. Absent leaves those off: a persistent directory still works,
49
+ * and a continuation is then told only about the ones on disk.
50
+ */
51
+ journal?: Journal;
31
52
  }
32
53
  export declare class DirectoryWorkspace implements Workspace {
33
54
  private readonly root;
55
+ private readonly journal;
34
56
  constructor(options: DirectoryWorkspaceOptions);
35
57
  /**
36
58
  * The directory a window works in, created on first use. Idempotent: the
@@ -47,6 +69,12 @@ export declare class DirectoryWorkspace implements Workspace {
47
69
  pathOfWindow(window: WindowRef): Promise<string | undefined>;
48
70
  mount(ref: WorkspaceRef): Promise<WorkspaceMount>;
49
71
  quota(workspace: WorkspaceRef): Promise<WorkspaceQuota>;
72
+ /**
73
+ * Reclaim a window's own directory. A persistent directory is not one: it
74
+ * lives under `keep`, beside the windows' directories, so reclaiming a
75
+ * window's own directory leaves every persistent directory where it is
76
+ * (docs/workspaces.md#lifecycle).
77
+ */
50
78
  reclaim(workspace: WorkspaceRef): Promise<void>;
51
79
  /**
52
80
  * The workspace a window's chain already has, or `undefined` when it has
@@ -57,16 +85,52 @@ export declare class DirectoryWorkspace implements Workspace {
57
85
  /** The directory a window works in, for the file tools. */
58
86
  pathOf(ref: WorkspaceRef): string;
59
87
  /**
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).
88
+ * The persistent directory `name` names, created on first use, and its path
89
+ * on this host. Idempotent: the name that is already there is handed back as
90
+ * it is, so a later window that wants the same place gets the same directory
91
+ * instead of a second one. The write is recorded in the journal with the
92
+ * window that made it, which is how a continuation learns which ones its
93
+ * chain has.
94
+ */
95
+ keep(window: WindowRef, name: string): Promise<string>;
96
+ /**
97
+ * The persistent directories on disk, by name, or only the ones a window's
98
+ * chain created when a window is named. The directory is the truth: one the
99
+ * model removed with `rm` is gone from the answer, and the journal only says
100
+ * who made which.
101
+ */
102
+ keeps(window?: WindowRef): Promise<readonly WorkspaceKeep[]>;
103
+ /** Every persistent directory under the keep root, by name. */
104
+ private keepsOnDisk;
105
+ /** The names a window's chain recorded a persistent directory under. */
106
+ private keptNames;
107
+ /** Every `workspace_keep` event one window recorded, paged to the end. */
108
+ private keepEvents;
109
+ /** The window and every window it continues from, newest first. */
110
+ private chainWindows;
111
+ private record;
112
+ /**
113
+ * What this implementation adds to the catalog: another window's directory by
114
+ * its window, and the persistent directories. The core declares no such
115
+ * capability of its own (docs/workspaces.md).
63
116
  */
64
117
  groups(table: TextTable): readonly ToolGroup[];
118
+ /**
119
+ * This implementation's own prompt text: work in your own directory in place,
120
+ * keep what has to outlive the window in a persistent directory, and treat
121
+ * another window's directory as read-only by convention. Its own, different
122
+ * from the ovnfs workspace's, so a host never shows the ovnfs method when it
123
+ * fell back here (docs/workspaces.md).
124
+ */
125
+ promptSection(): PromptSection;
65
126
  private workspacePath;
127
+ /** Where a persistent directory's name puts it, refusing a name that escapes. */
128
+ private keepPath;
66
129
  /**
67
130
  * The ref a window's directory has: the one it already has, or the next
68
131
  * number nothing has taken. A window's number only grows, so a directory is
69
- * never handed to a second window.
132
+ * never handed to a second window. The keep root is not a window's directory
133
+ * and is not one of the candidates.
70
134
  */
71
135
  private allocate;
72
136
  /** Resolve a ref a model may pass back into a path on this host. */
@@ -3,10 +3,12 @@
3
3
  *
4
4
  * A workspace is a plain directory, not bound to any repository: the framework
5
5
  * gives isolation and continuity, and the agent does the rest. This
6
- * implementation keeps every window's directory under one root:
6
+ * implementation keeps every window's directory under one root, and beside
7
+ * them the persistent directories a window asks to keep:
7
8
  *
8
9
  * ```text
9
10
  * <root>/ws3/ the window's own directory
11
+ * <root>/keep/notes/ a persistent directory, kept past the window
10
12
  * ```
11
13
  *
12
14
  * A new window gets a directory of its own, created on first use. A
@@ -15,6 +17,14 @@
15
17
  * so in place is safe, and the chain is in the journal, so a restart recovers
16
18
  * which directory a continuation uses.
17
19
  *
20
+ * A window's own directory ends with its chain: after `workspaces.retentionMs`
21
+ * the host reclaims it. Content that has to outlive that is a **persistent
22
+ * directory** - `workspace.keep(name)`, or a plain `mkdir` under the same root -
23
+ * which lives outside the windows' own directories, so reclaim never touches it
24
+ * and a restart finds it where it was. It is recorded in the journal with the
25
+ * window that made it, so a continuation is told which ones its chain has and
26
+ * `journal.search` finds the rest.
27
+ *
18
28
  * It isolates by directory only. Another window's directory is a path on this
19
29
  * host, which a command may name and `read`, `write` and `edit` may open and
20
30
  * change; nothing here refuses a path outside the window's own directory, and
@@ -23,15 +33,30 @@
23
33
  * implementation enforces. That is not a security boundary, and the docs say
24
34
  * so: real isolation comes from an implementation that owns its execution
25
35
  * environment.
36
+ *
37
+ * It reports no open work: a window works in place and its continuation keeps
38
+ * working in the same directory, so there is no step between the two where work
39
+ * could be left unapplied, and `Workspace.openWork` is left out rather than
40
+ * answered empty (docs/workspaces.md#open-work).
26
41
  */
27
42
  import { mkdir, readFile, readdir, rm, stat, writeFile } from "node:fs/promises";
28
43
  import { dirname, isAbsolute, join, normalize, resolve } from "node:path";
29
44
  import { arborError } from "@4fu/arbor-errors";
30
- import { WorkspacePathGroup } from "./path-module.js";
45
+ import { textOf } from "@4fu/arbor-prompt";
46
+ import { WorkspaceDirGroup } from "./module.js";
47
+ import { directorySection, directoryText } from "./section.js";
48
+ /** The directory under the root that holds the persistent directories. */
49
+ const KEEP_DIR = "keep";
50
+ /** How many `workspace_keep` events one read of the journal takes. */
51
+ const KEEP_PAGE = 200;
52
+ /** The longest a persistent directory's name may be. */
53
+ const KEEP_NAME_MAX = 64;
31
54
  export class DirectoryWorkspace {
32
55
  root;
56
+ journal;
33
57
  constructor(options) {
34
58
  this.root = options.root;
59
+ this.journal = options.journal;
35
60
  }
36
61
  /**
37
62
  * The directory a window works in, created on first use. Idempotent: the
@@ -60,8 +85,8 @@ export class DirectoryWorkspace {
60
85
  path: resolved,
61
86
  readFile: async (path) => {
62
87
  // 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).
88
+ // where it is on this host, which is how another window's directory and
89
+ // a persistent directory are read (docs/workspaces.md).
65
90
  return readFile(hostPath(resolved, path), "utf8").catch(() => null);
66
91
  },
67
92
  writeFile: async (path, text) => {
@@ -83,6 +108,12 @@ export class DirectoryWorkspace {
83
108
  bytes += size;
84
109
  return { bytes, used: bytes, files: files.size };
85
110
  }
111
+ /**
112
+ * Reclaim a window's own directory. A persistent directory is not one: it
113
+ * lives under `keep`, beside the windows' directories, so reclaiming a
114
+ * window's own directory leaves every persistent directory where it is
115
+ * (docs/workspaces.md#lifecycle).
116
+ */
86
117
  async reclaim(workspace) {
87
118
  await rm(this.workspacePath(workspace), { recursive: true, force: true });
88
119
  }
@@ -102,21 +133,149 @@ export class DirectoryWorkspace {
102
133
  pathOf(ref) {
103
134
  return this.workspacePath(ref);
104
135
  }
136
+ // ---- persistent directories -------------------------------------------
137
+ /**
138
+ * The persistent directory `name` names, created on first use, and its path
139
+ * on this host. Idempotent: the name that is already there is handed back as
140
+ * it is, so a later window that wants the same place gets the same directory
141
+ * instead of a second one. The write is recorded in the journal with the
142
+ * window that made it, which is how a continuation learns which ones its
143
+ * chain has.
144
+ */
145
+ async keep(window, name) {
146
+ const path = this.keepPath(name);
147
+ await mkdir(path, { recursive: true });
148
+ await this.record("workspace_keep", window, { name, path });
149
+ return path;
150
+ }
151
+ /**
152
+ * The persistent directories on disk, by name, or only the ones a window's
153
+ * chain created when a window is named. The directory is the truth: one the
154
+ * model removed with `rm` is gone from the answer, and the journal only says
155
+ * who made which.
156
+ */
157
+ async keeps(window) {
158
+ const onDisk = await this.keepsOnDisk();
159
+ if (window === undefined)
160
+ return onDisk;
161
+ const created = await this.keptNames(window);
162
+ return onDisk.filter((keep) => created.has(keep.name));
163
+ }
164
+ /** Every persistent directory under the keep root, by name. */
165
+ async keepsOnDisk() {
166
+ const root = join(this.root, KEEP_DIR);
167
+ let entries;
168
+ try {
169
+ entries = await readdir(root, { withFileTypes: true });
170
+ }
171
+ catch {
172
+ return [];
173
+ }
174
+ const keeps = [];
175
+ for (const entry of entries) {
176
+ if (!entry.isDirectory())
177
+ continue;
178
+ keeps.push({ name: entry.name, path: join(root, entry.name) });
179
+ }
180
+ // A listing reaches the prompt and a result line, so it is ordered rather
181
+ // than left to the filesystem.
182
+ return keeps.sort((one, other) => one.name.localeCompare(other.name));
183
+ }
184
+ /** The names a window's chain recorded a persistent directory under. */
185
+ async keptNames(window) {
186
+ const names = new Set();
187
+ if (this.journal === undefined)
188
+ return names;
189
+ for (const one of await this.chainWindows(window)) {
190
+ for (const name of await this.keepEvents(one))
191
+ names.add(name);
192
+ }
193
+ return names;
194
+ }
195
+ /** Every `workspace_keep` event one window recorded, paged to the end. */
196
+ async keepEvents(window) {
197
+ const journal = this.journal;
198
+ const names = [];
199
+ if (journal === undefined)
200
+ return names;
201
+ for (let offset = 0; offset < 10_000; offset += KEEP_PAGE) {
202
+ const page = await journal
203
+ .list({ window, kinds: ["workspace_keep"], limit: KEEP_PAGE, offset })
204
+ .catch(() => null);
205
+ if (page === null || page.items.length === 0)
206
+ return names;
207
+ for (const event of page.items) {
208
+ const name = keptName(event.body);
209
+ if (name !== undefined)
210
+ names.push(name);
211
+ }
212
+ if (offset + page.items.length >= page.total)
213
+ return names;
214
+ }
215
+ return names;
216
+ }
217
+ /** The window and every window it continues from, newest first. */
218
+ async chainWindows(window) {
219
+ const chain = [window];
220
+ const seen = new Set([window]);
221
+ let current = window;
222
+ for (let depth = 0; depth < 1000; depth += 1) {
223
+ if (this.journal === undefined)
224
+ break;
225
+ const page = await this.journal.list({ window: current, kinds: ["window_start"], limit: 1 });
226
+ const previous = continuedWindow(page.items[0]?.body);
227
+ if (previous === undefined || seen.has(previous))
228
+ break;
229
+ seen.add(previous);
230
+ chain.push(previous);
231
+ current = previous;
232
+ }
233
+ return chain;
234
+ }
235
+ async record(kind, window, body) {
236
+ if (this.journal === undefined)
237
+ return;
238
+ await this.journal.append({ kind, window, body: JSON.stringify(body) }).catch(() => { });
239
+ }
240
+ // ---- what this implementation adds ------------------------------------
105
241
  /**
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).
242
+ * What this implementation adds to the catalog: another window's directory by
243
+ * its window, and the persistent directories. The core declares no such
244
+ * capability of its own (docs/workspaces.md).
109
245
  */
110
246
  groups(table) {
111
- return [new WorkspacePathGroup(this, table)];
247
+ return [new WorkspaceDirGroup(this, { ...table, ...directoryText })];
248
+ }
249
+ /**
250
+ * This implementation's own prompt text: work in your own directory in place,
251
+ * keep what has to outlive the window in a persistent directory, and treat
252
+ * another window's directory as read-only by convention. Its own, different
253
+ * from the ovnfs workspace's, so a host never shows the ovnfs method when it
254
+ * fell back here (docs/workspaces.md).
255
+ */
256
+ promptSection() {
257
+ return directorySection(this.root);
112
258
  }
113
259
  workspacePath(ref) {
114
260
  return join(this.root, ref);
115
261
  }
262
+ /** Where a persistent directory's name puts it, refusing a name that escapes. */
263
+ keepPath(name) {
264
+ if (!/^[a-z0-9][a-z0-9._-]*$/.test(name) || name.length > KEEP_NAME_MAX) {
265
+ throw arborError({
266
+ code: "invalid",
267
+ rule: "workspace.keep.name",
268
+ message: textOf(directoryText, "refused.workspace.keep.name", { name }),
269
+ nextAction: textOf(directoryText, "next.workspace.keep.name"),
270
+ });
271
+ }
272
+ return join(this.root, KEEP_DIR, name);
273
+ }
116
274
  /**
117
275
  * The ref a window's directory has: the one it already has, or the next
118
276
  * number nothing has taken. A window's number only grows, so a directory is
119
- * never handed to a second window.
277
+ * never handed to a second window. The keep root is not a window's directory
278
+ * and is not one of the candidates.
120
279
  */
121
280
  async allocate(window) {
122
281
  const wanted = windowNumber(window);
@@ -222,3 +381,28 @@ async function listFiles(root) {
222
381
  await walk(root);
223
382
  return files;
224
383
  }
384
+ /** The window a `window_start` body names as the one it continues. */
385
+ function continuedWindow(body) {
386
+ if (body === undefined || body === null)
387
+ return undefined;
388
+ try {
389
+ const parsed = JSON.parse(body);
390
+ const previous = parsed.continuation;
391
+ return typeof previous === "string" && /^c\d+$/.test(previous) ? previous : undefined;
392
+ }
393
+ catch {
394
+ return undefined;
395
+ }
396
+ }
397
+ /** The name a `workspace_keep` body records, when it records one. */
398
+ function keptName(body) {
399
+ if (body === undefined || body === null)
400
+ return undefined;
401
+ try {
402
+ const parsed = JSON.parse(body);
403
+ return typeof parsed.name === "string" ? parsed.name : undefined;
404
+ }
405
+ catch {
406
+ return undefined;
407
+ }
408
+ }
package/index.d.ts CHANGED
@@ -3,8 +3,10 @@
3
3
  *
4
4
  * One directory per window, under one root, created on first use; a
5
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).
6
+ * ref. Beside them, the persistent directories a window asks to keep, which
7
+ * outlive the window, the retention period and a host restart. It needs nothing
8
+ * a host does not already have (docs/workspaces.md).
8
9
  */
9
10
  export { DirectoryWorkspace, type DirectoryWorkspaceOptions } from "./directory-workspace.ts";
10
- export { WorkspacePathGroup } from "./path-module.ts";
11
+ export { WorkspaceDirGroup } from "./module.ts";
12
+ export { directorySection, directoryText, DIRECTORY_METHOD_BLOCK } from "./section.ts";
package/index.js CHANGED
@@ -3,8 +3,10 @@
3
3
  *
4
4
  * One directory per window, under one root, created on first use; a
5
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).
6
+ * ref. Beside them, the persistent directories a window asks to keep, which
7
+ * outlive the window, the retention period and a host restart. It needs nothing
8
+ * a host does not already have (docs/workspaces.md).
8
9
  */
9
10
  export { DirectoryWorkspace } from "./directory-workspace.js";
10
- export { WorkspacePathGroup } from "./path-module.js";
11
+ export { WorkspaceDirGroup } from "./module.js";
12
+ export { directorySection, directoryText, DIRECTORY_METHOD_BLOCK } from "./section.js";
package/module.d.ts ADDED
@@ -0,0 +1,38 @@
1
+ /**
2
+ * The module functions the plain-directory workspace contributes
3
+ * (docs/workspaces.md#persistent-directories).
4
+ *
5
+ * `keep` and `keeps` give the model the persistent directories: a named place
6
+ * whose content outlives the window that made it, the retention period and a
7
+ * host restart, which a later window finds by name. `path` answers another
8
+ * window's own directory, by its window. The core knows nothing about either:
9
+ * a window reaches a persistent directory through these, through `journal`, or
10
+ * plainly by its path - `mkdir -p <root>/keep/<name>` makes the same directory.
11
+ *
12
+ * Every result and refusal is one text-table entry naming the rule and the next
13
+ * action. These functions live in the `workspace` module beside the core's
14
+ * `grep`, `find` and `ls`.
15
+ */
16
+ import type { CallContext, ModuleSpec, TextTable, ToolCall, ToolGroup, ToolResult } from "@4fu/arbor-interfaces";
17
+ import type { DirectoryWorkspace } from "./directory-workspace.ts";
18
+ /** `workspace.keep`, `workspace.keeps` and `workspace.path`. */
19
+ export declare class WorkspaceDirGroup implements ToolGroup {
20
+ readonly id = "workspace.dir";
21
+ private readonly workspace;
22
+ private readonly table;
23
+ constructor(workspace: DirectoryWorkspace, table: TextTable);
24
+ tools(): readonly [];
25
+ modules(): readonly ModuleSpec[];
26
+ call(request: ToolCall, context: CallContext): Promise<ToolResult>;
27
+ private keep;
28
+ private keeps;
29
+ private path;
30
+ private ok;
31
+ /**
32
+ * One refusal the workspace answered with, in the core's error shape. The rule
33
+ * names the table entry that carries its wording, exactly the way a note
34
+ * store's refusal reaches the model.
35
+ */
36
+ private refusalOf;
37
+ private notOwned;
38
+ }
package/module.js ADDED
@@ -0,0 +1,148 @@
1
+ /**
2
+ * The module functions the plain-directory workspace contributes
3
+ * (docs/workspaces.md#persistent-directories).
4
+ *
5
+ * `keep` and `keeps` give the model the persistent directories: a named place
6
+ * whose content outlives the window that made it, the retention period and a
7
+ * host restart, which a later window finds by name. `path` answers another
8
+ * window's own directory, by its window. The core knows nothing about either:
9
+ * a window reaches a persistent directory through these, through `journal`, or
10
+ * plainly by its path - `mkdir -p <root>/keep/<name>` makes the same directory.
11
+ *
12
+ * Every result and refusal is one text-table entry naming the rule and the next
13
+ * action. These functions live in the `workspace` module beside the core's
14
+ * `grep`, `find` and `ls`.
15
+ */
16
+ import { textOf } from "@4fu/arbor-prompt";
17
+ /** `workspace.keep`, `workspace.keeps` and `workspace.path`. */
18
+ export class WorkspaceDirGroup {
19
+ id = "workspace.dir";
20
+ workspace;
21
+ table;
22
+ constructor(workspace, table) {
23
+ this.workspace = workspace;
24
+ this.table = table;
25
+ }
26
+ tools() {
27
+ return [];
28
+ }
29
+ modules() {
30
+ return [
31
+ {
32
+ name: "workspace",
33
+ description: textOf(this.table, "module.workspace"),
34
+ functions: [
35
+ {
36
+ name: "workspace.keep",
37
+ signature: "workspace.keep(name)",
38
+ description: textOf(this.table, "function.workspace.keep"),
39
+ },
40
+ {
41
+ name: "workspace.keeps",
42
+ signature: "workspace.keeps()",
43
+ description: textOf(this.table, "function.workspace.keeps"),
44
+ },
45
+ {
46
+ name: "workspace.path",
47
+ signature: "workspace.path(window)",
48
+ description: textOf(this.table, "function.workspace.path"),
49
+ },
50
+ ],
51
+ },
52
+ ];
53
+ }
54
+ async call(request, context) {
55
+ switch (request.name) {
56
+ case "workspace.keep":
57
+ return this.keep(request, context);
58
+ case "workspace.keeps":
59
+ return this.keeps(request);
60
+ case "workspace.path":
61
+ return this.path(request);
62
+ default:
63
+ return this.notOwned(request);
64
+ }
65
+ }
66
+ async keep(request, context) {
67
+ const name = stringArg(request.args, "name");
68
+ try {
69
+ const path = await this.workspace.keep(context.window, name);
70
+ return this.ok(request, textOf(this.table, "result.workspace.keep", { name, path }));
71
+ }
72
+ catch (error) {
73
+ return this.refusalOf(request, error);
74
+ }
75
+ }
76
+ async keeps(request) {
77
+ const keeps = await this.workspace.keeps();
78
+ if (keeps.length === 0) {
79
+ return this.ok(request, textOf(this.table, "result.workspace.keeps.empty"));
80
+ }
81
+ const lines = keeps
82
+ .map((keep) => textOf(this.table, "result.workspace.keeps.line", { name: keep.name, path: keep.path }))
83
+ .join("\n");
84
+ return this.ok(request, textOf(this.table, "result.workspace.keeps", { lines }));
85
+ }
86
+ async path(request) {
87
+ const window = windowArg(request.args.window);
88
+ const path = await this.workspace.pathOfWindow(window);
89
+ if (path === undefined) {
90
+ return {
91
+ callId: request.callId,
92
+ ok: false,
93
+ content: "",
94
+ error: {
95
+ rule: "workspace.not_found",
96
+ message: textOf(this.table, "refused.workspace.path", { window }),
97
+ nextAction: textOf(this.table, "next.workspace.path"),
98
+ },
99
+ };
100
+ }
101
+ return {
102
+ callId: request.callId,
103
+ ok: true,
104
+ content: textOf(this.table, "result.workspace.path", { window, path }),
105
+ };
106
+ }
107
+ ok(request, content) {
108
+ return { callId: request.callId, ok: true, content };
109
+ }
110
+ /**
111
+ * One refusal the workspace answered with, in the core's error shape. The rule
112
+ * names the table entry that carries its wording, exactly the way a note
113
+ * store's refusal reaches the model.
114
+ */
115
+ refusalOf(request, error) {
116
+ const info = error;
117
+ return {
118
+ callId: request.callId,
119
+ ok: false,
120
+ content: "",
121
+ error: {
122
+ rule: info.rule ?? "workspace.keep.failed",
123
+ message: info.message ?? String(error),
124
+ ...(info.nextAction === undefined ? {} : { nextAction: info.nextAction }),
125
+ },
126
+ };
127
+ }
128
+ notOwned(request) {
129
+ return {
130
+ callId: request.callId,
131
+ ok: false,
132
+ content: "",
133
+ error: {
134
+ rule: "module.function.unknown",
135
+ message: textOf(this.table, "refused.unknown_function", { module: "workspace", name: request.name }),
136
+ nextAction: textOf(this.table, "next.unknown_function"),
137
+ },
138
+ };
139
+ }
140
+ }
141
+ function stringArg(args, name) {
142
+ const value = args[name];
143
+ return typeof value === "string" ? value : "";
144
+ }
145
+ /** The window an argument names, or `c0` when it names none. */
146
+ function windowArg(value) {
147
+ return typeof value === "string" && /^c\d+$/.test(value) ? value : "c0";
148
+ }
package/package.json CHANGED
@@ -1,8 +1,8 @@
1
1
  {
2
2
  "name": "@4fu/arbor-workspace-dir",
3
- "version": "2026.1106.0",
3
+ "version": "2026.1107.0",
4
4
  "type": "module",
5
- "description": "The plain-directory Workspace: one directory per window, with a continuation working in its predecessor's directory in place.",
5
+ "description": "The plain-directory Workspace: one directory per window, with a continuation working in its predecessor's directory in place, plus the persistent directories a window asks to keep.",
6
6
  "exports": {
7
7
  ".": {
8
8
  "types": "./index.d.ts",
@@ -16,8 +16,10 @@
16
16
  "directory-workspace.js",
17
17
  "index.d.ts",
18
18
  "index.js",
19
- "path-module.d.ts",
20
- "path-module.js"
19
+ "module.d.ts",
20
+ "module.js",
21
+ "section.d.ts",
22
+ "section.js"
21
23
  ],
22
24
  "engines": {
23
25
  "node": ">=24"
@@ -32,8 +34,8 @@
32
34
  "access": "public"
33
35
  },
34
36
  "dependencies": {
35
- "@4fu/arbor-errors": "2026.1106.0",
36
- "@4fu/arbor-interfaces": "2026.1106.0",
37
- "@4fu/arbor-prompt": "2026.1106.0"
37
+ "@4fu/arbor-errors": "2026.1107.0",
38
+ "@4fu/arbor-interfaces": "2026.1107.0",
39
+ "@4fu/arbor-prompt": "2026.1107.0"
38
40
  }
39
41
  }
package/section.d.ts ADDED
@@ -0,0 +1,25 @@
1
+ /**
2
+ * The prompt section and the model-facing text the plain-directory workspace
3
+ * contributes (docs/workspaces.md#persistent-directories).
4
+ *
5
+ * Every fixed string this implementation writes lives here, in Chinese, one
6
+ * entry per string: the prompt section that carries the method, the module
7
+ * functions' descriptions and their results and refusals. The host merges this
8
+ * table over its own when this implementation is the active workspace, so a
9
+ * host that runs ovnfs never shows this method - the ovnfs workspace carries
10
+ * its own, different text (packages/workspace-ovnfs/src/text.ts).
11
+ *
12
+ * Identifiers stay as they are: `workspace.keep`, `workspace.keeps`,
13
+ * `workspace.path`, `keep`.
14
+ */
15
+ import type { BlockKindSpec, PromptSection, TextTable } from "@4fu/arbor-interfaces";
16
+ /** The plain-directory workspace's own fixed strings. */
17
+ export declare const directoryText: TextTable;
18
+ /** The one block kind the section renders: its lines, no tag, not data. */
19
+ export declare const DIRECTORY_METHOD_BLOCK: BlockKindSpec;
20
+ /**
21
+ * The plain-directory workspace's prompt section, with the root its
22
+ * directories live under filled into the lines that name it - the same way the
23
+ * ovnfs section fills in the shared folder.
24
+ */
25
+ export declare function directorySection(root: string): PromptSection;
package/section.js ADDED
@@ -0,0 +1,74 @@
1
+ /**
2
+ * The prompt section and the model-facing text the plain-directory workspace
3
+ * contributes (docs/workspaces.md#persistent-directories).
4
+ *
5
+ * Every fixed string this implementation writes lives here, in Chinese, one
6
+ * entry per string: the prompt section that carries the method, the module
7
+ * functions' descriptions and their results and refusals. The host merges this
8
+ * table over its own when this implementation is the active workspace, so a
9
+ * host that runs ovnfs never shows this method - the ovnfs workspace carries
10
+ * its own, different text (packages/workspace-ovnfs/src/text.ts).
11
+ *
12
+ * Identifiers stay as they are: `workspace.keep`, `workspace.keeps`,
13
+ * `workspace.path`, `keep`.
14
+ */
15
+ /** The plain-directory workspace's own fixed strings. */
16
+ export const directoryText = {
17
+ // ---- the prompt section the implementation contributes -----------------
18
+ "workspace.method.directory": "你的工作区是本主机上的一个目录,随窗口创建;续窗口在原处继续用它,克隆的仓库、装好的依赖和未完成的工作都留着。",
19
+ "workspace.method.persistent": "要留下跨窗口的内容,就用 workspace.keep(\"<名称>\") 建一个常驻目录:它在本主机的 {root}/keep/<名称>,不随窗口结束、回收和主机重启删除,后来的窗口用同一个名称找到它。",
20
+ "workspace.method.persistent.plain": "常驻目录也可以用 bash 直接建:mkdir -p {root}/keep/<名称>;模块函数只是给出路径,并把它记进记录。",
21
+ "workspace.method.persistent.shared": "常驻目录按约定共享:按名称写入它该放的内容,不要覆盖别人的东西。",
22
+ "workspace.method.persistent.find": "用 workspace.keeps() 列出所有常驻目录,用 read、write、edit 和 bash 按路径直接用它;续窗口的首次输入会列出本窗口链建过的常驻目录。",
23
+ "workspace.method.readonly": "按约定,别的窗口的目录只读,不要修改;要协作就靠仓库自己的流程(push、review、CI),框架不替你合并文件。",
24
+ "workspace.method.means": "read、write、edit 和 bash 都在这个目录里工作,bash 也在这里跑后台作业。",
25
+ // ---- the module functions this implementation adds --------------------
26
+ "function.workspace.keep": "建一个常驻目录并返回它的路径:名称已存在时返回原来那个;里面的内容不随窗口结束、回收和主机重启删除。",
27
+ "function.workspace.keeps": "列出所有常驻目录:名称和路径,按名称排序。",
28
+ "function.workspace.path": "按窗口引用查询它的工作区目录:该目录属于其他窗口,只读,不要修改。",
29
+ // ---- results ----------------------------------------------------------
30
+ "result.workspace.keep": "常驻目录 {name}:{path}。它不随窗口结束、回收和主机重启删除,后来的窗口用同一个名称找到它。",
31
+ "result.workspace.keeps": "常驻目录:{lines}",
32
+ "result.workspace.keeps.empty": "还没有常驻目录;用 workspace.keep 建一个。",
33
+ "result.workspace.keeps.line": "{name} {path}",
34
+ "result.workspace.path": "{path}(窗口 {window} 的工作区,属于其他窗口:只读,不要修改)",
35
+ // ---- refusals ---------------------------------------------------------
36
+ "refused.workspace.keep.name": "常驻目录名 {name} 不符合约定:用小写字母或数字开头,只用小写字母、数字、.、_ 和 -,最长 64 个字符。",
37
+ "refused.workspace.path": "窗口 {window} 没有工作区。",
38
+ // ---- next actions -----------------------------------------------------
39
+ "next.workspace.keep.name": "换一个名称,例如 build-cache 或 shared-notes。",
40
+ "next.workspace.path": "该窗口没有操作过文件;用 windows.list 查看当前的窗口。",
41
+ // ---- the continuation's first input -----------------------------------
42
+ "continuation.keeps": "上个窗口链建过的常驻目录:{keeps}。它们不随窗口结束、回收和主机重启删除,按路径直接读写。",
43
+ };
44
+ /** The method's points, one text-table key per line, in order. */
45
+ const METHOD_LINES = [
46
+ "workspace.method.directory",
47
+ "workspace.method.persistent",
48
+ "workspace.method.persistent.plain",
49
+ "workspace.method.persistent.shared",
50
+ "workspace.method.persistent.find",
51
+ "workspace.method.readonly",
52
+ "workspace.method.means",
53
+ ];
54
+ /** The one block kind the section renders: its lines, no tag, not data. */
55
+ export const DIRECTORY_METHOD_BLOCK = {
56
+ kind: "directory_method",
57
+ data: false,
58
+ fields: [{ name: "root", type: "string", required: true }],
59
+ lines: [...METHOD_LINES],
60
+ };
61
+ /**
62
+ * The plain-directory workspace's prompt section, with the root its
63
+ * directories live under filled into the lines that name it - the same way the
64
+ * ovnfs section fills in the shared folder.
65
+ */
66
+ export function directorySection(root) {
67
+ const block = { kind: "directory_method", fields: { root } };
68
+ return {
69
+ tag: "workspace",
70
+ blocks: [DIRECTORY_METHOD_BLOCK],
71
+ text: directoryText,
72
+ content: () => [block],
73
+ };
74
+ }
package/path-module.d.ts DELETED
@@ -1,25 +0,0 @@
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 DELETED
@@ -1,80 +0,0 @@
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
- }