@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/README.md +14 -6
- package/index.d.ts +3 -1
- package/index.js +3 -1
- package/notes.d.ts +71 -2
- package/notes.js +298 -54
- package/package.json +17 -5
- package/schema.d.ts +8 -3
- package/schema.js +60 -10
- package/search.d.ts +49 -16
- package/search.js +109 -32
- package/semantic.d.ts +29 -0
- package/semantic.js +57 -0
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,
|
|
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:
|
|
95
|
-
nextAction: "
|
|
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
|
-
|
|
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:
|
|
221
|
-
nextAction: "
|
|
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
|
|
241
|
-
nextAction: "
|
|
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
|
|
249
|
-
nextAction: "write
|
|
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:
|
|
257
|
-
nextAction:
|
|
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
|
|
265
|
-
nextAction: "
|
|
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:
|
|
290
|
-
nextAction: "
|
|
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:
|
|
298
|
-
nextAction: "
|
|
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:
|
|
307
|
-
nextAction:
|
|
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:
|
|
315
|
-
|
|
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.
|
|
330
|
-
message:
|
|
331
|
-
nextAction:
|
|
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
|
|
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:
|
|
439
|
-
|
|
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
|
-
|
|
499
|
-
|
|
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:
|
|
568
|
-
nextAction: "
|
|
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: "
|
|
672
|
-
nextAction: "
|
|
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
|
|
716
|
-
|
|
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
|
-
|
|
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.
|
|
3
|
+
"version": "2026.1107.0",
|
|
4
4
|
"type": "module",
|
|
5
|
-
"description": "The default NoteStore: one copy of every note, checked writes, revisions, anchors
|
|
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.
|
|
44
|
-
"@4fu/arbor-interfaces": "2026.
|
|
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
|
}
|