@jbarket/loomai 0.5.0 → 0.7.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 (96) hide show
  1. package/CHANGELOG.md +129 -1
  2. package/README.md +115 -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 +81 -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/pieces.d.ts +24 -0
  36. package/dist/cli/pieces.js +139 -0
  37. package/dist/cli/recall.js +2 -1
  38. package/dist/cli/remember.js +10 -0
  39. package/dist/cli/remote.d.ts +36 -0
  40. package/dist/cli/remote.js +173 -0
  41. package/dist/cli/serve.d.ts +7 -0
  42. package/dist/cli/serve.js +94 -5
  43. package/dist/cli/subcommands.d.ts +1 -1
  44. package/dist/cli/subcommands.js +2 -2
  45. package/dist/cli/update.js +30 -11
  46. package/dist/clients.js +7 -4
  47. package/dist/config.d.ts +14 -0
  48. package/dist/config.js +25 -2
  49. package/dist/index.js +28 -8
  50. package/dist/injection/harnesses.js +9 -3
  51. package/dist/install/harnesses.js +7 -1
  52. package/dist/server.d.ts +8 -1
  53. package/dist/server.js +461 -374
  54. package/dist/tools/dossier.js +5 -1
  55. package/dist/tools/eurorack-import.js +3 -0
  56. package/dist/tools/identity.d.ts +2 -0
  57. package/dist/tools/identity.js +35 -2
  58. package/dist/tools/knowledge-history.d.ts +6 -1
  59. package/dist/tools/knowledge-history.js +26 -8
  60. package/dist/tools/knowledge-maintain.d.ts +5 -0
  61. package/dist/tools/knowledge-maintain.js +92 -5
  62. package/dist/tools/knowledge-recall.d.ts +7 -1
  63. package/dist/tools/knowledge-recall.js +53 -3
  64. package/dist/tools/knowledge-verify.d.ts +11 -1
  65. package/dist/tools/knowledge-verify.js +17 -4
  66. package/dist/tools/knowledge-write.d.ts +8 -1
  67. package/dist/tools/knowledge-write.js +27 -9
  68. package/dist/tools/memory-history.d.ts +5 -0
  69. package/dist/tools/memory-history.js +52 -0
  70. package/dist/tools/memory-list.js +2 -1
  71. package/dist/tools/memory-merge.d.ts +2 -0
  72. package/dist/tools/memory-merge.js +40 -0
  73. package/dist/tools/memory-revision-restore.d.ts +5 -0
  74. package/dist/tools/memory-revision-restore.js +33 -0
  75. package/dist/tools/person.d.ts +32 -0
  76. package/dist/tools/person.js +111 -0
  77. package/dist/tools/prune.js +1 -1
  78. package/dist/tools/recall.js +7 -1
  79. package/dist/tools/remember.js +18 -2
  80. package/dist/tools/update.js +5 -1
  81. package/dist/transport/cli-endpoint.d.ts +13 -0
  82. package/dist/transport/cli-endpoint.js +97 -0
  83. package/dist/transport/guards.d.ts +14 -0
  84. package/dist/transport/guards.js +26 -0
  85. package/dist/transport/http-server.d.ts +28 -1
  86. package/dist/transport/http-server.js +156 -18
  87. package/dist/transport/knowledge-server.d.ts +11 -0
  88. package/dist/transport/knowledge-server.js +30 -0
  89. package/dist/transport/knowledge-tools.d.ts +50 -0
  90. package/dist/transport/knowledge-tools.js +290 -0
  91. package/dist/transport/mcp-app-hello.d.ts +1 -1
  92. package/dist/widgets/display-modes.html +62 -56
  93. package/dist/widgets/hello.html +62 -56
  94. package/dist/widgets/proposals.html +62 -56
  95. package/dist/widgets/top-of-mind.html +60 -54
  96. package/package.json +7 -5
@@ -18,6 +18,10 @@ import { mmrSelect, DEFAULT_DIVERSITY } from './mmr.js';
18
18
  import { globToMatcher } from './glob.js';
19
19
  import { runMigrations } from './migrations.js';
20
20
  import { retryWrite } from './retry.js';
21
+ import { computeUniqKey } from './dedup.js';
22
+ import { effectiveScope, isVisibleInProject } from './scope.js';
23
+ import { FEEDBACK_CATEGORY } from '../categories.js';
24
+ import { FEEDBACK_CONFIDENCE_DEFAULT, applyObservation, decayedConfidence, } from './salience.js';
21
25
  function slugify(text) {
22
26
  return text
23
27
  .toLowerCase()
@@ -37,36 +41,82 @@ export class SqliteVecBackend {
37
41
  mkdirSync(dirname(config.dbPath), { recursive: true });
38
42
  this.db = new BetterSqlite3(config.dbPath);
39
43
  this.db.pragma('journal_mode = WAL');
40
- // Single-writer (c-loom-strictness §single-writer): WAL admits one writer at
41
- // a time; busy_timeout = 0 makes a second concurrent writer FAIL FAST with
42
- // SQLITE_BUSY rather than block for the better-sqlite3 default 5s. loom is
43
- // synchronous with one connection per call, so this never bites the daemon
44
- // itself — only a genuine second writer (e.g. a stray second instance) is
45
- // refused, which is exactly the guarantee. No torn write, no silent race.
46
- this.db.pragma('busy_timeout = 0');
44
+ // WAL + 5s busy_timeout: concurrent sleeves (wakes, lanes, voice) each open
45
+ // their own connection. A brief write-lock window (checkpoint, concurrent
46
+ // write) retries for up to 5s rather than failing immediately. A genuine
47
+ // second loom instance will still fail — it won't hold the lock that long.
48
+ this.db.pragma('busy_timeout = 5000');
47
49
  sqliteVec.load(this.db);
48
50
  this.initSchema();
49
51
  }
50
52
  // ── MemoryBackend interface ──
51
53
  async remember(input) {
52
- const uuid = randomUUID();
53
54
  const timestamp = new Date().toISOString();
55
+ const uniqKey = computeUniqKey({
56
+ category: input.category,
57
+ project: input.project,
58
+ title: input.title,
59
+ content: input.content,
60
+ scope: input.scope,
61
+ });
62
+ // Write-time dedup (t-335): a character-identical restatement (after
63
+ // normalizing) reheats the existing active row instead of inserting a
64
+ // second one. Scoped to active rows only — an archived memory that gets
65
+ // restated is a deliberate un-archive-by-rewrite, not a reheat.
66
+ //
67
+ // This first lookup is only a fast path that skips the embedder for a
68
+ // restatement. It is NOT the guarantee: another writer (this process or
69
+ // another connection on the same file) can insert the same key while we
70
+ // await the embedder. The authoritative check is repeated inside the
71
+ // IMMEDIATE insert transaction below, and the partial UNIQUE index
72
+ // idx_memories_uniq_exact makes a second active holder impossible
73
+ // (PR120 review).
74
+ const hit = this.db
75
+ .prepare('SELECT 1 FROM memories WHERE uniq = ? AND archived = 0')
76
+ .get(uniqKey);
77
+ if (hit) {
78
+ // Re-read under the write lock; if the holder was archived meanwhile,
79
+ // fall through to a fresh insert.
80
+ const reheated = retryWrite(() => this.reheatTx(uniqKey, timestamp).immediate());
81
+ if (reheated)
82
+ return reheated;
83
+ }
84
+ const uuid = randomUUID();
54
85
  const slug = slugify(input.title);
55
86
  const ref = `${input.category}/${slug}-${uuid.slice(0, 8)}`;
56
87
  const expiresAt = computeExpiresAt(timestamp, input.ttl);
57
88
  const vector = await this.embedder.embed(`${input.title}\n\n${input.content}`);
89
+ // A feedback memory is evidence-backed from the moment it's written: the
90
+ // observation that created it is the first piece of evidence. Every other
91
+ // category leaves confidence NULL ("not applicable") — this model is
92
+ // scoped to feedback, per t-338, not a generic trust score.
93
+ const isFeedback = input.category === FEEDBACK_CATEGORY;
94
+ const confidence = isFeedback ? FEEDBACK_CONFIDENCE_DEFAULT : null;
95
+ const evidenceCount = isFeedback ? 1 : 0;
58
96
  const insertMem = this.db.prepare(`
59
97
  INSERT INTO memories (
60
98
  uuid, ref, title, category, project, content, metadata,
61
- created, ttl, expires_at, salience
62
- ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
99
+ created, ttl, expires_at, salience, sourcing, provenance,
100
+ confidence, evidence_count, observed_at, scope, uniq
101
+ ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
63
102
  `);
64
103
  const insertVec = this.db.prepare('INSERT INTO vec_memories(rowid, embedding) VALUES (?, ?)');
65
104
  const tx = this.db.transaction(() => {
66
- const result = insertMem.run(uuid, ref, input.title, input.category, input.project ?? null, input.content, JSON.stringify(input.metadata ?? {}), timestamp, input.ttl ?? null, expiresAt, 1.0);
105
+ // Recheck under the write lock: a concurrent writer may have stored
106
+ // this exact content while we were embedding.
107
+ const winner = this.reheatTx(uniqKey, timestamp)();
108
+ if (winner)
109
+ return winner;
110
+ const result = insertMem.run(uuid, ref, input.title, input.category, input.project ?? null, input.content, JSON.stringify(input.metadata ?? {}), timestamp, input.ttl ?? null, expiresAt, 1.0, // a freshly authored memory is hot; the lane decays it from here
111
+ input.sourcing ?? null, input.provenance ?? null, confidence, evidenceCount, isFeedback ? timestamp : null, input.scope ?? null, uniqKey);
67
112
  insertVec.run(BigInt(result.lastInsertRowid), toVecBuffer(vector));
113
+ return undefined;
68
114
  });
69
- retryWrite(() => tx());
115
+ // IMMEDIATE: take the write lock before the recheck, so no other
116
+ // connection can commit the same key between the check and the insert.
117
+ const reheated = retryWrite(() => tx.immediate());
118
+ if (reheated)
119
+ return reheated;
70
120
  return {
71
121
  ref,
72
122
  category: input.category,
@@ -88,13 +138,17 @@ export class SqliteVecBackend {
88
138
  const candidates = this.searchVectors(queryVector, poolSize, poolSize * 4, (mem) => {
89
139
  if (categoryFilter && mem.category !== categoryFilter)
90
140
  return false;
91
- if (projectFilter && mem.project !== projectFilter)
141
+ if (!isVisibleInProject(mem, projectFilter))
92
142
  return false;
93
143
  return true;
94
144
  });
95
145
  // MMR only re-orders/selects among candidates that already passed the
96
146
  // filters; with <= limit candidates it returns them in relevance order.
147
+ // `blended` carries the score that actually drove each pick, aligned by
148
+ // index with `hits` — null means MMR didn't touch anything, so the
149
+ // blended score is just the raw similarity (see rowToMatch).
97
150
  let hits = candidates;
151
+ let blended = null;
98
152
  let diversityDrops = 0;
99
153
  if (diversity > 0 && candidates.length > limit) {
100
154
  const vectors = this.loadVectors(candidates.map((c) => c.mem.id));
@@ -105,9 +159,10 @@ export class SqliteVecBackend {
105
159
  }));
106
160
  const picked = mmrSelect(pool, limit, diversity);
107
161
  hits = picked.selected.map((c) => c.hit);
162
+ blended = picked.scores;
108
163
  diversityDrops = picked.diversityDrops;
109
164
  }
110
- const results = hits.map((h) => rowToMatch(h.mem, h.distance));
165
+ const results = hits.map((h, i) => rowToMatch(h.mem, h.distance, blended?.[i]));
111
166
  const hitIds = hits.map((h) => h.mem.id);
112
167
  if (hitIds.length > 0) {
113
168
  const now = new Date().toISOString();
@@ -181,48 +236,90 @@ export class SqliteVecBackend {
181
236
  return { deleted: targets.map((r) => r.ref) };
182
237
  }
183
238
  async update(input) {
184
- let row;
239
+ let found;
185
240
  if (input.ref) {
186
- row = this.db
187
- .prepare('SELECT * FROM memories WHERE ref = ?')
241
+ found = this.db
242
+ .prepare('SELECT id, ref, title FROM memories WHERE ref = ?')
188
243
  .get(input.ref);
189
244
  }
190
245
  else if (input.category && input.title) {
191
- row = this.db
192
- .prepare('SELECT * FROM memories WHERE category = ? AND title = ?')
246
+ found = this.db
247
+ .prepare('SELECT id, ref, title FROM memories WHERE category = ? AND title = ?')
193
248
  .get(input.category, input.title);
194
249
  }
195
250
  else {
196
251
  return { updated: false };
197
252
  }
198
- if (!row)
253
+ if (!found)
199
254
  return { updated: false };
200
- const newContent = input.content ?? row.content;
201
- const existingMeta = JSON.parse(row.metadata);
202
- const newMeta = input.metadata
203
- ? { ...existingMeta, ...input.metadata }
204
- : existingMeta;
255
+ const { id, ref, title } = found;
256
+ // The only async step, so it runs before the transaction. Everything that
257
+ // reads the row runs INSIDE the transaction below, against a fresh read —
258
+ // a row read up here would be stale by the time an overlapping update()
259
+ // commits, and writing it back would drop that update's content, metadata
260
+ // or evidence (PR130 review).
261
+ const vector = input.content !== undefined ? await this.embedder.embed(`${title}\n\n${input.content}`) : undefined;
205
262
  const updatedAt = new Date().toISOString();
206
- const updateStmt = this.db.prepare(`
207
- UPDATE memories
208
- SET content = ?, metadata = ?, updated = ?
209
- WHERE id = ?
210
- `);
211
- const updateVec = this.db.prepare('UPDATE vec_memories SET embedding = ? WHERE rowid = ?');
212
- const vector = await this.embedder.embed(`${row.title}\n\n${newContent}`);
213
- // Snapshot the current body before overwriting — same protection the knowledge
214
- // wing added after the 2026-06-01 verify run stomped 13 pages with no recovery.
215
- // Only snapshot when content actually changes to avoid noise in the history.
216
263
  let snapshotId;
217
- if (input.content !== undefined && input.content !== row.content) {
218
- snapshotId = this.snapshotRevision(row.id, row.ref, row.content, 'update', updatedAt);
219
- }
264
+ let newConfidence;
265
+ let newEvidenceCount;
220
266
  const tx = this.db.transaction(() => {
221
- updateStmt.run(newContent, JSON.stringify(newMeta), updatedAt, row.id);
222
- updateVec.run(toVecBuffer(vector), BigInt(row.id));
267
+ const row = this.db.prepare('SELECT * FROM memories WHERE id = ?').get(id);
268
+ if (!row)
269
+ return false; // deleted between lookup and write
270
+ // Only the columns this call is about get written; everything else is
271
+ // left as the row has it now.
272
+ const sets = ['updated = ?'];
273
+ const params = [updatedAt];
274
+ const contentChanged = input.content !== undefined && input.content !== row.content;
275
+ if (contentChanged) {
276
+ // Snapshot the current body before overwriting — same protection the
277
+ // knowledge wing added after the 2026-06-01 verify run stomped 13
278
+ // pages with no recovery. Only on a real change, to keep history quiet.
279
+ snapshotId = this.snapshotRevision(row.id, row.ref, row.content, 'update', updatedAt);
280
+ sets.push('content = ?', 'uniq = ?');
281
+ // The dedup key describes the current body — a stale one would make
282
+ // remember() of the OLD body return this row (PR120 review).
283
+ params.push(input.content, this.claimableUniq(row.id, { ...row, content: input.content }));
284
+ }
285
+ if (input.metadata) {
286
+ const existingMeta = JSON.parse(row.metadata);
287
+ sets.push('metadata = ?');
288
+ params.push(JSON.stringify({ ...existingMeta, ...input.metadata }));
289
+ }
290
+ // Confirm/contradict (t-338): computed in code, feedback memories only.
291
+ // The delta applies to the confidence as it reads NOW (decay
292
+ // materialized), then the evidence clock restarts — so a contradict
293
+ // always lowers the current value and a confirm always raises it, and
294
+ // the evidence an aged score has already lost can't come back.
295
+ if (input.observation && row.category === FEEDBACK_CATEGORY) {
296
+ const current = row.confidence !== null
297
+ ? decayedConfidence(row.confidence, row.observed_at ?? row.created, row.category, Date.parse(updatedAt))
298
+ : FEEDBACK_CONFIDENCE_DEFAULT;
299
+ newConfidence = applyObservation(current, input.observation);
300
+ newEvidenceCount = row.evidence_count + 1;
301
+ sets.push('confidence = ?', 'evidence_count = ?', 'observed_at = ?');
302
+ params.push(newConfidence, newEvidenceCount, updatedAt);
303
+ }
304
+ this.db.prepare(`UPDATE memories SET ${sets.join(', ')} WHERE id = ?`).run(...params, row.id);
305
+ if (contentChanged) {
306
+ this.db
307
+ .prepare('UPDATE vec_memories SET embedding = ? WHERE rowid = ?')
308
+ .run(toVecBuffer(vector), BigInt(row.id));
309
+ }
310
+ return true;
223
311
  });
224
- retryWrite(() => tx());
225
- return { updated: true, ref: row.ref, snapshotId };
312
+ // IMMEDIATE takes the write lock before the read, so another process
313
+ // (loom-http and the CLI share the file) can't commit between them.
314
+ if (!retryWrite(() => tx.immediate()))
315
+ return { updated: false };
316
+ return {
317
+ updated: true,
318
+ ref,
319
+ snapshotId,
320
+ confidence: newConfidence,
321
+ evidenceCount: newEvidenceCount,
322
+ };
226
323
  }
227
324
  async prune(options) {
228
325
  const dryRun = options?.dryRun ?? false;
@@ -250,7 +347,20 @@ export class SqliteVecBackend {
250
347
  }
251
348
  }
252
349
  if (!dryRun && expiredIds.length > 0) {
253
- this.deleteById(expiredIds);
350
+ // Archive instead of hard-delete: content is preserved for recovery,
351
+ // the embedding is dropped (no KNN slots wasted), archived = 1 hides
352
+ // the row from recall/list/audit — matching the contract in t-332.
353
+ const now = new Date().toISOString();
354
+ const tombstone = JSON.stringify({ note: 'TTL elapsed', archived_at: now });
355
+ const archiveStmt = this.db.prepare('UPDATE memories SET archived = 1, archive_note = ?, updated = ? WHERE id = ?');
356
+ const delVec = this.db.prepare('DELETE FROM vec_memories WHERE rowid = ?');
357
+ const tx = this.db.transaction(() => {
358
+ for (const id of expiredIds) {
359
+ archiveStmt.run(tombstone, now, id);
360
+ delVec.run(BigInt(id));
361
+ }
362
+ });
363
+ retryWrite(() => tx());
254
364
  }
255
365
  return { expired, stale };
256
366
  }
@@ -269,7 +379,8 @@ export class SqliteVecBackend {
269
379
  const where = `WHERE ${clauses.join(' AND ')}`;
270
380
  params.push(input.limit ?? 50);
271
381
  const rows = this.db
272
- .prepare(`SELECT ref, title, category, project, created
382
+ .prepare(`SELECT ref, title, category, project, created, sourcing, provenance,
383
+ confidence, evidence_count, observed_at
273
384
  FROM memories ${where}
274
385
  ORDER BY created DESC LIMIT ?`)
275
386
  .all(...params);
@@ -279,6 +390,12 @@ export class SqliteVecBackend {
279
390
  category: r.category,
280
391
  project: r.project ?? undefined,
281
392
  created: r.created,
393
+ sourcing: r.sourcing ?? undefined,
394
+ provenance: r.provenance ?? undefined,
395
+ confidence: r.confidence !== null
396
+ ? decayedConfidence(r.confidence, r.observed_at ?? r.created, r.category, Date.now())
397
+ : undefined,
398
+ evidenceCount: r.confidence !== null ? r.evidence_count : undefined,
282
399
  }));
283
400
  }
284
401
  async findSimilar(input) {
@@ -299,7 +416,7 @@ export class SqliteVecBackend {
299
416
  .get(BigInt(row.id));
300
417
  if (!vecRow)
301
418
  throw new Error(`findSimilar: embedding missing for ${input.ref}`);
302
- queryVector = Array.from(new Float32Array(vecRow.embedding.buffer, vecRow.embedding.byteOffset, vecRow.embedding.byteLength / 4));
419
+ queryVector = Array.from(bufToF32(vecRow.embedding));
303
420
  }
304
421
  else {
305
422
  queryVector = await (this.embedder.embedQuery?.(input.text) ??
@@ -316,7 +433,7 @@ export class SqliteVecBackend {
316
433
  return false;
317
434
  if (categoryFilter && mem.category !== categoryFilter)
318
435
  return false;
319
- if (projectFilter && (mem.project ?? null) !== projectFilter)
436
+ if (!isVisibleInProject(mem, projectFilter))
320
437
  return false;
321
438
  if (1 - vr.distance < minRelevance)
322
439
  return false;
@@ -357,24 +474,33 @@ export class SqliteVecBackend {
357
474
  });
358
475
  }
359
476
  }
360
- // Duplicate detection: for each memory, MATCH its stored embedding,
361
- // take the top non-self neighbour, keep pairs above threshold, dedupe.
362
- const duplicates = [];
477
+ // Duplicate detection: batch-load all embeddings in one query, then
478
+ // for each memory find its k-nearest neighbours (k=10 to surface
479
+ // clusters, not just the single closest), collect ALL pairs above the
480
+ // threshold, sort by descending similarity, and truncate to maxDuplicates.
481
+ // One DB query per row (the MATCH) instead of two (fetch + MATCH).
482
+ const allPairs = [];
363
483
  const seenPair = new Set();
364
484
  const pairKey = (a, b) => a < b ? `${a}:${b}` : `${b}:${a}`;
365
485
  const byIdRow = new Map(rows.map((r) => [r.id, r]));
486
+ // Load all stored embeddings in one pass so the inner loop only runs
487
+ // the MATCH query rather than a fetch-then-match pair.
488
+ const allEmbedRows = this.db
489
+ .prepare('SELECT rowid, embedding FROM vec_memories')
490
+ .all();
491
+ const embeddingMap = new Map();
492
+ for (const er of allEmbedRows) {
493
+ embeddingMap.set(Number(er.rowid), er.embedding);
494
+ }
495
+ const kNeighbours = 10;
366
496
  const vecStmt = this.db.prepare(`SELECT rowid, distance FROM vec_memories
367
- WHERE embedding MATCH ? AND k = 2
497
+ WHERE embedding MATCH ? AND k = ${kNeighbours}
368
498
  ORDER BY distance`);
369
499
  for (const r of rows) {
370
- if (duplicates.length >= maxDuplicates)
371
- break;
372
- const stored = this.db
373
- .prepare('SELECT embedding FROM vec_memories WHERE rowid = ?')
374
- .get(BigInt(r.id));
375
- if (!stored)
500
+ const emb = embeddingMap.get(r.id);
501
+ if (!emb)
376
502
  continue;
377
- const hits = vecStmt.all(stored.embedding);
503
+ const hits = vecStmt.all(emb);
378
504
  for (const h of hits) {
379
505
  if (h.rowid === r.id)
380
506
  continue;
@@ -388,15 +514,17 @@ export class SqliteVecBackend {
388
514
  const other = byIdRow.get(h.rowid);
389
515
  if (!other)
390
516
  continue;
391
- duplicates.push({
517
+ allPairs.push({
392
518
  a: { ref: r.ref, title: r.title },
393
519
  b: { ref: other.ref, title: other.title },
394
520
  relevance,
395
521
  });
396
- if (duplicates.length >= maxDuplicates)
397
- break;
398
522
  }
399
523
  }
524
+ // Sort highest-similarity pairs first so the truncation keeps the worst
525
+ // duplicates, not just the ones discovered first by insertion order.
526
+ allPairs.sort((x, y) => y.relevance - x.relevance);
527
+ const duplicates = allPairs.slice(0, maxDuplicates);
400
528
  return {
401
529
  totalMemories: rows.length,
402
530
  byCategory,
@@ -443,14 +571,14 @@ export class SqliteVecBackend {
443
571
  let rows = [];
444
572
  if (input.ref) {
445
573
  const row = this.db
446
- .prepare('SELECT id, ref, title, content FROM memories WHERE ref = ? AND archived = 1')
574
+ .prepare('SELECT id, ref, title, content, category, project, scope FROM memories WHERE ref = ? AND archived = 1')
447
575
  .get(input.ref);
448
576
  if (row)
449
577
  rows = [row];
450
578
  }
451
579
  else if (input.category && input.title) {
452
580
  rows = this.db
453
- .prepare('SELECT id, ref, title, content FROM memories WHERE category = ? AND title = ? AND archived = 1')
581
+ .prepare('SELECT id, ref, title, content, category, project, scope FROM memories WHERE category = ? AND title = ? AND archived = 1')
454
582
  .all(input.category, input.title);
455
583
  }
456
584
  else {
@@ -461,19 +589,22 @@ export class SqliteVecBackend {
461
589
  // Archive dropped the embedding; rebuild it from the stored content.
462
590
  const vectors = await this.embedder.embedBatch(rows.map((r) => `${r.title}\n\n${r.content}`));
463
591
  const now = new Date().toISOString();
464
- const stmt = this.db.prepare('UPDATE memories SET archived = 0, archive_note = NULL, updated = ? WHERE id = ?');
592
+ const stmt = this.db.prepare('UPDATE memories SET archived = 0, archive_note = NULL, uniq = ?, updated = ? WHERE id = ?');
465
593
  // Delete-then-insert: legacy rows archived before delete-on-archive
466
594
  // may still hold a vector, and vec0 rowids must stay unique.
467
595
  const delVec = this.db.prepare('DELETE FROM vec_memories WHERE rowid = ?');
468
596
  const insVec = this.db.prepare('INSERT INTO vec_memories(rowid, embedding) VALUES (?, ?)');
469
597
  const tx = this.db.transaction(() => {
470
598
  rows.forEach((row, i) => {
471
- stmt.run(now, row.id);
599
+ // Re-derive the key: the row may carry a stale or pre-rekey one, and
600
+ // while it sat archived the same body may have been remembered again
601
+ // — that active row keeps the key (PR120 review).
602
+ stmt.run(this.claimableUniq(row.id, row), now, row.id);
472
603
  delVec.run(BigInt(row.id));
473
604
  insVec.run(BigInt(row.id), toVecBuffer(vectors[i]));
474
605
  });
475
606
  });
476
- retryWrite(() => tx());
607
+ retryWrite(() => tx.immediate());
477
608
  return { restored: rows.map((r) => r.ref) };
478
609
  }
479
610
  listRevisions(ref) {
@@ -517,13 +648,13 @@ export class SqliteVecBackend {
517
648
  // Snapshot the current body before replacing it.
518
649
  const snapshotId = this.snapshotRevision(mem.id, mem.ref, mem.content, 'revision-restore', timestamp);
519
650
  const vector = await this.embedder.embed(`${mem.title}\n\n${rev.content}`);
520
- const updateStmt = this.db.prepare('UPDATE memories SET content = ?, updated = ? WHERE id = ?');
651
+ const updateStmt = this.db.prepare('UPDATE memories SET content = ?, uniq = ?, updated = ? WHERE id = ?');
521
652
  const updateVec = this.db.prepare('UPDATE vec_memories SET embedding = ? WHERE rowid = ?');
522
653
  const tx = this.db.transaction(() => {
523
- updateStmt.run(rev.content, timestamp, mem.id);
654
+ updateStmt.run(rev.content, this.claimableUniq(mem.id, { ...mem, content: rev.content }), timestamp, mem.id);
524
655
  updateVec.run(toVecBuffer(vector), BigInt(mem.id));
525
656
  });
526
- retryWrite(() => tx());
657
+ retryWrite(() => tx.immediate());
527
658
  return { ref: input.ref, revision_id: input.revision_id, restored: true, snapshot_id: snapshotId };
528
659
  }
529
660
  supersede(input) {
@@ -563,6 +694,83 @@ export class SqliteVecBackend {
563
694
  retryWrite(() => tx());
564
695
  return Promise.resolve({ old_ref: input.old_ref, new_ref: input.new_ref, archived: true });
565
696
  }
697
+ async mergeMemories(input) {
698
+ if (!input.source_refs || input.source_refs.length === 0) {
699
+ return Promise.reject(new Error('mergeMemories: source_refs must not be empty'));
700
+ }
701
+ const targetRow = this.db
702
+ .prepare('SELECT * FROM memories WHERE ref = ? AND archived = 0')
703
+ .get(input.target_ref);
704
+ if (!targetRow) {
705
+ return Promise.reject(new Error(`mergeMemories: target_ref not found or already archived: ${input.target_ref}`));
706
+ }
707
+ const sourceRows = [];
708
+ for (const ref of input.source_refs) {
709
+ if (ref === input.target_ref) {
710
+ return Promise.reject(new Error(`mergeMemories: source_ref '${ref}' is the same as target_ref — cannot merge a memory into itself`));
711
+ }
712
+ const row = this.db
713
+ .prepare('SELECT * FROM memories WHERE ref = ? AND archived = 0')
714
+ .get(ref);
715
+ if (!row) {
716
+ return Promise.reject(new Error(`mergeMemories: source_ref not found or already archived: ${ref}`));
717
+ }
718
+ sourceRows.push(row);
719
+ }
720
+ const timestamp = new Date().toISOString();
721
+ const losers = sourceRows.map((r) => ({ ref: r.ref, title: r.title, content: r.content }));
722
+ const combinedTimesSeen = targetRow.times_seen + sourceRows.reduce((sum, r) => sum + r.times_seen, 0);
723
+ let newContent = targetRow.content;
724
+ if (input.append_loser_bodies) {
725
+ for (const loser of losers) {
726
+ newContent += `\n\n--- merged from ${loser.ref} ---\n\n${loser.content}`;
727
+ }
728
+ }
729
+ let vector;
730
+ if (newContent !== targetRow.content) {
731
+ // Snapshot the target body before overwriting — same protection update() gives.
732
+ this.snapshotRevision(targetRow.id, targetRow.ref, targetRow.content, 'update', timestamp);
733
+ vector = await this.embedder.embed(`${targetRow.title}\n\n${newContent}`);
734
+ }
735
+ const updateTarget = this.db.prepare('UPDATE memories SET content = ?, times_seen = ?, updated = ?, uniq = ? WHERE id = ?');
736
+ const updateVec = this.db.prepare('UPDATE vec_memories SET embedding = ? WHERE rowid = ?');
737
+ const repointRevisions = this.db.prepare('UPDATE memory_revisions SET memory_id = ? WHERE memory_id = ?');
738
+ const archiveSource = this.db.prepare('UPDATE memories SET archived = 1, archive_note = ?, updated = ? WHERE id = ?');
739
+ const delVec = this.db.prepare('DELETE FROM vec_memories WHERE rowid = ?');
740
+ const insertSup = this.db.prepare('INSERT INTO memory_supersessions (old_ref, new_ref, note, created) VALUES (?, ?, ?, ?)');
741
+ const tx = this.db.transaction(() => {
742
+ for (const source of sourceRows) {
743
+ // Re-parent revision history before the source row can be deleted.
744
+ repointRevisions.run(targetRow.id, source.id);
745
+ const tombstoneNote = input.note
746
+ ? `Merged into ${input.target_ref}. ${input.note}`
747
+ : `Merged into ${input.target_ref}.`;
748
+ archiveSource.run(JSON.stringify({ note: tombstoneNote, archived_at: timestamp }), timestamp, source.id);
749
+ delVec.run(BigInt(source.id));
750
+ insertSup.run(source.ref, input.target_ref, input.note ?? null, timestamp);
751
+ if (input.hard_delete_losers) {
752
+ // Journaled like every other hard delete (t-436); nested here, so
753
+ // the journal row and the delete commit or roll back with the merge.
754
+ this.deleteByIdTx([source.id], 'merge')();
755
+ }
756
+ }
757
+ // After the sources are archived, so a key one of them held is free
758
+ // for the target's (possibly concatenated) body.
759
+ const newUniq = newContent !== targetRow.content
760
+ ? this.claimableUniq(targetRow.id, { ...targetRow, content: newContent })
761
+ : targetRow.uniq;
762
+ updateTarget.run(newContent, combinedTimesSeen, timestamp, newUniq, targetRow.id);
763
+ if (vector)
764
+ updateVec.run(toVecBuffer(vector), BigInt(targetRow.id));
765
+ });
766
+ retryWrite(() => tx.immediate());
767
+ return {
768
+ target_ref: input.target_ref,
769
+ sources_merged: sourceRows.length,
770
+ times_seen: combinedTimesSeen,
771
+ losers,
772
+ };
773
+ }
566
774
  close() {
567
775
  this.db.close();
568
776
  }
@@ -570,6 +778,45 @@ export class SqliteVecBackend {
570
778
  return this.db;
571
779
  }
572
780
  // ── Internals ──
781
+ /**
782
+ * A transaction that reheats the active row holding `uniqKey` (times_seen
783
+ * + 1, updated = timestamp) and returns its ref, or returns undefined if no
784
+ * active row holds it. Run it with .immediate() on its own, or call it
785
+ * inside another transaction (better-sqlite3 nests it as a savepoint).
786
+ */
787
+ reheatTx(uniqKey, timestamp) {
788
+ return this.db.transaction(() => {
789
+ const existing = this.db
790
+ .prepare('SELECT id, ref, title, category FROM memories WHERE uniq = ? AND archived = 0 LIMIT 1')
791
+ .get(uniqKey);
792
+ if (!existing)
793
+ return undefined;
794
+ this.db
795
+ .prepare('UPDATE memories SET times_seen = times_seen + 1, updated = ? WHERE id = ?')
796
+ .run(timestamp, existing.id);
797
+ const slashIdx = existing.ref.indexOf('/');
798
+ return {
799
+ ref: existing.ref,
800
+ category: existing.category,
801
+ filename: slashIdx >= 0 ? existing.ref.slice(slashIdx + 1) : existing.ref,
802
+ title: existing.title,
803
+ };
804
+ });
805
+ }
806
+ /**
807
+ * The dedup key a row should hold for the given body, or NULL when another
808
+ * ACTIVE row already holds that key — the existing holder keeps it, same
809
+ * policy as the migration backfill (rebuildUniqKeys). Call inside the
810
+ * write transaction that changes the body, so the check and the write see
811
+ * the same state.
812
+ */
813
+ claimableUniq(id, row) {
814
+ const key = computeUniqKey(row);
815
+ const holder = this.db
816
+ .prepare('SELECT id FROM memories WHERE uniq = ? AND archived = 0 AND id != ? LIMIT 1')
817
+ .get(key, id);
818
+ return holder ? null : key;
819
+ }
573
820
  /**
574
821
  * Insert a content snapshot into memory_revisions and prune beyond the per-memory
575
822
  * cap. Mirrors the knowledge wing's snapshotRevision() in sqlite-knowledge.ts.
@@ -660,7 +907,7 @@ export class SqliteVecBackend {
660
907
  const row = stmt.get(BigInt(id));
661
908
  if (!row)
662
909
  continue;
663
- out.set(id, new Float32Array(row.embedding.buffer, row.embedding.byteOffset, row.embedding.byteLength / 4));
910
+ out.set(id, bufToF32(row.embedding));
664
911
  }
665
912
  return out;
666
913
  }
@@ -697,31 +944,97 @@ export class SqliteVecBackend {
697
944
  // aborts startup loudly rather than leaving memory half-broken.
698
945
  runMigrations(this.db, { strict: true });
699
946
  }
700
- deleteById(ids) {
947
+ /**
948
+ * Hard-delete rows by id. Every call is journaled to memory_deletions first
949
+ * (t-436) — this is the ONLY path that permanently destroys a memory, so it
950
+ * is the one place that must record what it destroyed. The journal table is
951
+ * standalone (keyed by ref, no FK to memories) so it survives the delete it
952
+ * describes.
953
+ */
954
+ deleteById(ids, op = 'forget') {
701
955
  if (ids.length === 0)
702
956
  return;
957
+ const tx = this.deleteByIdTx(ids, op);
958
+ retryWrite(() => tx());
959
+ }
960
+ /**
961
+ * The journal-then-delete transaction behind deleteById. Callers already
962
+ * inside a transaction (mergeMemories with hard_delete_losers) invoke it
963
+ * directly; better-sqlite3 nests it as a savepoint.
964
+ */
965
+ deleteByIdTx(ids, op) {
703
966
  const placeholders = ids.map(() => '?').join(',');
967
+ const selectRows = this.db.prepare(`SELECT ref, category, title, project, content, created
968
+ FROM memories WHERE id IN (${placeholders})`);
969
+ const journalStmt = this.db.prepare(`INSERT INTO memory_deletions (ref, category, title, project, content, created, deleted_at, op)
970
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?)`);
704
971
  const delMem = this.db.prepare(`DELETE FROM memories WHERE id IN (${placeholders})`);
705
972
  const delVec = this.db.prepare(`DELETE FROM vec_memories WHERE rowid IN (${placeholders})`);
706
973
  const bigIds = ids.map((n) => BigInt(n));
707
- const tx = this.db.transaction(() => {
974
+ return this.db.transaction(() => {
975
+ // Read inside the transaction, so the journal records exactly the
976
+ // rows this delete removes.
977
+ const rows = selectRows.all(...ids);
978
+ const deletedAt = new Date().toISOString();
979
+ for (const row of rows) {
980
+ journalStmt.run(row.ref, row.category, row.title, row.project, row.content, row.created, deletedAt, op);
981
+ }
708
982
  delMem.run(...ids);
709
983
  delVec.run(...bigIds);
710
984
  });
711
- retryWrite(() => tx());
712
985
  }
713
986
  }
714
- // ── Helpers ──
715
987
  const EMPTY_VECTOR = new Float32Array(0);
988
+ /** Float32Array view of a Buffer; copies only when alignment forces it. */
989
+ export function bufToF32(buf) {
990
+ if (buf.byteOffset % Float32Array.BYTES_PER_ELEMENT === 0) {
991
+ return new Float32Array(buf.buffer, buf.byteOffset, buf.byteLength / Float32Array.BYTES_PER_ELEMENT);
992
+ }
993
+ return new Float32Array(buf.buffer.slice(buf.byteOffset, buf.byteOffset + buf.byteLength));
994
+ }
716
995
  function clamp01(x) {
717
996
  if (!Number.isFinite(x))
718
997
  return DEFAULT_DIVERSITY;
719
998
  return Math.min(1, Math.max(0, x));
720
999
  }
1000
+ /**
1001
+ * An all-zero (or otherwise signal-free) embedding matches every row
1002
+ * equally in cosine search — it doesn't fail loudly, it just sits there
1003
+ * forever as a vector that neither rejects nor ever truly matches
1004
+ * anything. Refuse to persist one instead of writing that landmine (t-334;
1005
+ * mirrors Memori's embedding_rows_have_signal).
1006
+ */
1007
+ function hasEmbeddingSignal(vector) {
1008
+ let sumSq = 0;
1009
+ for (const v of vector)
1010
+ sumSq += v * v;
1011
+ return sumSq > 1e-12;
1012
+ }
721
1013
  function toVecBuffer(vector) {
1014
+ if (!hasEmbeddingSignal(vector)) {
1015
+ throw new Error(`refusing to persist a degenerate embedding — ${vector.length} dims with no real signal (all-zero or near-zero)`);
1016
+ }
722
1017
  return Buffer.from(new Float32Array(vector).buffer);
723
1018
  }
724
- function rowToMatch(row, distance) {
1019
+ /**
1020
+ * Decayed confidence for display: undefined for non-feedback memories (NULL
1021
+ * stored confidence), else the raw value relaxed toward neutral by how long
1022
+ * it's been since the last observation. `observed_at` (not `updated`, which
1023
+ * every edit stamps, nor `last_accessed`) is the decay clock — see decayedConfidence()'s doc comment in salience.ts.
1024
+ */
1025
+ function decayedRowConfidence(row) {
1026
+ if (row.confidence === null)
1027
+ return undefined;
1028
+ const lastObserved = row.observed_at ?? row.created;
1029
+ return decayedConfidence(row.confidence, lastObserved, row.category, Date.now());
1030
+ }
1031
+ /**
1032
+ * `blendedRelevance`, when given, is the MMR score that actually placed this
1033
+ * row (see recall()'s `blended` array) — omit it (findSimilar, or recall
1034
+ * when MMR didn't run) and relevance falls back to the raw similarity.
1035
+ */
1036
+ function rowToMatch(row, distance, blendedRelevance) {
1037
+ const similarity = 1 - distance;
725
1038
  return {
726
1039
  path: row.ref,
727
1040
  title: row.title,
@@ -729,9 +1042,15 @@ function rowToMatch(row, distance) {
729
1042
  project: row.project ?? undefined,
730
1043
  created: row.created,
731
1044
  content: row.content,
732
- relevance: 1 - distance,
1045
+ relevance: blendedRelevance ?? similarity,
1046
+ similarity,
1047
+ scope: effectiveScope(row),
733
1048
  lastAccessed: row.last_accessed ?? undefined,
734
1049
  ttl: row.ttl ?? undefined,
735
1050
  expiresAt: row.expires_at ?? undefined,
1051
+ sourcing: row.sourcing ?? undefined,
1052
+ provenance: row.provenance ?? undefined,
1053
+ confidence: decayedRowConfidence(row),
1054
+ evidenceCount: row.confidence !== null ? row.evidence_count : undefined,
736
1055
  };
737
1056
  }