@jbarket/loomai 0.5.0 → 0.6.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.
Files changed (94) hide show
  1. package/CHANGELOG.md +114 -1
  2. package/README.md +97 -0
  3. package/dist/backends/dedup.d.ts +23 -0
  4. package/dist/backends/dedup.js +100 -0
  5. package/dist/backends/embedding-runtime.js +38 -9
  6. package/dist/backends/episodes.js +1 -1
  7. package/dist/backends/fastembed.js +30 -4
  8. package/dist/backends/migrations.js +200 -0
  9. package/dist/backends/mmr.d.ts +8 -0
  10. package/dist/backends/mmr.js +18 -4
  11. package/dist/backends/people.d.ts +192 -0
  12. package/dist/backends/people.js +824 -0
  13. package/dist/backends/proposals.js +1 -1
  14. package/dist/backends/recorder-health.d.ts +34 -0
  15. package/dist/backends/recorder-health.js +117 -0
  16. package/dist/backends/salience.d.ts +39 -0
  17. package/dist/backends/salience.js +52 -4
  18. package/dist/backends/scope.d.ts +56 -0
  19. package/dist/backends/scope.js +75 -0
  20. package/dist/backends/sqlite-knowledge.d.ts +24 -4
  21. package/dist/backends/sqlite-knowledge.js +253 -91
  22. package/dist/backends/sqlite-vec.d.ts +32 -1
  23. package/dist/backends/sqlite-vec.js +394 -75
  24. package/dist/backends/types.d.ts +203 -3
  25. package/dist/categories.d.ts +2 -0
  26. package/dist/categories.js +2 -0
  27. package/dist/cli/args.d.ts +16 -1
  28. package/dist/cli/args.js +45 -1
  29. package/dist/cli/index.d.ts +7 -0
  30. package/dist/cli/index.js +57 -3
  31. package/dist/cli/knowledge.js +4 -1
  32. package/dist/cli/memory.js +2 -2
  33. package/dist/cli/person.d.ts +2 -0
  34. package/dist/cli/person.js +116 -0
  35. package/dist/cli/recall.js +2 -1
  36. package/dist/cli/remember.js +10 -0
  37. package/dist/cli/remote.d.ts +36 -0
  38. package/dist/cli/remote.js +173 -0
  39. package/dist/cli/serve.d.ts +7 -0
  40. package/dist/cli/serve.js +94 -5
  41. package/dist/cli/subcommands.d.ts +1 -1
  42. package/dist/cli/subcommands.js +1 -1
  43. package/dist/cli/update.js +30 -11
  44. package/dist/clients.js +7 -4
  45. package/dist/config.d.ts +14 -0
  46. package/dist/config.js +25 -2
  47. package/dist/index.js +22 -7
  48. package/dist/injection/harnesses.js +9 -3
  49. package/dist/install/harnesses.js +7 -1
  50. package/dist/server.d.ts +8 -1
  51. package/dist/server.js +461 -374
  52. package/dist/tools/dossier.js +5 -1
  53. package/dist/tools/eurorack-import.js +3 -0
  54. package/dist/tools/identity.d.ts +2 -0
  55. package/dist/tools/identity.js +35 -2
  56. package/dist/tools/knowledge-history.d.ts +6 -1
  57. package/dist/tools/knowledge-history.js +26 -8
  58. package/dist/tools/knowledge-maintain.d.ts +5 -0
  59. package/dist/tools/knowledge-maintain.js +92 -5
  60. package/dist/tools/knowledge-recall.d.ts +7 -1
  61. package/dist/tools/knowledge-recall.js +53 -3
  62. package/dist/tools/knowledge-verify.d.ts +11 -1
  63. package/dist/tools/knowledge-verify.js +17 -4
  64. package/dist/tools/knowledge-write.d.ts +8 -1
  65. package/dist/tools/knowledge-write.js +27 -9
  66. package/dist/tools/memory-history.d.ts +5 -0
  67. package/dist/tools/memory-history.js +52 -0
  68. package/dist/tools/memory-list.js +2 -1
  69. package/dist/tools/memory-merge.d.ts +2 -0
  70. package/dist/tools/memory-merge.js +40 -0
  71. package/dist/tools/memory-revision-restore.d.ts +5 -0
  72. package/dist/tools/memory-revision-restore.js +33 -0
  73. package/dist/tools/person.d.ts +32 -0
  74. package/dist/tools/person.js +111 -0
  75. package/dist/tools/prune.js +1 -1
  76. package/dist/tools/recall.js +7 -1
  77. package/dist/tools/remember.js +18 -2
  78. package/dist/tools/update.js +5 -1
  79. package/dist/transport/cli-endpoint.d.ts +13 -0
  80. package/dist/transport/cli-endpoint.js +97 -0
  81. package/dist/transport/guards.d.ts +14 -0
  82. package/dist/transport/guards.js +26 -0
  83. package/dist/transport/http-server.d.ts +28 -1
  84. package/dist/transport/http-server.js +156 -18
  85. package/dist/transport/knowledge-server.d.ts +11 -0
  86. package/dist/transport/knowledge-server.js +30 -0
  87. package/dist/transport/knowledge-tools.d.ts +50 -0
  88. package/dist/transport/knowledge-tools.js +290 -0
  89. package/dist/transport/mcp-app-hello.d.ts +1 -1
  90. package/dist/widgets/display-modes.html +62 -56
  91. package/dist/widgets/hello.html +62 -56
  92. package/dist/widgets/proposals.html +62 -56
  93. package/dist/widgets/top-of-mind.html +60 -54
  94. package/package.json +7 -5
@@ -11,6 +11,8 @@ import { mkdirSync } from 'node:fs';
11
11
  import { dirname } from 'node:path';
12
12
  import BetterSqlite3 from 'better-sqlite3';
13
13
  import { retryWrite } from './retry.js';
14
+ /** Identity attributed to a stamped access when the caller doesn't pass one. */
15
+ const UNKNOWN_IDENTITY = 'unknown';
14
16
  /** Hard size caps — enforced at write boundary (design §A3). */
15
17
  const MAX_PAGE_BODY_LENGTH = 64 * 1024; // 64 KB
16
18
  const MAX_CITATION_EXCERPT_LENGTH = 4096; // 4 KB
@@ -25,10 +27,9 @@ export class SqliteKnowledgeBackend {
25
27
  // Open in constructor for schema init; caller closes after use.
26
28
  this.db = new BetterSqlite3(config.dbPath);
27
29
  this.db.pragma('journal_mode = WAL');
28
- // Single-writer (c-loom-strictness §single-writer): fail fast on a second
29
- // concurrent writer (SQLITE_BUSY) instead of blocking for the 5s default —
30
- // see sqlite-vec.ts for the rationale. The knowledge wing shares the policy.
31
- this.db.pragma('busy_timeout = 0');
30
+ // busy_timeout: concurrent sleeves retry brief write-lock windows rather
31
+ // than failing immediately — see sqlite-vec.ts for the full rationale.
32
+ this.db.pragma(`busy_timeout = ${config.busyTimeoutMs ?? 5000}`);
32
33
  this.db.pragma('user_version = 1');
33
34
  this.initSchema();
34
35
  }
@@ -38,63 +39,92 @@ export class SqliteKnowledgeBackend {
38
39
  enforcePageBodyCap(input.body);
39
40
  const db = this.ensureOpen();
40
41
  const timestamp = new Date().toISOString();
41
- const existing = db.prepare('SELECT id, uuid, title, body FROM pages WHERE slug = ?').get(input.slug);
42
- let pageId;
43
- let uuid;
44
- let title;
45
- let sourcing;
46
- let appliedMode;
47
- if (existing) {
48
- uuid = existing.uuid;
49
- // Title and domain follow the write — the page is addressed by slug,
50
- // so a differing title/domain on upsert is an intentional revision.
51
- title = input.title;
52
- sourcing = input.sourcing ?? 'sourced';
53
- pageId = existing.id;
54
- appliedMode = input.bodyMode ?? 'replace';
55
- const newBody = appliedMode === 'append'
56
- ? `${existing.body}\n\n${input.body}`
57
- : input.body;
58
- enforcePageBodyCap(newBody);
59
- // Replace-writes destroy the stored body — snapshot it first so the
60
- // page is recoverable (the 2026-06-01 verify run stomped 13 bodies
61
- // with no recovery path other than transcript archaeology).
62
- if (appliedMode === 'replace' && newBody !== existing.body) {
63
- retryWrite(() => this.snapshotRevision(db, pageId, input.slug, existing.body, 'write-replace', timestamp));
42
+ // Title and domain follow the write — the page is addressed by slug,
43
+ // so a differing title/domain on upsert is an intentional revision.
44
+ const title = input.title;
45
+ const sourcing = input.sourcing ?? 'sourced';
46
+ // Set inside the transaction, read after it commits. Every one of these
47
+ // is derived from the existing-page read, so all of them are re-derived
48
+ // on a SQLITE_BUSY retry rather than carried over from the losing attempt.
49
+ let uuid = '';
50
+ let appliedMode = 'create';
51
+ let pageId = 0;
52
+ // Validate all citations before opening the transaction.
53
+ if (input.citations && input.citations.length > 0) {
54
+ for (const cit of input.citations) {
55
+ enforceExcerptCap(cit.excerpt);
64
56
  }
65
- // verified_at is a verification stamp, not a write stamp: an update
66
- // only moves it when the writer explicitly claims verification.
67
- // (Creation still stamps write time — the page was just synthesized
68
- // against its sources.)
69
- // created_by / version are preserved across upserts when omitted.
70
- retryWrite(() => db.prepare(`UPDATE pages SET title = ?, domain = ?, body = ?, sourcing = ?, verified_at = COALESCE(?, verified_at), freshness_anchor = COALESCE(?, freshness_anchor), created_by = COALESCE(?, created_by), version = COALESCE(?, version), updated = ? WHERE id = ?`).run(title, input.domain, newBody, sourcing, input.verified_at ?? null, input.freshness_anchor ?? null, input.created_by ?? null, input.version ?? null, timestamp, pageId));
71
- }
72
- else {
73
- uuid = randomUUID();
74
- title = input.title;
75
- sourcing = input.sourcing ?? 'sourced';
76
- appliedMode = 'create';
77
- const result = retryWrite(() => db.prepare(`INSERT INTO pages (uuid, slug, title, domain, body, sourcing, provenance, verified_at, freshness_anchor, created_by, version, created, updated)
78
- VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`).run(uuid, input.slug, title, input.domain, input.body, sourcing, input.provenance ?? null, input.verified_at ?? timestamp, input.freshness_anchor ?? null, input.created_by ?? null, input.version ?? null, timestamp, timestamp));
79
- pageId = Number(result.lastInsertRowid);
80
57
  }
81
58
  let citationsAdded = 0;
82
59
  let citationsDeduped = 0;
83
- if (input.citations && input.citations.length > 0) {
84
- for (const cit of input.citations) {
85
- enforceExcerptCap(cit.excerpt);
60
+ // Pre-prepare citation statements — compiled once, safe to reuse on retry.
61
+ // Exact-duplicate guard: re-sending a page's citations on upsert must not
62
+ // multiply them. A citation is a duplicate when claim, kind, locator, and
63
+ // excerpt all match an existing row on the same page.
64
+ const dupCheck = db.prepare(`SELECT 1 FROM citations
65
+ WHERE page_id = ? AND claim = ? AND source_kind = ?
66
+ AND COALESCE(source_locator, '') = COALESCE(?, '')
67
+ AND excerpt = ?`);
68
+ const insertCit = db.prepare(`INSERT INTO citations (page_id, claim, source_kind, source_locator, excerpt, retrieved_at, created)
69
+ VALUES (?, ?, ?, ?, ?, ?, ?)`);
70
+ // ── Single transaction: read + snapshot + page write + citations ─────────
71
+ // All sub-operations must commit atomically — a crash between any two
72
+ // leaves a partial state: a page with no citations, or a snapshot with no
73
+ // corresponding page update. Every other mutating method uses db.transaction;
74
+ // writePage — the hottest path — now does too (t-329).
75
+ //
76
+ // The existing-page read is INSIDE the span, and the span is IMMEDIATE:
77
+ // writePage is state-dependent (mode, body, uuid, and the snapshot's
78
+ // pre-image all follow from what it read), so an atomic write over a read
79
+ // taken outside the lock protects nothing. A second process committing in
80
+ // that window used to be silently overwritten — an append dropped the
81
+ // contribution it never saw, and a replace snapshotted a body that was no
82
+ // longer the one it displaced, losing it with no revision to recover from.
83
+ // BEGIN IMMEDIATE takes the write lock before the read, so the read/write
84
+ // sequence is serialized: the loser is told SQLITE_BUSY and retries the
85
+ // whole thing — re-reading — instead of writing over what it missed.
86
+ // (Mark, 2026-09-06, reviewing #92.)
87
+ //
88
+ // Counter variables (citationsAdded, citationsDeduped) are reset at the top
89
+ // of the callback so a SQLITE_BUSY retry never double-counts.
90
+ const tx = db.transaction(() => {
91
+ citationsAdded = 0;
92
+ citationsDeduped = 0;
93
+ const existing = db.prepare('SELECT id, uuid, title, body, freshness_anchor FROM pages WHERE slug = ?').get(input.slug);
94
+ uuid = existing?.uuid ?? randomUUID();
95
+ pageId = existing?.id ?? 0;
96
+ appliedMode = existing ? (input.bodyMode ?? 'replace') : 'create';
97
+ const newBody = (existing && appliedMode === 'append')
98
+ ? `${existing.body}\n\n${input.body}`
99
+ : input.body;
100
+ if (existing)
101
+ enforcePageBodyCap(newBody);
102
+ // Derive sla_class from the final anchor + domain; caller may override.
103
+ const finalAnchor = input.freshness_anchor ?? existing?.freshness_anchor ?? null;
104
+ const slaClass = input.sla_class ?? deriveSlaClass(finalAnchor, input.domain);
105
+ if (existing) {
106
+ // Replace-writes destroy the stored body — snapshot it first so the
107
+ // page is recoverable (the 2026-06-01 verify run stomped 13 bodies
108
+ // with no recovery path other than transcript archaeology).
109
+ if (appliedMode === 'replace' && newBody !== existing.body) {
110
+ this.snapshotRevision(db, pageId, input.slug, existing.body, 'write-replace', timestamp, input.actor ?? null);
111
+ }
112
+ // verified_at is a verification stamp, not a write stamp: an update
113
+ // only moves it when the writer explicitly claims verification.
114
+ // (Creation still stamps write time — the page was just synthesized
115
+ // against its sources.)
116
+ // created_by / version are preserved across upserts when omitted.
117
+ // author is deliberately absent from this UPDATE — it is the
118
+ // identity that wrote v1 and is never changed by a later upsert.
119
+ // sla_class is always re-derived from the final anchor + domain.
120
+ db.prepare(`UPDATE pages SET title = ?, domain = ?, body = ?, sourcing = ?, verified_at = COALESCE(?, verified_at), freshness_anchor = COALESCE(?, freshness_anchor), created_by = COALESCE(?, created_by), version = COALESCE(?, version), sla_class = ?, updated = ? WHERE id = ?`).run(title, input.domain, newBody, sourcing, input.verified_at ?? null, input.freshness_anchor ?? null, input.created_by ?? null, input.version ?? null, slaClass, timestamp, pageId);
86
121
  }
87
- // Exact-duplicate guard: re-sending a page's citations on upsert must
88
- // not multiply them. A citation is a duplicate when claim, kind,
89
- // locator, and excerpt all match an existing row on the same page
90
- // (same identity tuple mergePages dedupes on).
91
- const dupCheck = db.prepare(`SELECT 1 FROM citations
92
- WHERE page_id = ? AND claim = ? AND source_kind = ?
93
- AND COALESCE(source_locator, '') = COALESCE(?, '')
94
- AND excerpt = ?`);
95
- const insertCit = db.prepare(`INSERT INTO citations (page_id, claim, source_kind, source_locator, excerpt, retrieved_at, created)
96
- VALUES (?, ?, ?, ?, ?, ?, ?)`);
97
- const tx = db.transaction(() => {
122
+ else {
123
+ const result = db.prepare(`INSERT INTO pages (uuid, slug, title, domain, body, sourcing, provenance, verified_at, freshness_anchor, sla_class, created_by, version, author, created, updated)
124
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`).run(uuid, input.slug, title, input.domain, newBody, sourcing, input.provenance ?? null, input.verified_at ?? timestamp, input.freshness_anchor ?? null, slaClass, input.created_by ?? null, input.version ?? null, input.actor ?? null, timestamp, timestamp);
125
+ pageId = Number(result.lastInsertRowid);
126
+ }
127
+ if (input.citations && input.citations.length > 0) {
98
128
  for (const cit of input.citations) {
99
129
  const dup = dupCheck.get(pageId, cit.claim, cit.source_kind, cit.source_locator ?? null, cit.excerpt);
100
130
  if (dup) {
@@ -104,9 +134,9 @@ export class SqliteKnowledgeBackend {
104
134
  insertCit.run(pageId, cit.claim, cit.source_kind, cit.source_locator ?? null, cit.excerpt, timestamp, timestamp);
105
135
  citationsAdded++;
106
136
  }
107
- });
108
- retryWrite(() => tx());
109
- }
137
+ }
138
+ });
139
+ retryWrite(() => tx.immediate());
110
140
  return Promise.resolve({
111
141
  uuid,
112
142
  slug: input.slug,
@@ -129,7 +159,7 @@ export class SqliteKnowledgeBackend {
129
159
  return Promise.resolve(null);
130
160
  if (opts?.stampAccess) {
131
161
  const now = new Date().toISOString();
132
- retryWrite(() => db.prepare('UPDATE pages SET last_accessed = ?, hit_count = hit_count + 1 WHERE id = ?').run(now, page.id));
162
+ this.recordAccess(db, [page.id], opts.identity ?? UNKNOWN_IDENTITY, now);
133
163
  page.last_accessed = now;
134
164
  page.hit_count += 1;
135
165
  }
@@ -210,12 +240,7 @@ export class SqliteKnowledgeBackend {
210
240
  // appearing in a listing is not a read.
211
241
  if (rows.length > 0 && input.stampAccess !== false) {
212
242
  const now = new Date().toISOString();
213
- const stamp = db.prepare('UPDATE pages SET last_accessed = ?, hit_count = hit_count + 1 WHERE id = ?');
214
- const tx = db.transaction((ids) => {
215
- for (const id of ids)
216
- stamp.run(now, id);
217
- });
218
- retryWrite(() => tx(rows.map((r) => r.id)));
243
+ this.recordAccess(db, rows.map((r) => r.id), input.identity ?? UNKNOWN_IDENTITY, now);
219
244
  }
220
245
  return Promise.resolve(rows.map((page) => ({
221
246
  ...page,
@@ -280,7 +305,7 @@ export class SqliteKnowledgeBackend {
280
305
  // Record the supersession.
281
306
  db.prepare("INSERT INTO supersessions (old_slug, new_slug, note, created) VALUES (?, ?, ?, ?)").run(input.old_slug, input.new_slug, input.note ?? null, timestamp);
282
307
  });
283
- retryWrite(() => tx());
308
+ retryWrite(() => tx.immediate());
284
309
  return Promise.resolve({
285
310
  old_slug: input.old_slug,
286
311
  new_slug: input.new_slug,
@@ -312,7 +337,7 @@ export class SqliteKnowledgeBackend {
312
337
  pages.push({ slug: p.slug, old_domain: p.domain, new_domain: newDomain });
313
338
  }
314
339
  });
315
- retryWrite(() => tx());
340
+ retryWrite(() => tx.immediate());
316
341
  return Promise.resolve({ moved: pages.length, pages, pointers_written: 0 });
317
342
  }
318
343
  // ── Batch by explicit slug list (re-domain only) ──────────────────────────
@@ -331,7 +356,7 @@ export class SqliteKnowledgeBackend {
331
356
  pages.push({ slug, old_domain: page.domain, new_domain: input.new_domain });
332
357
  }
333
358
  });
334
- retryWrite(() => tx());
359
+ retryWrite(() => tx.immediate());
335
360
  return Promise.resolve({ moved: pages.length, pages, pointers_written: 0 });
336
361
  }
337
362
  // ── Single-page mode ──────────────────────────────────────────────────────
@@ -373,7 +398,7 @@ export class SqliteKnowledgeBackend {
373
398
  db.prepare('UPDATE pages SET domain = ?, updated = ? WHERE slug = ?').run(input.new_domain, timestamp, currentSlug);
374
399
  }
375
400
  });
376
- retryWrite(() => tx());
401
+ retryWrite(() => tx.immediate());
377
402
  return Promise.resolve({
378
403
  moved: 1,
379
404
  pages: [{
@@ -395,7 +420,7 @@ export class SqliteKnowledgeBackend {
395
420
  if (!input.source_slugs || input.source_slugs.length === 0) {
396
421
  return Promise.reject(new Error('mergePages: source_slugs must not be empty'));
397
422
  }
398
- const targetRow = db.prepare('SELECT id, body, verified_at, freshness_anchor FROM pages WHERE slug = ?').get(input.target_slug);
423
+ const targetRow = db.prepare('SELECT id, body, verified_at, freshness_anchor, domain FROM pages WHERE slug = ?').get(input.target_slug);
399
424
  if (!targetRow) {
400
425
  return Promise.reject(new Error(`mergePages: target page not found: ${input.target_slug}`));
401
426
  }
@@ -455,8 +480,9 @@ export class SqliteKnowledgeBackend {
455
480
  db.prepare('UPDATE citations SET page_id = ? WHERE page_id = ?').run(targetRow.id, source.id);
456
481
  citationsMoved += after;
457
482
  }
458
- // Update target: verified_at, freshness_anchor, updated.
459
- db.prepare('UPDATE pages SET verified_at = ?, freshness_anchor = ?, updated = ? WHERE id = ?').run(maxVerifiedAt, freshnessAnchor ?? null, timestamp, targetRow.id);
483
+ // Update target: verified_at, freshness_anchor, sla_class, updated.
484
+ const mergedSlaClass = deriveSlaClass(freshnessAnchor ?? null, targetRow.domain);
485
+ db.prepare('UPDATE pages SET verified_at = ?, freshness_anchor = ?, sla_class = ?, updated = ? WHERE id = ?').run(maxVerifiedAt, freshnessAnchor ?? null, mergedSlaClass, timestamp, targetRow.id);
460
486
  // Optionally append loser bodies to target body.
461
487
  if (input.append_loser_bodies) {
462
488
  let appendedBody = targetRow.body;
@@ -482,7 +508,7 @@ export class SqliteKnowledgeBackend {
482
508
  }
483
509
  }
484
510
  });
485
- retryWrite(() => tx());
511
+ retryWrite(() => tx.immediate());
486
512
  return Promise.resolve({
487
513
  target_slug: input.target_slug,
488
514
  sources_merged: sourceRows.length,
@@ -524,7 +550,7 @@ export class SqliteKnowledgeBackend {
524
550
  db.prepare('DELETE FROM pages WHERE id = ?').run(row.id);
525
551
  }
526
552
  });
527
- retryWrite(() => tx());
553
+ retryWrite(() => tx.immediate());
528
554
  return Promise.resolve({ purged: input.slugs.length, slugs: input.slugs });
529
555
  }
530
556
  catch (e) {
@@ -550,19 +576,29 @@ export class SqliteKnowledgeBackend {
550
576
  }
551
577
  const rows = [];
552
578
  for (const slug of slugs) {
553
- const page = db.prepare('SELECT id, status, body FROM pages WHERE slug = ?').get(slug);
579
+ const page = db.prepare('SELECT id, status, body, freshness_anchor, domain FROM pages WHERE slug = ?').get(slug);
554
580
  if (!page) {
555
581
  return Promise.reject(new Error(`verifyPages: page not found: ${slug}`));
556
582
  }
557
583
  if (page.status === 'archived') {
558
584
  return Promise.reject(new Error(`verifyPages: page '${slug}' is archived — restore it before verifying`));
559
585
  }
560
- rows.push({ id: page.id, slug, body: page.body });
561
- }
586
+ rows.push({ id: page.id, slug, body: page.body, freshness_anchor: page.freshness_anchor, domain: page.domain });
587
+ }
588
+ const outcome = input.outcome ?? 'confirmed';
589
+ const citationChecked = input.citation_checked ? 1 : 0;
590
+ const insertVerification = db.prepare('INSERT INTO verifications (page_id, verifier, verified_at, outcome, citation_checked) VALUES (?, ?, ?, ?, ?)');
591
+ // verified_at means "last SUCCESSFUL verification" — it drives the
592
+ // stale-first queue and SLA checks. A 'stale' outcome is a failed
593
+ // verification: it goes in the audit trail but must not renew the
594
+ // stamp, or a known-wrong page drops to the back of the queue.
595
+ const stamped = outcome !== 'stale';
562
596
  let noted = false;
563
597
  const tx = db.transaction(() => {
564
598
  for (const row of rows) {
565
- db.prepare('UPDATE pages SET verified_at = ?, freshness_anchor = COALESCE(?, freshness_anchor) WHERE id = ?').run(verifiedAt, input.freshness_anchor ?? null, row.id);
599
+ const finalAnchor = input.freshness_anchor ?? row.freshness_anchor;
600
+ const slaClass = deriveSlaClass(finalAnchor, row.domain);
601
+ db.prepare('UPDATE pages SET verified_at = CASE WHEN ? THEN ? ELSE verified_at END, freshness_anchor = COALESCE(?, freshness_anchor), sla_class = ? WHERE id = ?').run(stamped ? 1 : 0, verifiedAt, input.freshness_anchor ?? null, slaClass, row.id);
566
602
  if (input.note) {
567
603
  const dateLabel = verifiedAt.slice(0, 10);
568
604
  const newBody = `${row.body}\n\n## Verification — ${dateLabel}\n\n${input.note}`;
@@ -572,13 +608,18 @@ export class SqliteKnowledgeBackend {
572
608
  db.prepare('UPDATE pages SET body = ?, updated = ? WHERE id = ?').run(newBody, timestamp, row.id);
573
609
  noted = true;
574
610
  }
611
+ // The attributed audit trail (t-675) — recorded for every outcome,
612
+ // additive to the verified_at stamp above (which stays for
613
+ // freshness sort/SLA and only moves on success).
614
+ insertVerification.run(row.id, input.verifier ?? null, verifiedAt, outcome, citationChecked);
575
615
  }
576
616
  });
577
- retryWrite(() => tx());
617
+ retryWrite(() => tx.immediate());
578
618
  return Promise.resolve({
579
619
  verified: rows.length,
580
620
  slugs: rows.map((r) => r.slug),
581
621
  verified_at: verifiedAt,
622
+ stamped,
582
623
  noted,
583
624
  });
584
625
  }
@@ -586,6 +627,21 @@ export class SqliteKnowledgeBackend {
586
627
  return Promise.reject(e);
587
628
  }
588
629
  }
630
+ getVerifications(slug) {
631
+ try {
632
+ const db = this.ensureOpen();
633
+ const page = db.prepare('SELECT id FROM pages WHERE slug = ?').get(slug);
634
+ if (!page) {
635
+ return Promise.reject(new Error(`getVerifications: page not found: ${slug}`));
636
+ }
637
+ const rows = db.prepare(`SELECT id, page_id, verifier, verified_at, outcome, citation_checked
638
+ FROM verifications WHERE page_id = ? ORDER BY id DESC`).all(page.id);
639
+ return Promise.resolve(rows.map((r) => ({ ...r, citation_checked: r.citation_checked !== 0 })));
640
+ }
641
+ catch (e) {
642
+ return Promise.reject(e);
643
+ }
644
+ }
589
645
  listRevisions(slug) {
590
646
  try {
591
647
  const db = this.ensureOpen();
@@ -593,7 +649,7 @@ export class SqliteKnowledgeBackend {
593
649
  if (!page) {
594
650
  return Promise.reject(new Error(`listRevisions: page not found: ${slug}`));
595
651
  }
596
- const rows = db.prepare(`SELECT id, page_id, slug, op, replaced_at, LENGTH(body) AS body_length
652
+ const rows = db.prepare(`SELECT id, page_id, slug, op, replaced_at, actor, LENGTH(body) AS body_length
597
653
  FROM page_revisions WHERE page_id = ? ORDER BY id DESC`).all(page.id);
598
654
  return Promise.resolve(rows);
599
655
  }
@@ -604,7 +660,7 @@ export class SqliteKnowledgeBackend {
604
660
  getRevision(revisionId) {
605
661
  try {
606
662
  const db = this.ensureOpen();
607
- const row = db.prepare('SELECT id, page_id, slug, op, replaced_at, body FROM page_revisions WHERE id = ?').get(revisionId);
663
+ const row = db.prepare('SELECT id, page_id, slug, op, replaced_at, actor, body FROM page_revisions WHERE id = ?').get(revisionId);
608
664
  return Promise.resolve(row ?? null);
609
665
  }
610
666
  catch (e) {
@@ -630,10 +686,10 @@ export class SqliteKnowledgeBackend {
630
686
  const tx = db.transaction(() => {
631
687
  // The body being displaced is itself preserved — a restore must
632
688
  // never be the second irreversible overwrite in the story.
633
- snapshotId = this.snapshotRevision(db, page.id, input.slug, page.body, 'history-restore', timestamp);
689
+ snapshotId = this.snapshotRevision(db, page.id, input.slug, page.body, 'history-restore', timestamp, input.actor ?? null);
634
690
  db.prepare('UPDATE pages SET body = ?, updated = ? WHERE id = ?').run(revision.body, timestamp, page.id);
635
691
  });
636
- retryWrite(() => tx());
692
+ retryWrite(() => tx.immediate());
637
693
  return Promise.resolve({
638
694
  slug: input.slug,
639
695
  revision_id: input.revision_id,
@@ -646,12 +702,13 @@ export class SqliteKnowledgeBackend {
646
702
  }
647
703
  }
648
704
  /**
649
- * Insert a body snapshot and prune beyond the per-page cap. Caller owns the
650
- * transaction when called from restoreRevision; called bare from writePage.
651
- * Each statement is wrapped in retryWrite so both call sites are safe.
705
+ * Insert a body snapshot and prune beyond the per-page cap. Always called
706
+ * inside a transaction owned by the caller (writePage or restoreRevision).
707
+ * The inner retryWrite calls run bare statements — safe inside an outer
708
+ * transaction; the outer transaction is the atomicity boundary.
652
709
  */
653
- snapshotRevision(db, pageId, slug, body, op, timestamp) {
654
- const result = retryWrite(() => db.prepare('INSERT INTO page_revisions (page_id, slug, body, op, replaced_at) VALUES (?, ?, ?, ?, ?)').run(pageId, slug, body, op, timestamp));
710
+ snapshotRevision(db, pageId, slug, body, op, timestamp, actor = null) {
711
+ const result = retryWrite(() => db.prepare('INSERT INTO page_revisions (page_id, slug, body, op, replaced_at, actor) VALUES (?, ?, ?, ?, ?, ?)').run(pageId, slug, body, op, timestamp, actor));
655
712
  retryWrite(() => db.prepare(`DELETE FROM page_revisions WHERE page_id = ? AND id NOT IN (
656
713
  SELECT id FROM page_revisions WHERE page_id = ? ORDER BY id DESC LIMIT ?
657
714
  )`).run(pageId, pageId, MAX_REVISIONS_PER_PAGE));
@@ -675,13 +732,24 @@ export class SqliteKnowledgeBackend {
675
732
  added++;
676
733
  }
677
734
  });
678
- retryWrite(() => tx());
735
+ retryWrite(() => tx.immediate());
679
736
  return Promise.resolve(added);
680
737
  }
681
738
  catch (e) {
682
739
  return Promise.reject(e);
683
740
  }
684
741
  }
742
+ getAccessStats(pageId) {
743
+ try {
744
+ const db = this.ensureOpen();
745
+ const rows = db.prepare(`SELECT identity, hit_count, last_accessed FROM knowledge_access
746
+ WHERE page_id = ? ORDER BY last_accessed DESC`).all(pageId);
747
+ return Promise.resolve(rows);
748
+ }
749
+ catch (e) {
750
+ return Promise.reject(e);
751
+ }
752
+ }
685
753
  close() {
686
754
  if (this.db) {
687
755
  this.db.close();
@@ -689,6 +757,29 @@ export class SqliteKnowledgeBackend {
689
757
  }
690
758
  }
691
759
  // ── Internals ──
760
+ /**
761
+ * Stamp a real read on each page id: bump the shared pages.hit_count /
762
+ * last_accessed aggregate (unchanged legacy behavior — this is what
763
+ * knowledge_maintain's cold/expansion ranking reads) AND upsert the
764
+ * per-identity row in knowledge_access (t-677 attribution). Both updates
765
+ * happen in one transaction so a crash between them can't desync the
766
+ * aggregate from the per-reader breakdown.
767
+ */
768
+ recordAccess(db, pageIds, identity, now) {
769
+ const bumpPage = db.prepare('UPDATE pages SET last_accessed = ?, hit_count = hit_count + 1 WHERE id = ?');
770
+ const upsertAccess = db.prepare(`INSERT INTO knowledge_access (page_id, identity, hit_count, last_accessed)
771
+ VALUES (?, ?, 1, ?)
772
+ ON CONFLICT(page_id, identity) DO UPDATE SET
773
+ hit_count = hit_count + 1,
774
+ last_accessed = excluded.last_accessed`);
775
+ const tx = db.transaction((ids) => {
776
+ for (const id of ids) {
777
+ bumpPage.run(now, id);
778
+ upsertAccess.run(id, identity, now);
779
+ }
780
+ });
781
+ retryWrite(() => tx(pageIds));
782
+ }
692
783
  ensureOpen() {
693
784
  if (!this.db) {
694
785
  throw new Error('KnowledgeBackend is already closed');
@@ -715,7 +806,8 @@ export class SqliteKnowledgeBackend {
715
806
  last_accessed TEXT,
716
807
  hit_count INTEGER NOT NULL DEFAULT 0,
717
808
  verified_at TEXT,
718
- freshness_anchor TEXT
809
+ freshness_anchor TEXT,
810
+ sla_class TEXT NOT NULL DEFAULT 'default'
719
811
  )`,
720
812
  `CREATE INDEX IF NOT EXISTS idx_pages_domain ON pages(domain)`,
721
813
  `CREATE INDEX IF NOT EXISTS idx_pages_slug ON pages(slug)`,
@@ -749,6 +841,29 @@ export class SqliteKnowledgeBackend {
749
841
  replaced_at TEXT NOT NULL
750
842
  )`,
751
843
  `CREATE INDEX IF NOT EXISTS idx_page_revisions_page ON page_revisions(page_id)`,
844
+ // Actor attribution (t-675): who verified a page, when, and what they
845
+ // found. Additive to pages.verified_at (which stays the fast single-
846
+ // column freshness stamp everything else queries/sorts on) — this
847
+ // table is the attributed audit trail underneath it.
848
+ `CREATE TABLE IF NOT EXISTS verifications (
849
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
850
+ page_id INTEGER NOT NULL REFERENCES pages(id) ON DELETE CASCADE,
851
+ verifier TEXT,
852
+ verified_at TEXT NOT NULL,
853
+ outcome TEXT NOT NULL DEFAULT 'confirmed',
854
+ citation_checked INTEGER NOT NULL DEFAULT 0
855
+ )`,
856
+ `CREATE INDEX IF NOT EXISTS idx_verifications_page ON verifications(page_id)`,
857
+ // Per-identity read attribution (t-677) — additive to pages.hit_count/
858
+ // last_accessed, never a replacement. See getAccessStats/recordAccess.
859
+ `CREATE TABLE IF NOT EXISTS knowledge_access (
860
+ page_id INTEGER NOT NULL REFERENCES pages(id) ON DELETE CASCADE,
861
+ identity TEXT NOT NULL,
862
+ hit_count INTEGER NOT NULL DEFAULT 0,
863
+ last_accessed TEXT,
864
+ PRIMARY KEY (page_id, identity)
865
+ )`,
866
+ `CREATE INDEX IF NOT EXISTS idx_knowledge_access_page ON knowledge_access(page_id)`,
752
867
  ];
753
868
  for (const sql of statements) {
754
869
  this.db.prepare(sql).run();
@@ -771,9 +886,56 @@ export class SqliteKnowledgeBackend {
771
886
  if (!pageCols.includes('version')) {
772
887
  this.db.prepare('ALTER TABLE pages ADD COLUMN version TEXT').run();
773
888
  }
889
+ // sla_class: machine-readable freshness SLA class (t-434, 2026-09-25).
890
+ // DEFAULT 'default' covers all rows created before this migration.
891
+ // Backfill then upgrades rows whose anchor or domain implies a non-default class.
892
+ if (!pageCols.includes('sla_class')) {
893
+ this.db.prepare("ALTER TABLE pages ADD COLUMN sla_class TEXT NOT NULL DEFAULT 'default'").run();
894
+ // Backfill: re-derive for every existing row.
895
+ const rows = this.db.prepare("SELECT id, freshness_anchor, domain FROM pages").all();
896
+ const update = this.db.prepare("UPDATE pages SET sla_class = ? WHERE id = ?");
897
+ const backfill = this.db.transaction(() => {
898
+ for (const row of rows) {
899
+ const klass = deriveSlaClass(row.freshness_anchor, row.domain);
900
+ if (klass !== 'default') {
901
+ update.run(klass, row.id);
902
+ }
903
+ }
904
+ });
905
+ backfill();
906
+ }
907
+ // Actor attribution (t-675): identity that wrote v1 of the page. Set
908
+ // only at creation (writePage) — never backfilled, since there is no
909
+ // way to recover who actually wrote pre-existing rows.
910
+ if (!pageCols.includes('author')) {
911
+ this.db.prepare('ALTER TABLE pages ADD COLUMN author TEXT').run();
912
+ }
913
+ const revisionCols = this.db.prepare('PRAGMA table_info(page_revisions)').all().map((c) => c.name);
914
+ if (!revisionCols.includes('actor')) {
915
+ this.db.prepare('ALTER TABLE page_revisions ADD COLUMN actor TEXT').run();
916
+ }
774
917
  }
775
918
  }
776
919
  // ── Helpers ──
920
+ /**
921
+ * SLA class derivation — deterministic, no model required (t-434).
922
+ *
923
+ * Priority:
924
+ * 1. version-anchored: freshness_anchor matches /v?\d+\.\d+/ → 'software' (14d)
925
+ * 2. device-domain: domain starts with a known device-domain prefix → 'device' (7d)
926
+ * 3. else → 'default' (30d)
927
+ *
928
+ * Callers may override with KnowledgePageInput.sla_class.
929
+ */
930
+ const DEVICE_DOMAIN_PREFIXES = ['music/gear', 'monitor/hardware'];
931
+ const RE_SLA_VERSION = /v?\d+\.\d+/i;
932
+ export function deriveSlaClass(anchor, domain) {
933
+ if (anchor && RE_SLA_VERSION.test(anchor))
934
+ return 'software';
935
+ if (DEVICE_DOMAIN_PREFIXES.some((p) => domain === p || domain.startsWith(`${p}/`)))
936
+ return 'device';
937
+ return 'default';
938
+ }
777
939
  function enforcePageBodyCap(body) {
778
940
  if (body.length > MAX_PAGE_BODY_LENGTH) {
779
941
  throw new Error(`Page body exceeds hard cap of ${MAX_PAGE_BODY_LENGTH} characters ` +
@@ -1,5 +1,5 @@
1
1
  import { type Database } from 'better-sqlite3';
2
- import type { MemoryBackend, MemoryInput, MemoryRef, RecallInput, MemoryMatch, ForgetInput, ForgetResult, UpdateInput, UpdateResult, PruneResult, ListInput, MemoryEntry, EmbeddingProvider, FindSimilarInput, AuditOptions, AuditReport, ArchiveInput, ArchiveResult, RestoreInput, RestoreResult, RecallObservation, MemoryRevisionMeta, MemoryRevision, MemoryRevisionRestoreInput, MemoryRevisionRestoreResult, MemorySupersededInput, MemorySupersededResult } from './types.js';
2
+ import type { MemoryBackend, MemoryInput, MemoryRef, RecallInput, MemoryMatch, ForgetInput, ForgetResult, UpdateInput, UpdateResult, PruneResult, ListInput, MemoryEntry, EmbeddingProvider, FindSimilarInput, AuditOptions, AuditReport, ArchiveInput, ArchiveResult, RestoreInput, RestoreResult, RecallObservation, MemoryRevisionMeta, MemoryRevision, MemoryRevisionRestoreInput, MemoryRevisionRestoreResult, MemorySupersededInput, MemorySupersededResult, MemoryMergeInput, MemoryMergeResult } from './types.js';
3
3
  export interface SqliteVecConfig {
4
4
  /** Absolute path to the SQLite database file */
5
5
  dbPath: string;
@@ -32,8 +32,24 @@ export declare class SqliteVecBackend implements MemoryBackend {
32
32
  getRevision(revisionId: number): Promise<MemoryRevision | null>;
33
33
  restoreRevision(input: MemoryRevisionRestoreInput): Promise<MemoryRevisionRestoreResult>;
34
34
  supersede(input: MemorySupersededInput): Promise<MemorySupersededResult>;
35
+ mergeMemories(input: MemoryMergeInput): Promise<MemoryMergeResult>;
35
36
  close(): void;
36
37
  getDatabase(): Database;
38
+ /**
39
+ * A transaction that reheats the active row holding `uniqKey` (times_seen
40
+ * + 1, updated = timestamp) and returns its ref, or returns undefined if no
41
+ * active row holds it. Run it with .immediate() on its own, or call it
42
+ * inside another transaction (better-sqlite3 nests it as a savepoint).
43
+ */
44
+ private reheatTx;
45
+ /**
46
+ * The dedup key a row should hold for the given body, or NULL when another
47
+ * ACTIVE row already holds that key — the existing holder keeps it, same
48
+ * policy as the migration backfill (rebuildUniqKeys). Call inside the
49
+ * write transaction that changes the body, so the check and the write see
50
+ * the same state.
51
+ */
52
+ private claimableUniq;
37
53
  /**
38
54
  * Insert a content snapshot into memory_revisions and prune beyond the per-memory
39
55
  * cap. Mirrors the knowledge wing's snapshotRevision() in sqlite-knowledge.ts.
@@ -60,5 +76,20 @@ export declare class SqliteVecBackend implements MemoryBackend {
60
76
  /** Stored embeddings for a set of memory ids (point lookups; pools are small). */
61
77
  private loadVectors;
62
78
  private initSchema;
79
+ /**
80
+ * Hard-delete rows by id. Every call is journaled to memory_deletions first
81
+ * (t-436) — this is the ONLY path that permanently destroys a memory, so it
82
+ * is the one place that must record what it destroyed. The journal table is
83
+ * standalone (keyed by ref, no FK to memories) so it survives the delete it
84
+ * describes.
85
+ */
63
86
  private deleteById;
87
+ /**
88
+ * The journal-then-delete transaction behind deleteById. Callers already
89
+ * inside a transaction (mergeMemories with hard_delete_losers) invoke it
90
+ * directly; better-sqlite3 nests it as a savepoint.
91
+ */
92
+ private deleteByIdTx;
64
93
  }
94
+ /** Float32Array view of a Buffer; copies only when alignment forces it. */
95
+ export declare function bufToF32(buf: Buffer): Float32Array;