@4fu/arbor-notes-sqlite 2026.1106.0 → 2026.1106.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -27,9 +27,11 @@ everything that takes judgment, such as git, merges and conflicts, to the agent.
27
27
  guarantees what an agent cannot guarantee for itself - the record, isolation,
28
28
  exactly-once effects, the checks, dispatch. Everything that takes judgment,
29
29
  such as git, merges and conflicts, is the agent's.
30
- - **A directory per window.** Each window gets its own directory, and a
31
- continuation keeps working in its predecessor's directory in place. It is not
32
- bound to a repository; the agent clones and pushes what it needs.
30
+ - **A shared workspace with per-window checkouts.** The default workspace is
31
+ ovnfs: one shared folder every window reads, and a window opens its own
32
+ copy-on-write checkout of the project it needs, works there and merges back
33
+ with `apply`. It falls back to a plain directory per window when ovnfs is not
34
+ installed. It is not a sandbox: ovnfs isolates files, not processes.
33
35
  - **A self-changing environment.** The model adds and changes skills, MCP
34
36
  servers and resident services itself; a `reload` validates the change and
35
37
  broadcasts it to every live window as a delta of names and descriptions.
@@ -118,7 +120,8 @@ window gets, and how a continuation keeps working in it, are in
118
120
  | [`@4fu/arbor-journal-sqlite`](packages/journal-sqlite) | the default `Journal` and `BlobStore` on SQLite |
119
121
  | [`@4fu/arbor-notes-sqlite`](packages/notes-sqlite) | the default `NoteStore` on SQLite, beside the journal |
120
122
  | [`@4fu/arbor-segmenter-jieba`](packages/segmenter-jieba) | the default `Segmenter`: jieba's dictionary segmentation for the word index, optional |
121
- | [`@4fu/arbor-workspace-dir`](packages/workspace-dir) | the default `Workspace`: one directory per window, with a continuation working in its predecessor's directory in place |
123
+ | [`@4fu/arbor-workspace-dir`](packages/workspace-dir) | the fallback `Workspace`: one directory per window, with a continuation working in its predecessor's directory in place |
124
+ | [`@4fu/arbor-workspace-ovnfs`](packages/workspace-ovnfs) | the default `Workspace` when ovnfs is installed: one shared folder, per-window copy-on-write checkouts the model opens and applies back |
122
125
  | [`@4fu/arbor-environment-dir`](packages/environment-dir) | the default `Environment`: one `/env` directory, a revision per item, validation and deltas |
123
126
  | [`@4fu/arbor-supervisor-process`](packages/supervisor-process) | the default `Supervisor`: one host process per service, restart with backoff, logs |
124
127
  | [`@4fu/arbor-judge-jev`](packages/judge-jev) | the default `Judge`: Jev, TypeSafe AI's System One classifier, answering the dispatch decisions |
@@ -151,7 +154,7 @@ deployment can see what it would be taking on before it adopts one.
151
154
  | [docs/coordination.md](docs/coordination.md) | the board, expectations, deliveries and broadcasts, staying current, effects |
152
155
  | [docs/environment.md](docs/environment.md) | skills, MCP servers and services: using, changing, reload, built-in skills, deltas |
153
156
  | [docs/memory.md](docs/memory.md) | notes, the active set, anchors, links |
154
- | [docs/workspaces.md](docs/workspaces.md) | per-window directories: isolation, continuity, what the agent does |
157
+ | [docs/workspaces.md](docs/workspaces.md) | the shared folder and per-window checkouts: isolation, continuity, what the agent does |
155
158
  | [docs/tools.md](docs/tools.md) | direct tools and code mode modules |
156
159
  | [docs/prompting.md](docs/prompting.md) | how every model-facing text is written: layers, notes, deliveries, errors, judge questions, terms |
157
160
  | [docs/release.md](docs/release.md) | calendar versioning, the manual release steps, how a package is published |
package/notes.d.ts CHANGED
@@ -46,6 +46,12 @@ export interface SqliteNoteStoreOptions {
46
46
  now?: () => number;
47
47
  /** Usage loses half of its weight this often. 24 hours. */
48
48
  usageHalfLifeMs?: number;
49
+ /**
50
+ * One fixed string from the deployment's text table, so this implementation
51
+ * writes none of its own (docs/prompting.md#language-and-the-text-table).
52
+ * Defaults to the framework's own Chinese table.
53
+ */
54
+ text?: (key: string, args?: Record<string, string | number>) => string;
49
55
  }
50
56
  /** The opening of a body, when a write carries no summary of its own. */
51
57
  export declare function deriveSummary(body: string): string;
@@ -55,6 +61,8 @@ export declare class SqliteNoteStore implements NoteStore {
55
61
  private readonly journal;
56
62
  private readonly clock;
57
63
  private readonly halfLifeMs;
64
+ /** The table every refusal this store raises is written from. */
65
+ private readonly text;
58
66
  /** One write at a time: a write spans the notes database and the journal. */
59
67
  private writeQueue;
60
68
  private closed;
@@ -121,6 +129,8 @@ export declare class SqliteNoteStore implements NoteStore {
121
129
  * the changes since reach it as a delta.
122
130
  */
123
131
  private epochAt;
132
+ /** What the active notes of one epoch cost, as the epoch froze them. */
133
+ private epochTokens;
124
134
  /** The active notes one epoch froze, by summary, sorted by id. */
125
135
  private epochSummaries;
126
136
  }
package/notes.js CHANGED
@@ -31,6 +31,7 @@ import { noteIdOf, noteRefOf, noteRefForms, parseNoteId, parseNoteRef, storedEve
31
31
  import { FTS_TABLE, createSchema } from "./schema.js";
32
32
  import { USE_WEIGHTS, applyJudgeShare, pickByBudget, scoreNotes } from "./score.js";
33
33
  import { holdingIds, substringClause } from "./search.js";
34
+ import { textOf, zhTextTable } from "@4fu/arbor-prompt";
34
35
  /** A summary is at most this many characters (docs/memory.md). */
35
36
  export const SUMMARY_LIMIT = 200;
36
37
  /** How many characters of a body one read returns. */
@@ -87,19 +88,19 @@ export function deriveSummary(body) {
87
88
  const characters = Array.from(flat);
88
89
  return characters.length <= SUMMARY_LIMIT ? flat : `${characters.slice(0, SUMMARY_LIMIT).join("")}…`;
89
90
  }
90
- function invalidNoteRef(call, ref) {
91
+ function invalidNoteRef(text, call, ref) {
91
92
  return arborError({
92
93
  code: "invalid_input",
93
94
  rule: `note.${call}.ref`,
94
- message: `note.${call}: ${ref} is not a note ref`,
95
- nextAction: "pass a note ref such as n012, or one of its revisions such as n012@3",
95
+ message: text("refused.note.ref", { ref }),
96
+ nextAction: text("next.note.ref"),
96
97
  details: { ref },
97
98
  });
98
99
  }
99
- function parseOrThrow(ref, call) {
100
+ function parseOrThrow(text, ref, call) {
100
101
  const parsed = parseNoteRef(ref);
101
102
  if (parsed === null)
102
- throw invalidNoteRef(call, ref);
103
+ throw invalidNoteRef(text, call, ref);
103
104
  return parsed;
104
105
  }
105
106
  function toSummary(row) {
@@ -164,6 +165,8 @@ export class SqliteNoteStore {
164
165
  journal;
165
166
  clock;
166
167
  halfLifeMs;
168
+ /** The table every refusal this store raises is written from. */
169
+ text;
167
170
  /** One write at a time: a write spans the notes database and the journal. */
168
171
  writeQueue = Promise.resolve();
169
172
  closed = false;
@@ -174,6 +177,7 @@ export class SqliteNoteStore {
174
177
  this.journal = options.journal;
175
178
  this.clock = options.now ?? (() => Date.now());
176
179
  this.halfLifeMs = options.usageHalfLifeMs ?? DEFAULT_HALF_LIFE_MS;
180
+ this.text = options.text ?? ((key, args) => textOf(zhTextTable, key, args));
177
181
  }
178
182
  // --- writes -------------------------------------------------------------
179
183
  async write(input) {
@@ -217,8 +221,8 @@ export class SqliteNoteStore {
217
221
  throw arborError({
218
222
  code: isArborError(error) ? error.code : "internal",
219
223
  rule: "note.write.journal",
220
- message: `note.write: ${noteRef} was not written because the journal refused the event (${detail})`,
221
- nextAction: "fix what the journal refused, then write the note again",
224
+ message: this.text("refused.note.write.journal", { ref: noteRef, detail }),
225
+ nextAction: this.text("next.note.write.journal"),
222
226
  details: { upstream: detail },
223
227
  cause: error,
224
228
  });
@@ -233,36 +237,44 @@ export class SqliteNoteStore {
233
237
  }
234
238
  /** The rules a write has to meet before it touches the one copy. */
235
239
  checkWrite(input) {
240
+ if (input.id !== undefined && input.id.trim() === "") {
241
+ throw arborError({
242
+ code: "invalid_input",
243
+ rule: "note.write.id",
244
+ message: this.text("refused.note.write.id"),
245
+ nextAction: this.text("next.note.write.id"),
246
+ });
247
+ }
236
248
  if (input.title.trim() === "") {
237
249
  throw arborError({
238
250
  code: "invalid_input",
239
251
  rule: "note.write.title",
240
- message: "note.write: a note needs a title",
241
- nextAction: "give the note a short title",
252
+ message: this.text("refused.note.write.title"),
253
+ nextAction: this.text("next.note.write.title"),
242
254
  });
243
255
  }
244
256
  if (input.delete !== true && input.body.trim() === "") {
245
257
  throw arborError({
246
258
  code: "invalid_input",
247
259
  rule: "note.write.body",
248
- message: "note.write: a note needs a body",
249
- nextAction: "write what the note holds in body; a delete needs no body",
260
+ message: this.text("refused.note.write.body"),
261
+ nextAction: this.text("next.note.write.body"),
250
262
  });
251
263
  }
252
264
  if (input.summary !== undefined && Array.from(input.summary).length > SUMMARY_LIMIT) {
253
265
  throw arborError({
254
266
  code: "invalid_input",
255
267
  rule: "note.write.summary",
256
- message: `note.write: a summary is at most ${SUMMARY_LIMIT} characters`,
257
- nextAction: `shorten the summary to at most ${SUMMARY_LIMIT} characters, or omit it`,
268
+ message: this.text("refused.note.write.summary", { limit: SUMMARY_LIMIT }),
269
+ nextAction: this.text("next.note.write.summary", { limit: SUMMARY_LIMIT }),
258
270
  });
259
271
  }
260
272
  if (input.id === undefined && input.expectedRevision !== undefined) {
261
273
  throw arborError({
262
274
  code: "invalid_input",
263
275
  rule: "note.write.expected_revision",
264
- message: "note.write: a new note has no revision to name",
265
- nextAction: "omit expectedRevision to write a new note, or pass the note's id to replace it",
276
+ message: this.text("refused.note.write.expected_revision"),
277
+ nextAction: this.text("next.note.write.expected_revision"),
266
278
  });
267
279
  }
268
280
  }
@@ -280,22 +292,22 @@ export class SqliteNoteStore {
280
292
  else {
281
293
  const parsed = parseNoteId(input.id);
282
294
  if (parsed === null)
283
- throw invalidNoteRef("write", input.id);
295
+ throw invalidNoteRef(this.text, "write", input.id);
284
296
  previous = this.noteRow(parsed);
285
297
  if (previous === null) {
286
298
  throw arborError({
287
299
  code: "not_found",
288
300
  rule: "note.write.note",
289
- message: `note.write: ${input.id} is not in the notes`,
290
- nextAction: "omit id to write a new note, or look the note up with notes.search",
301
+ message: this.text("refused.note.write.note", { id: input.id }),
302
+ nextAction: this.text("next.note.write.note"),
291
303
  });
292
304
  }
293
305
  if (previous.status === "deleted") {
294
306
  throw arborError({
295
307
  code: "not_found",
296
308
  rule: "note.write.deleted",
297
- message: `note.write: ${input.id} is deleted`,
298
- nextAction: "its revisions still resolve by ref, but it cannot be written; write a new note instead",
309
+ message: this.text("refused.note.write.deleted", { id: input.id }),
310
+ nextAction: this.text("next.note.write.deleted"),
299
311
  });
300
312
  }
301
313
  const expected = input.expectedRevision;
@@ -303,16 +315,20 @@ export class SqliteNoteStore {
303
315
  throw arborError({
304
316
  code: "invalid_input",
305
317
  rule: "note.write.revision",
306
- message: `note.write: ${input.id} is at revision ${previous.revision}; a write names the revision it read`,
307
- nextAction: `read the note and write again against revision ${previous.revision}`,
318
+ message: this.text("refused.note.write.revision", { id: input.id, revision: previous.revision }),
319
+ nextAction: this.text("next.note.write.revision", { revision: previous.revision }),
308
320
  });
309
321
  }
310
322
  if (expected > previous.revision) {
311
323
  throw arborError({
312
324
  code: "invalid_input",
313
- rule: "note.write.revision",
314
- message: `note.write: ${input.id}@${expected} does not exist; the note is at revision ${previous.revision}`,
315
- nextAction: `read the note and write again against revision ${previous.revision}`,
325
+ rule: "note.write.revision.absent",
326
+ message: this.text("refused.note.write.revision.absent", {
327
+ id: input.id,
328
+ expected,
329
+ revision: previous.revision,
330
+ }),
331
+ nextAction: this.text("next.note.write.revision.absent", { revision: previous.revision }),
316
332
  });
317
333
  }
318
334
  if (expected < previous.revision)
@@ -326,9 +342,9 @@ export class SqliteNoteStore {
326
342
  input.body !== previous.body) {
327
343
  throw arborError({
328
344
  code: "invalid_input",
329
- rule: "note.write.summary",
330
- message: `note.write: a replace that changes the body of ${input.id} names a summary`,
331
- nextAction: `write a summary of at most ${SUMMARY_LIMIT} characters for the new body`,
345
+ rule: "note.write.replace_summary",
346
+ message: this.text("refused.note.write.replace_summary", { id: input.id }),
347
+ nextAction: this.text("next.note.write.replace_summary", { limit: SUMMARY_LIMIT }),
332
348
  });
333
349
  }
334
350
  }
@@ -435,8 +451,16 @@ export class SqliteNoteStore {
435
451
  return arborError({
436
452
  code: "conflict",
437
453
  rule: "note.write.stale_revision",
438
- message: `note.write: ${noteIdOf(note.id)} is at revision ${current}, not the revision ${read} you read; merge what changed and write again`,
439
- nextAction: `read ${noteIdOf(note.id)}@${current} and the revisions since ${read}, merge them with this write, then write again against revision ${current}`,
454
+ message: this.text("refused.note.write.stale_revision", {
455
+ ref: noteRefOf(note.id, current),
456
+ current,
457
+ read,
458
+ }),
459
+ nextAction: this.text("next.note.write.stale_revision", {
460
+ ref: noteRefOf(note.id, current),
461
+ current,
462
+ read,
463
+ }),
440
464
  refs: [noteRefOf(note.id, current), ...revisions.map((revision) => revision.ref)],
441
465
  details: {
442
466
  conflict: { read, current, revisions },
@@ -447,7 +471,7 @@ export class SqliteNoteStore {
447
471
  // --- reads --------------------------------------------------------------
448
472
  async read(ref, options) {
449
473
  this.ensureOpen();
450
- const parsed = parseOrThrow(ref, "read");
474
+ const parsed = parseOrThrow(this.text, ref, "read");
451
475
  const row = parsed.revision === null ? this.latestRevisionRow(parsed.id) : this.revisionRow(parsed.id, parsed.revision);
452
476
  if (row === null)
453
477
  return null;
@@ -469,7 +493,7 @@ export class SqliteNoteStore {
469
493
  }
470
494
  async revisions(ref) {
471
495
  this.ensureOpen();
472
- const parsed = parseOrThrow(ref, "revisions");
496
+ const parsed = parseOrThrow(this.text, ref, "revisions");
473
497
  const limit = DEFAULT_PAGE_LIMIT;
474
498
  const nextCall = `notes.revisions(${noteIdOf(parsed.id)}, offset=${limit})`;
475
499
  const note = this.noteRow(parsed.id);
@@ -510,7 +534,7 @@ export class SqliteNoteStore {
510
534
  }
511
535
  async context(ref) {
512
536
  this.ensureOpen();
513
- const parsed = parseOrThrow(ref, "context");
537
+ const parsed = parseOrThrow(this.text, ref, "context");
514
538
  const note = this.noteRow(parsed.id);
515
539
  if (note === null)
516
540
  return null;
@@ -552,6 +576,7 @@ export class SqliteNoteStore {
552
576
  limit: items.length,
553
577
  nextCall: `notes.delta(${epoch.epoch})`,
554
578
  budget,
579
+ tokens: this.epochTokens(epoch.epoch),
555
580
  };
556
581
  });
557
582
  }
@@ -564,8 +589,8 @@ export class SqliteNoteStore {
564
589
  throw arborError({
565
590
  code: "invalid_input",
566
591
  rule: "note.delta.epoch",
567
- message: `note.delta: epoch ${since} is not in the notes`,
568
- nextAction: "pass the epoch the window froze, or 0 for every note",
592
+ message: this.text("refused.note.delta.epoch", { since }),
593
+ nextAction: this.text("next.note.delta.epoch"),
569
594
  details: { since },
570
595
  });
571
596
  }
@@ -631,7 +656,7 @@ export class SqliteNoteStore {
631
656
  }
632
657
  async recordUse(input) {
633
658
  this.ensureOpen();
634
- const parsed = parseOrThrow(input.ref, "recordUse");
659
+ const parsed = parseOrThrow(this.text, input.ref, "recordUse");
635
660
  // A use of a note that is not there names a ref that does not resolve; the
636
661
  // host's own append must not fail for it.
637
662
  if (this.noteRow(parsed.id) === null)
@@ -668,8 +693,8 @@ export class SqliteNoteStore {
668
693
  throw arborError({
669
694
  code: "unavailable",
670
695
  rule: "note.closed",
671
- message: "notes: the note store is closed",
672
- nextAction: "open a note store before reading or writing",
696
+ message: this.text("refused.note.closed"),
697
+ nextAction: this.text("next.note.closed"),
673
698
  });
674
699
  }
675
700
  }
@@ -841,6 +866,13 @@ export class SqliteNoteStore {
841
866
  activate.run(item.note);
842
867
  return this.epochRow(epoch);
843
868
  }
869
+ /** What the active notes of one epoch cost, as the epoch froze them. */
870
+ epochTokens(epoch) {
871
+ const row = this.db
872
+ .prepare("SELECT COALESCE(SUM(tokens), 0) AS total FROM note_epoch_items WHERE epoch = ?")
873
+ .get(epoch);
874
+ return row === undefined ? 0 : toNumber(row.total);
875
+ }
844
876
  /** The active notes one epoch froze, by summary, sorted by id. */
845
877
  epochSummaries(epoch) {
846
878
  const rows = this.db
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@4fu/arbor-notes-sqlite",
3
- "version": "2026.1106.0",
3
+ "version": "2026.1106.1",
4
4
  "type": "module",
5
5
  "description": "The default NoteStore: one copy of every note, checked writes, revisions, anchors and the active set on SQLite beside the journal.",
6
6
  "exports": {
@@ -40,7 +40,8 @@
40
40
  "access": "public"
41
41
  },
42
42
  "dependencies": {
43
- "@4fu/arbor-errors": "2026.1106.0",
44
- "@4fu/arbor-interfaces": "2026.1106.0"
43
+ "@4fu/arbor-errors": "2026.1106.1",
44
+ "@4fu/arbor-interfaces": "2026.1106.1",
45
+ "@4fu/arbor-prompt": "2026.1106.1"
45
46
  }
46
47
  }