@4fu/arbor-notes-sqlite 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/notes.js CHANGED
@@ -15,7 +15,11 @@
15
15
  * count and token budget, and frozen with the epoch's version, so every
16
16
  * window opened in one epoch has the same prefix and later windows see the
17
17
  * changes as a delta;
18
- * - every write recorded in the journal as a `note_revision` event.
18
+ * - every write recorded in the journal as a `note_revision` event;
19
+ * - lexical search: a word index with BM25, a trigram index for exact
20
+ * substrings, and a `LIKE` scan for one- and two-character queries, merged
21
+ * with exact substring hits first - and a semantic channel, note text embedded
22
+ * and searched by meaning, when `@4fu/arbor-search-zvec` is installed.
19
23
  *
20
24
  * It is synchronous inside: one `DatabaseSync`, one transaction per write, so
21
25
  * the revision a write checks and the revision it writes are one atomic step.
@@ -24,13 +28,20 @@
24
28
  * may live behind a process boundary.
25
29
  */
26
30
  import { DatabaseSync } from "node:sqlite";
31
+ import { randomUUID } from "node:crypto";
32
+ import { rm } from "node:fs/promises";
33
+ import { tmpdir } from "node:os";
34
+ import { join } from "node:path";
27
35
  import { arborError, isArborError } from "@4fu/arbor-errors";
28
36
  import { DEFAULT_PAGE_LIMIT, MAX_PAGE_LIMIT } from "@4fu/arbor-interfaces";
37
+ import { indexText, resolveSegmenter } from "@4fu/arbor-lexical";
29
38
  import { bodyDiff } from "./diff.js";
30
39
  import { noteIdOf, noteRefOf, noteRefForms, parseNoteId, parseNoteRef, storedEventRef, storedWindowRef, } from "./refs.js";
31
- import { FTS_TABLE, createSchema } from "./schema.js";
40
+ import { FTS_TABLE, WORD_TABLE, createSchema } from "./schema.js";
32
41
  import { USE_WEIGHTS, applyJudgeShare, pickByBudget, scoreNotes } from "./score.js";
33
- import { holdingIds, substringClause } from "./search.js";
42
+ import { holdingIds, matchedIds } from "./search.js";
43
+ import { resolveSemanticIndex } from "./semantic.js";
44
+ import { textOf, zhTextTable } from "@4fu/arbor-prompt";
34
45
  /** A summary is at most this many characters (docs/memory.md). */
35
46
  export const SUMMARY_LIMIT = 200;
36
47
  /** How many characters of a body one read returns. */
@@ -46,6 +57,13 @@ export const DEFAULT_JUDGE_SHARE = 0.15;
46
57
  export const DEFAULT_HALF_LIFE_MS = 86_400_000;
47
58
  /** How many events one `about` lookup reads. */
48
59
  const ABOUT_EVENT_LIMIT = 200;
60
+ /**
61
+ * The fewest candidates the semantic channel is asked for. A page of one still
62
+ * fuses the channels over a handful of candidates, so a hit the word index
63
+ * ranked first is not lost to a semantic hit the channel would have ranked
64
+ * second.
65
+ */
66
+ const MIN_SEMANTIC_CANDIDATES = 10;
49
67
  function toNumber(value) {
50
68
  if (typeof value === "number")
51
69
  return value;
@@ -87,19 +105,19 @@ export function deriveSummary(body) {
87
105
  const characters = Array.from(flat);
88
106
  return characters.length <= SUMMARY_LIMIT ? flat : `${characters.slice(0, SUMMARY_LIMIT).join("")}…`;
89
107
  }
90
- function invalidNoteRef(call, ref) {
108
+ function invalidNoteRef(text, call, ref) {
91
109
  return arborError({
92
110
  code: "invalid_input",
93
111
  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",
112
+ message: text("refused.note.ref", { ref }),
113
+ nextAction: text("next.note.ref"),
96
114
  details: { ref },
97
115
  });
98
116
  }
99
- function parseOrThrow(ref, call) {
117
+ function parseOrThrow(text, ref, call) {
100
118
  const parsed = parseNoteRef(ref);
101
119
  if (parsed === null)
102
- throw invalidNoteRef(call, ref);
120
+ throw invalidNoteRef(text, call, ref);
103
121
  return parsed;
104
122
  }
105
123
  function toSummary(row) {
@@ -163,17 +181,38 @@ export class SqliteNoteStore {
163
181
  db;
164
182
  journal;
165
183
  clock;
184
+ segmenter;
166
185
  halfLifeMs;
186
+ semantic;
187
+ /** The temporary directory an in-memory store's vector collection lives in. */
188
+ ownedVectorDirectory;
189
+ /** The table every refusal this store raises is written from. */
190
+ text;
167
191
  /** One write at a time: a write spans the notes database and the journal. */
168
192
  writeQueue = Promise.resolve();
193
+ semanticReady = false;
194
+ semanticCaughtUp = false;
169
195
  closed = false;
170
196
  constructor(options) {
171
197
  this.path = options.path ?? ":memory:";
172
198
  this.db = new DatabaseSync(this.path);
173
- createSchema(this.db);
199
+ this.segmenter = options.segmenter ?? resolveSegmenter();
200
+ // The segmenter is resolved first: a version-1 database migrates by
201
+ // segmenting the notes it already holds into the word index.
202
+ createSchema(this.db, this.segmenter);
174
203
  this.journal = options.journal;
175
204
  this.clock = options.now ?? (() => Date.now());
176
205
  this.halfLifeMs = options.usageHalfLifeMs ?? DEFAULT_HALF_LIFE_MS;
206
+ this.text = options.text ?? ((key, args) => textOf(zhTextTable, key, args));
207
+ // An in-memory store has no directory of its own; give it a temporary one
208
+ // and take it away on close. The semantic channel's store lives under it,
209
+ // beside where the notes database would be.
210
+ const root = this.path === ":memory:" ? join(tmpdir(), `arbor-notes-${randomUUID()}`) : null;
211
+ this.semantic =
212
+ options.semantic === undefined
213
+ ? resolveSemanticIndex(root === null ? `${this.path}.vectors` : join(root, "vectors"))
214
+ : options.semantic;
215
+ this.ownedVectorDirectory = root;
177
216
  }
178
217
  // --- writes -------------------------------------------------------------
179
218
  async write(input) {
@@ -217,8 +256,8 @@ export class SqliteNoteStore {
217
256
  throw arborError({
218
257
  code: isArborError(error) ? error.code : "internal",
219
258
  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",
259
+ message: this.text("refused.note.write.journal", { ref: noteRef, detail }),
260
+ nextAction: this.text("next.note.write.journal"),
222
261
  details: { upstream: detail },
223
262
  cause: error,
224
263
  });
@@ -229,40 +268,59 @@ export class SqliteNoteStore {
229
268
  .run(event.ref, written.id, written.revision);
230
269
  this.db.prepare("UPDATE notes SET event = ? WHERE id = ?").run(event.ref, written.id);
231
270
  });
271
+ // The semantic channel is asynchronous, so the note is indexed once its
272
+ // revision is committed: a write never waits on a model, and a channel that
273
+ // is not ready yet holds the text until it is. With no channel there is
274
+ // nothing to wait for, and the write takes the path it always did. A delete
275
+ // takes the note's vector back out, as a redaction does in the journal.
276
+ if (this.semantic !== null) {
277
+ if (written.deleted)
278
+ await this.removeFromSemantic(written.id);
279
+ else
280
+ await this.addToSemantic(written.id, written.title, written.summary, written.body);
281
+ }
232
282
  return toNote(this.noteRow(written.id));
233
283
  }
234
284
  /** The rules a write has to meet before it touches the one copy. */
235
285
  checkWrite(input) {
286
+ if (input.id !== undefined && input.id.trim() === "") {
287
+ throw arborError({
288
+ code: "invalid_input",
289
+ rule: "note.write.id",
290
+ message: this.text("refused.note.write.id"),
291
+ nextAction: this.text("next.note.write.id"),
292
+ });
293
+ }
236
294
  if (input.title.trim() === "") {
237
295
  throw arborError({
238
296
  code: "invalid_input",
239
297
  rule: "note.write.title",
240
- message: "note.write: a note needs a title",
241
- nextAction: "give the note a short title",
298
+ message: this.text("refused.note.write.title"),
299
+ nextAction: this.text("next.note.write.title"),
242
300
  });
243
301
  }
244
302
  if (input.delete !== true && input.body.trim() === "") {
245
303
  throw arborError({
246
304
  code: "invalid_input",
247
305
  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",
306
+ message: this.text("refused.note.write.body"),
307
+ nextAction: this.text("next.note.write.body"),
250
308
  });
251
309
  }
252
310
  if (input.summary !== undefined && Array.from(input.summary).length > SUMMARY_LIMIT) {
253
311
  throw arborError({
254
312
  code: "invalid_input",
255
313
  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`,
314
+ message: this.text("refused.note.write.summary", { limit: SUMMARY_LIMIT }),
315
+ nextAction: this.text("next.note.write.summary", { limit: SUMMARY_LIMIT }),
258
316
  });
259
317
  }
260
318
  if (input.id === undefined && input.expectedRevision !== undefined) {
261
319
  throw arborError({
262
320
  code: "invalid_input",
263
321
  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",
322
+ message: this.text("refused.note.write.expected_revision"),
323
+ nextAction: this.text("next.note.write.expected_revision"),
266
324
  });
267
325
  }
268
326
  }
@@ -280,22 +338,22 @@ export class SqliteNoteStore {
280
338
  else {
281
339
  const parsed = parseNoteId(input.id);
282
340
  if (parsed === null)
283
- throw invalidNoteRef("write", input.id);
341
+ throw invalidNoteRef(this.text, "write", input.id);
284
342
  previous = this.noteRow(parsed);
285
343
  if (previous === null) {
286
344
  throw arborError({
287
345
  code: "not_found",
288
346
  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",
347
+ message: this.text("refused.note.write.note", { id: input.id }),
348
+ nextAction: this.text("next.note.write.note"),
291
349
  });
292
350
  }
293
351
  if (previous.status === "deleted") {
294
352
  throw arborError({
295
353
  code: "not_found",
296
354
  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",
355
+ message: this.text("refused.note.write.deleted", { id: input.id }),
356
+ nextAction: this.text("next.note.write.deleted"),
299
357
  });
300
358
  }
301
359
  const expected = input.expectedRevision;
@@ -303,16 +361,20 @@ export class SqliteNoteStore {
303
361
  throw arborError({
304
362
  code: "invalid_input",
305
363
  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}`,
364
+ message: this.text("refused.note.write.revision", { id: input.id, revision: previous.revision }),
365
+ nextAction: this.text("next.note.write.revision", { revision: previous.revision }),
308
366
  });
309
367
  }
310
368
  if (expected > previous.revision) {
311
369
  throw arborError({
312
370
  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}`,
371
+ rule: "note.write.revision.absent",
372
+ message: this.text("refused.note.write.revision.absent", {
373
+ id: input.id,
374
+ expected,
375
+ revision: previous.revision,
376
+ }),
377
+ nextAction: this.text("next.note.write.revision.absent", { revision: previous.revision }),
316
378
  });
317
379
  }
318
380
  if (expected < previous.revision)
@@ -326,9 +388,9 @@ export class SqliteNoteStore {
326
388
  input.body !== previous.body) {
327
389
  throw arborError({
328
390
  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`,
391
+ rule: "note.write.replace_summary",
392
+ message: this.text("refused.note.write.replace_summary", { id: input.id }),
393
+ nextAction: this.text("next.note.write.replace_summary", { limit: SUMMARY_LIMIT }),
332
394
  });
333
395
  }
334
396
  }
@@ -359,12 +421,16 @@ export class SqliteNoteStore {
359
421
  .prepare(`INSERT INTO note_revisions (note, revision, title, summary, body, deleted, time, window, event, anchor, refs)
360
422
  VALUES (?, ?, ?, ?, ?, ?, ?, ?, NULL, ?, ?)`)
361
423
  .run(id, revision, input.title, summary, body, deleted ? 1 : 0, time, window, anchorJson, refs);
362
- // The search index holds the current state; a deleted note is not found.
424
+ // The search indexes hold the current state; a deleted note is not found.
363
425
  this.db.prepare(`DELETE FROM ${FTS_TABLE} WHERE rowid = ?`).run(id);
426
+ this.db.prepare(`DELETE FROM ${WORD_TABLE} WHERE rowid = ?`).run(id);
364
427
  if (!deleted) {
365
428
  this.db
366
429
  .prepare(`INSERT INTO ${FTS_TABLE} (rowid, title, summary, body) VALUES (?, ?, ?, ?)`)
367
430
  .run(id, input.title, summary, body);
431
+ this.db
432
+ .prepare(`INSERT INTO ${WORD_TABLE} (rowid, title, summary, body) VALUES (?, ?, ?, ?)`)
433
+ .run(id, indexText(input.title, this.segmenter), indexText(summary, this.segmenter), indexText(body, this.segmenter));
368
434
  }
369
435
  // The refs the revision names: what it cites and what it holds as
370
436
  // evidence. The evidence ones between notes are what the credit walk
@@ -387,6 +453,8 @@ export class SqliteNoteStore {
387
453
  time,
388
454
  title: input.title,
389
455
  summary,
456
+ body,
457
+ deleted,
390
458
  window,
391
459
  anchor,
392
460
  };
@@ -401,6 +469,7 @@ export class SqliteNoteStore {
401
469
  .prepare("DELETE FROM note_refs WHERE note = ? AND revision = ?")
402
470
  .run(written.id, written.revision);
403
471
  this.db.prepare(`DELETE FROM ${FTS_TABLE} WHERE rowid = ?`).run(written.id);
472
+ this.db.prepare(`DELETE FROM ${WORD_TABLE} WHERE rowid = ?`).run(written.id);
404
473
  if (written.created) {
405
474
  this.db.prepare("DELETE FROM notes WHERE id = ?").run(written.id);
406
475
  return;
@@ -415,6 +484,9 @@ export class SqliteNoteStore {
415
484
  this.db
416
485
  .prepare(`INSERT INTO ${FTS_TABLE} (rowid, title, summary, body) VALUES (?, ?, ?, ?)`)
417
486
  .run(written.id, previous.title, previous.summary, previous.body);
487
+ this.db
488
+ .prepare(`INSERT INTO ${WORD_TABLE} (rowid, title, summary, body) VALUES (?, ?, ?, ?)`)
489
+ .run(written.id, indexText(previous.title, this.segmenter), indexText(previous.summary, this.segmenter), indexText(previous.body, this.segmenter));
418
490
  }
419
491
  });
420
492
  }
@@ -435,8 +507,16 @@ export class SqliteNoteStore {
435
507
  return arborError({
436
508
  code: "conflict",
437
509
  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}`,
510
+ message: this.text("refused.note.write.stale_revision", {
511
+ ref: noteRefOf(note.id, current),
512
+ current,
513
+ read,
514
+ }),
515
+ nextAction: this.text("next.note.write.stale_revision", {
516
+ ref: noteRefOf(note.id, current),
517
+ current,
518
+ read,
519
+ }),
440
520
  refs: [noteRefOf(note.id, current), ...revisions.map((revision) => revision.ref)],
441
521
  details: {
442
522
  conflict: { read, current, revisions },
@@ -447,7 +527,7 @@ export class SqliteNoteStore {
447
527
  // --- reads --------------------------------------------------------------
448
528
  async read(ref, options) {
449
529
  this.ensureOpen();
450
- const parsed = parseOrThrow(ref, "read");
530
+ const parsed = parseOrThrow(this.text, ref, "read");
451
531
  const row = parsed.revision === null ? this.latestRevisionRow(parsed.id) : this.revisionRow(parsed.id, parsed.revision);
452
532
  if (row === null)
453
533
  return null;
@@ -469,7 +549,7 @@ export class SqliteNoteStore {
469
549
  }
470
550
  async revisions(ref) {
471
551
  this.ensureOpen();
472
- const parsed = parseOrThrow(ref, "revisions");
552
+ const parsed = parseOrThrow(this.text, ref, "revisions");
473
553
  const limit = DEFAULT_PAGE_LIMIT;
474
554
  const nextCall = `notes.revisions(${noteIdOf(parsed.id)}, offset=${limit})`;
475
555
  const note = this.noteRow(parsed.id);
@@ -487,16 +567,25 @@ export class SqliteNoteStore {
487
567
  const offset = Math.max(0, Math.floor(query.offset ?? 0));
488
568
  const text = (query.query ?? "").trim();
489
569
  // `about` and `linked` bound the result to the notes the journal resolved,
490
- // in weight order; a text match narrows that set.
570
+ // in weight order; a text match narrows that set. The semantic channel does
571
+ // not widen a bound the journal resolved, so it is not asked at all here.
491
572
  if (query.about !== undefined || query.linked !== undefined) {
492
573
  const ordered = await this.linkedNoteIds(query.about, query.linked);
493
- const holding = text === "" ? null : new Set(holdingIds(this.db, text, ordered));
574
+ const holding = text === "" ? null : new Set(holdingIds(this.db, text, ordered, this.segmenter));
494
575
  const chosen = holding === null ? ordered : ordered.filter((id) => holding.has(id));
495
576
  const page = this.summaryPageByIds(chosen, query, limit, offset);
496
577
  return { ...page, nextCall: searchCall(query, offset, limit) };
497
578
  }
498
- const match = text === "" ? null : substringClause(text);
499
- const page = this.summaryPageBySql(query, limit, offset, match);
579
+ // No query lists every note by id; a query is answered by the lexical
580
+ // channels and the semantic one, merged: exact substring hits first, then
581
+ // word hits, then the hits only the semantic channel found.
582
+ const semantic = text === "" ? [] : await this.semanticRanks(text, limit, query.status);
583
+ const matched = text === ""
584
+ ? null
585
+ : matchedIds(this.db, text, this.segmenter, query.status, semantic);
586
+ const page = matched === null
587
+ ? this.summaryPageBySql(query, limit, offset)
588
+ : this.summaryPage(matched, limit, offset);
500
589
  return { ...page, nextCall: searchCall(query, offset, limit) };
501
590
  }
502
591
  async active() {
@@ -510,7 +599,7 @@ export class SqliteNoteStore {
510
599
  }
511
600
  async context(ref) {
512
601
  this.ensureOpen();
513
- const parsed = parseOrThrow(ref, "context");
602
+ const parsed = parseOrThrow(this.text, ref, "context");
514
603
  const note = this.noteRow(parsed.id);
515
604
  if (note === null)
516
605
  return null;
@@ -552,6 +641,7 @@ export class SqliteNoteStore {
552
641
  limit: items.length,
553
642
  nextCall: `notes.delta(${epoch.epoch})`,
554
643
  budget,
644
+ tokens: this.epochTokens(epoch.epoch),
555
645
  };
556
646
  });
557
647
  }
@@ -564,8 +654,8 @@ export class SqliteNoteStore {
564
654
  throw arborError({
565
655
  code: "invalid_input",
566
656
  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",
657
+ message: this.text("refused.note.delta.epoch", { since }),
658
+ nextAction: this.text("next.note.delta.epoch"),
569
659
  details: { since },
570
660
  });
571
661
  }
@@ -631,7 +721,7 @@ export class SqliteNoteStore {
631
721
  }
632
722
  async recordUse(input) {
633
723
  this.ensureOpen();
634
- const parsed = parseOrThrow(input.ref, "recordUse");
724
+ const parsed = parseOrThrow(this.text, input.ref, "recordUse");
635
725
  // A use of a note that is not there names a ref that does not resolve; the
636
726
  // host's own append must not fail for it.
637
727
  if (this.noteRow(parsed.id) === null)
@@ -661,6 +751,39 @@ export class SqliteNoteStore {
661
751
  this.closed = true;
662
752
  await this.writeQueue;
663
753
  this.db.close();
754
+ // The channel's own store is closed with the notes database, and the
755
+ // temporary directory an in-memory store gave it goes with it.
756
+ if (this.semantic !== null)
757
+ await this.semantic.close();
758
+ if (this.ownedVectorDirectory !== null) {
759
+ await rm(this.ownedVectorDirectory, { recursive: true, force: true });
760
+ }
761
+ }
762
+ /**
763
+ * Index the notes the semantic channel does not hold yet, and say how many.
764
+ *
765
+ * A restart over an existing database, a channel that was not ready when the
766
+ * notes were written, and a write whose indexing failed all leave notes to
767
+ * catch up on. A channel that is not ready yet holds what it was given and
768
+ * indexes that itself, so this answers 0 until it is.
769
+ */
770
+ async catchUpSemantic() {
771
+ this.ensureOpen();
772
+ if (this.semantic === null || !(await this.semantic.ready()))
773
+ return 0;
774
+ const held = await this.semantic.held();
775
+ const rows = this.db
776
+ .prepare(`SELECT id, title, summary, body FROM notes WHERE status <> 'deleted' ORDER BY id`)
777
+ .all();
778
+ let indexed = 0;
779
+ for (const row of rows) {
780
+ const id = toNumber(row.id);
781
+ if (held.has(noteIdOf(id)))
782
+ continue;
783
+ await this.addToSemantic(id, row.title, row.summary, row.body);
784
+ indexed += 1;
785
+ }
786
+ return indexed;
664
787
  }
665
788
  // --- internals ---------------------------------------------------------
666
789
  ensureOpen() {
@@ -668,11 +791,109 @@ export class SqliteNoteStore {
668
791
  throw arborError({
669
792
  code: "unavailable",
670
793
  rule: "note.closed",
671
- message: "notes: the note store is closed",
672
- nextAction: "open a note store before reading or writing",
794
+ message: this.text("refused.note.closed"),
795
+ nextAction: this.text("next.note.closed"),
673
796
  });
674
797
  }
675
798
  }
799
+ /**
800
+ * Hand one note's text to the semantic channel, when there is one.
801
+ *
802
+ * The note is embedded as one text: its title, its summary and its body, in
803
+ * the order the lexical index weights them. The vector channel holds one
804
+ * vector per note and cannot weight a note's parts against each other the way
805
+ * BM25 weights the three columns, so the title - the note's name - leads the
806
+ * text and the body follows it, and a query that means either reaches the
807
+ * note (docs/memory.md#search).
808
+ */
809
+ async addToSemantic(id, title, summary, body) {
810
+ if (this.semantic === null)
811
+ return;
812
+ try {
813
+ await this.semantic.add(noteIdOf(id), [title, summary, body].join("\n"));
814
+ }
815
+ catch {
816
+ // An optional channel that refuses leaves the note searchable lexically;
817
+ // a catch-up indexes it once the channel answers again.
818
+ }
819
+ }
820
+ /** Take one note's text back out of the semantic channel, as a delete does. */
821
+ async removeFromSemantic(id) {
822
+ if (this.semantic === null)
823
+ return;
824
+ try {
825
+ await this.semantic.remove(noteIdOf(id));
826
+ }
827
+ catch {
828
+ // As with a write: the channel is optional, and the note is gone from
829
+ // every index the store owns.
830
+ }
831
+ }
832
+ /**
833
+ * The semantic channel's candidates, as ranks in the order it gave them.
834
+ *
835
+ * An absent channel, one that is not ready yet, and one that refuses all
836
+ * answer with nothing, and the search is exactly the lexical one. The note
837
+ * read's own filters decide which candidates count, so a channel that filters
838
+ * less than the store does still answers the read it was asked for.
839
+ */
840
+ async semanticRanks(query, limit, status) {
841
+ if (this.semantic === null)
842
+ return [];
843
+ const ready = await this.semantic.ready();
844
+ if (ready !== this.semanticReady) {
845
+ // The channel just became usable, or stopped being: index what it missed
846
+ // while it was not.
847
+ this.semanticReady = ready;
848
+ this.semanticCaughtUp = false;
849
+ }
850
+ if (!this.semanticCaughtUp) {
851
+ this.semanticCaughtUp = true;
852
+ await this.catchUpSemantic();
853
+ }
854
+ if (!ready)
855
+ return [];
856
+ // The filters a note read applies are the store's own, and none of them is
857
+ // a field of the channel: a note's status changes at every snapshot epoch
858
+ // without a revision, and `about` and `linked` are bounds the journal
859
+ // resolved. So the channel is asked for the notes nearest the query and
860
+ // nothing else, and the store applies its own filters to what comes back -
861
+ // exactly as the journal does with the targets it filters on itself
862
+ // (docs/memory.md#search).
863
+ const asked = {};
864
+ const refs = await this.semantic.search(query, Math.max(limit, MIN_SEMANTIC_CANDIDATES), asked);
865
+ const ids = [];
866
+ for (const ref of refs) {
867
+ const id = parseNoteId(ref);
868
+ if (id !== null)
869
+ ids.push(id);
870
+ }
871
+ const allowed = this.allowedNoteIds(ids, status);
872
+ // The channel's order, as ranks the shared merge reads: the nearest hit
873
+ // carries the most negative rank, so it normalizes to the best score.
874
+ return allowed.map((id, index) => ({ rowid: id, rank: index - allowed.length }));
875
+ }
876
+ /**
877
+ * The notes among `ids` the read's own filters allow, in the order given. A
878
+ * deleted note is not among them, so a vector that outlived its note never
879
+ * reaches a page.
880
+ */
881
+ allowedNoteIds(ids, status) {
882
+ if (ids.length === 0)
883
+ return [];
884
+ const clauses = ["status <> 'deleted'"];
885
+ const params = [...ids];
886
+ if (status !== undefined) {
887
+ clauses.push("status = ?");
888
+ params.push(status);
889
+ }
890
+ const rows = this.db
891
+ .prepare(`SELECT id FROM notes
892
+ WHERE id IN (${ids.map(() => "?").join(", ")}) AND ${clauses.join(" AND ")}`)
893
+ .all(...params);
894
+ const allowed = new Set(rows.map((row) => toNumber(row.id)));
895
+ return ids.filter((id) => allowed.has(id));
896
+ }
676
897
  noteRow(id) {
677
898
  const row = this.db
678
899
  .prepare(`SELECT id, created, updated, revision, title, summary, body, status, window, anchor, event
@@ -712,13 +933,11 @@ export class SqliteNoteStore {
712
933
  }
713
934
  return states;
714
935
  }
715
- summaryPageBySql(query, limit, offset, match) {
716
- const clauses = [];
936
+ summaryPageBySql(query, limit, offset) {
937
+ // A delete leaves the row and marks it, and this reads the notes table
938
+ // itself, so it leaves the deleted ones out on its own (docs/memory.md).
939
+ const clauses = ["n.status <> 'deleted'"];
717
940
  const params = [];
718
- if (match !== null) {
719
- clauses.push(match.sql);
720
- params.push(...match.params);
721
- }
722
941
  if (query.status !== undefined) {
723
942
  clauses.push("n.status = ?");
724
943
  params.push(query.status);
@@ -734,10 +953,28 @@ export class SqliteNoteStore {
734
953
  .get(...params).n);
735
954
  return { items: rows.map(toSummary), total, limit };
736
955
  }
956
+ /** One page of an ordered id list, in the order the list gives. */
957
+ summaryPage(ordered, limit, offset) {
958
+ const page = ordered.slice(offset, offset + limit);
959
+ if (page.length === 0)
960
+ return { items: [], total: ordered.length, limit };
961
+ const rows = this.db
962
+ .prepare(`SELECT id, revision, title, summary, status, updated FROM notes
963
+ WHERE id IN (${page.map(() => "?").join(", ")})`)
964
+ .all(...page);
965
+ const byId = new Map(rows.map((row) => [toNumber(row.id), row]));
966
+ const items = page
967
+ .map((id) => byId.get(id))
968
+ .filter((row) => row !== undefined)
969
+ .map(toSummary);
970
+ return { items, total: ordered.length, limit };
971
+ }
737
972
  summaryPageByIds(ordered, query, limit, offset) {
738
973
  if (ordered.length === 0)
739
974
  return { items: [], total: 0, limit };
740
- const clauses = [`id IN (${ordered.map(() => "?").join(", ")})`];
975
+ // A delete leaves the row and marks it, and no search returns the note
976
+ // again, whichever lookup ordered it (docs/memory.md).
977
+ const clauses = [`status <> 'deleted'`, `id IN (${ordered.map(() => "?").join(", ")})`];
741
978
  const params = [...ordered];
742
979
  if (query.status !== undefined) {
743
980
  clauses.push("status = ?");
@@ -841,6 +1078,13 @@ export class SqliteNoteStore {
841
1078
  activate.run(item.note);
842
1079
  return this.epochRow(epoch);
843
1080
  }
1081
+ /** What the active notes of one epoch cost, as the epoch froze them. */
1082
+ epochTokens(epoch) {
1083
+ const row = this.db
1084
+ .prepare("SELECT COALESCE(SUM(tokens), 0) AS total FROM note_epoch_items WHERE epoch = ?")
1085
+ .get(epoch);
1086
+ return row === undefined ? 0 : toNumber(row.total);
1087
+ }
844
1088
  /** The active notes one epoch froze, by summary, sorted by id. */
845
1089
  epochSummaries(epoch) {
846
1090
  const rows = this.db
package/package.json CHANGED
@@ -1,8 +1,8 @@
1
1
  {
2
2
  "name": "@4fu/arbor-notes-sqlite",
3
- "version": "2026.1106.0",
3
+ "version": "2026.1107.0",
4
4
  "type": "module",
5
- "description": "The default NoteStore: one copy of every note, checked writes, revisions, anchors and the active set on SQLite beside the journal.",
5
+ "description": "The default NoteStore: one copy of every note, checked writes, revisions, anchors, the active set and search on SQLite beside the journal: lexical, and semantic when the optional channel is installed.",
6
6
  "exports": {
7
7
  ".": {
8
8
  "types": "./index.d.ts",
@@ -25,7 +25,9 @@
25
25
  "score.d.ts",
26
26
  "score.js",
27
27
  "search.d.ts",
28
- "search.js"
28
+ "search.js",
29
+ "semantic.d.ts",
30
+ "semantic.js"
29
31
  ],
30
32
  "engines": {
31
33
  "node": ">=24"
@@ -40,7 +42,17 @@
40
42
  "access": "public"
41
43
  },
42
44
  "dependencies": {
43
- "@4fu/arbor-errors": "2026.1106.0",
44
- "@4fu/arbor-interfaces": "2026.1106.0"
45
+ "@4fu/arbor-errors": "2026.1107.0",
46
+ "@4fu/arbor-interfaces": "2026.1107.0",
47
+ "@4fu/arbor-lexical": "2026.1107.0",
48
+ "@4fu/arbor-prompt": "2026.1107.0"
49
+ },
50
+ "peerDependencies": {
51
+ "@4fu/arbor-search-zvec": "2026.1107.0"
52
+ },
53
+ "peerDependenciesMeta": {
54
+ "@4fu/arbor-search-zvec": {
55
+ "optional": true
56
+ }
45
57
  }
46
58
  }