@jbarket/loomai 0.5.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 (176) hide show
  1. package/CHANGELOG.md +475 -0
  2. package/LICENSE +661 -0
  3. package/README.md +702 -0
  4. package/assets/demo.cast +396 -0
  5. package/assets/skill/SKILL.md +192 -0
  6. package/dist/backends/embedding-runtime.d.ts +39 -0
  7. package/dist/backends/embedding-runtime.js +265 -0
  8. package/dist/backends/episodes.d.ts +36 -0
  9. package/dist/backends/episodes.js +126 -0
  10. package/dist/backends/fastembed.d.ts +18 -0
  11. package/dist/backends/fastembed.js +95 -0
  12. package/dist/backends/glob.d.ts +10 -0
  13. package/dist/backends/glob.js +16 -0
  14. package/dist/backends/index.d.ts +6 -0
  15. package/dist/backends/index.js +78 -0
  16. package/dist/backends/migrations.d.ts +38 -0
  17. package/dist/backends/migrations.js +157 -0
  18. package/dist/backends/mmr.d.ts +45 -0
  19. package/dist/backends/mmr.js +82 -0
  20. package/dist/backends/proposals.d.ts +65 -0
  21. package/dist/backends/proposals.js +182 -0
  22. package/dist/backends/recall-log.d.ts +42 -0
  23. package/dist/backends/recall-log.js +143 -0
  24. package/dist/backends/retry.d.ts +29 -0
  25. package/dist/backends/retry.js +60 -0
  26. package/dist/backends/salience.d.ts +57 -0
  27. package/dist/backends/salience.js +186 -0
  28. package/dist/backends/sqlite-knowledge.d.ts +37 -0
  29. package/dist/backends/sqlite-knowledge.js +788 -0
  30. package/dist/backends/sqlite-vec.d.ts +64 -0
  31. package/dist/backends/sqlite-vec.js +737 -0
  32. package/dist/backends/ttl.d.ts +24 -0
  33. package/dist/backends/ttl.js +50 -0
  34. package/dist/backends/types.d.ts +562 -0
  35. package/dist/backends/types.js +8 -0
  36. package/dist/blocks/harness.d.ts +45 -0
  37. package/dist/blocks/harness.js +142 -0
  38. package/dist/blocks/model.d.ts +4 -0
  39. package/dist/blocks/model.js +56 -0
  40. package/dist/blocks/types.d.ts +40 -0
  41. package/dist/blocks/types.js +34 -0
  42. package/dist/categories.d.ts +16 -0
  43. package/dist/categories.js +29 -0
  44. package/dist/cli/args.d.ts +21 -0
  45. package/dist/cli/args.js +47 -0
  46. package/dist/cli/bootstrap.d.ts +2 -0
  47. package/dist/cli/bootstrap.js +214 -0
  48. package/dist/cli/doctor.d.ts +2 -0
  49. package/dist/cli/doctor.js +206 -0
  50. package/dist/cli/forget.d.ts +2 -0
  51. package/dist/cli/forget.js +97 -0
  52. package/dist/cli/harness.d.ts +2 -0
  53. package/dist/cli/harness.js +91 -0
  54. package/dist/cli/index.d.ts +2 -0
  55. package/dist/cli/index.js +127 -0
  56. package/dist/cli/inject.d.ts +2 -0
  57. package/dist/cli/inject.js +298 -0
  58. package/dist/cli/install.d.ts +2 -0
  59. package/dist/cli/install.js +137 -0
  60. package/dist/cli/io.d.ts +14 -0
  61. package/dist/cli/io.js +63 -0
  62. package/dist/cli/knowledge.d.ts +2 -0
  63. package/dist/cli/knowledge.js +216 -0
  64. package/dist/cli/memory.d.ts +2 -0
  65. package/dist/cli/memory.js +608 -0
  66. package/dist/cli/migrate.d.ts +2 -0
  67. package/dist/cli/migrate.js +116 -0
  68. package/dist/cli/recall.d.ts +2 -0
  69. package/dist/cli/recall.js +91 -0
  70. package/dist/cli/remember.d.ts +2 -0
  71. package/dist/cli/remember.js +111 -0
  72. package/dist/cli/serve.d.ts +2 -0
  73. package/dist/cli/serve.js +71 -0
  74. package/dist/cli/subcommands.d.ts +7 -0
  75. package/dist/cli/subcommands.js +11 -0
  76. package/dist/cli/tui/multi-select.d.ts +53 -0
  77. package/dist/cli/tui/multi-select.js +116 -0
  78. package/dist/cli/update-identity.d.ts +2 -0
  79. package/dist/cli/update-identity.js +90 -0
  80. package/dist/cli/update.d.ts +2 -0
  81. package/dist/cli/update.js +87 -0
  82. package/dist/cli/wake.d.ts +2 -0
  83. package/dist/cli/wake.js +53 -0
  84. package/dist/clients.d.ts +8 -0
  85. package/dist/clients.js +63 -0
  86. package/dist/config.d.ts +59 -0
  87. package/dist/config.js +160 -0
  88. package/dist/index.d.ts +15 -0
  89. package/dist/index.js +75 -0
  90. package/dist/injection/harnesses.d.ts +17 -0
  91. package/dist/injection/harnesses.js +51 -0
  92. package/dist/injection/render.d.ts +17 -0
  93. package/dist/injection/render.js +50 -0
  94. package/dist/injection/writer.d.ts +25 -0
  95. package/dist/injection/writer.js +118 -0
  96. package/dist/install/harnesses.d.ts +26 -0
  97. package/dist/install/harnesses.js +90 -0
  98. package/dist/install/names.d.ts +16 -0
  99. package/dist/install/names.js +36 -0
  100. package/dist/install/render.d.ts +18 -0
  101. package/dist/install/render.js +42 -0
  102. package/dist/install/skill-source.d.ts +1 -0
  103. package/dist/install/skill-source.js +11 -0
  104. package/dist/path-safety.d.ts +19 -0
  105. package/dist/path-safety.js +62 -0
  106. package/dist/server.d.ts +8 -0
  107. package/dist/server.js +571 -0
  108. package/dist/tools/append-maintenance-log.d.ts +52 -0
  109. package/dist/tools/append-maintenance-log.js +103 -0
  110. package/dist/tools/archive.d.ts +2 -0
  111. package/dist/tools/archive.js +21 -0
  112. package/dist/tools/bootstrap.d.ts +11 -0
  113. package/dist/tools/bootstrap.js +161 -0
  114. package/dist/tools/dossier.d.ts +1 -0
  115. package/dist/tools/dossier.js +102 -0
  116. package/dist/tools/eurorack-import.d.ts +47 -0
  117. package/dist/tools/eurorack-import.js +199 -0
  118. package/dist/tools/find-similar.d.ts +3 -0
  119. package/dist/tools/find-similar.js +26 -0
  120. package/dist/tools/forget.d.ts +6 -0
  121. package/dist/tools/forget.js +70 -0
  122. package/dist/tools/harness.d.ts +18 -0
  123. package/dist/tools/harness.js +54 -0
  124. package/dist/tools/identity-scaffold.d.ts +22 -0
  125. package/dist/tools/identity-scaffold.js +142 -0
  126. package/dist/tools/identity.d.ts +4 -0
  127. package/dist/tools/identity.js +226 -0
  128. package/dist/tools/knowledge-archive.d.ts +5 -0
  129. package/dist/tools/knowledge-archive.js +24 -0
  130. package/dist/tools/knowledge-history.d.ts +6 -0
  131. package/dist/tools/knowledge-history.js +55 -0
  132. package/dist/tools/knowledge-maintain.d.ts +9 -0
  133. package/dist/tools/knowledge-maintain.js +113 -0
  134. package/dist/tools/knowledge-merge.d.ts +8 -0
  135. package/dist/tools/knowledge-merge.js +40 -0
  136. package/dist/tools/knowledge-move.d.ts +10 -0
  137. package/dist/tools/knowledge-move.js +48 -0
  138. package/dist/tools/knowledge-purge.d.ts +5 -0
  139. package/dist/tools/knowledge-purge.js +20 -0
  140. package/dist/tools/knowledge-recall.d.ts +20 -0
  141. package/dist/tools/knowledge-recall.js +142 -0
  142. package/dist/tools/knowledge-restore.d.ts +4 -0
  143. package/dist/tools/knowledge-restore.js +24 -0
  144. package/dist/tools/knowledge-supersede.d.ts +6 -0
  145. package/dist/tools/knowledge-supersede.js +36 -0
  146. package/dist/tools/knowledge-verify.d.ts +8 -0
  147. package/dist/tools/knowledge-verify.js +39 -0
  148. package/dist/tools/knowledge-write.d.ts +25 -0
  149. package/dist/tools/knowledge-write.js +87 -0
  150. package/dist/tools/memory-audit.d.ts +3 -0
  151. package/dist/tools/memory-audit.js +47 -0
  152. package/dist/tools/memory-list.d.ts +4 -0
  153. package/dist/tools/memory-list.js +23 -0
  154. package/dist/tools/prune.d.ts +4 -0
  155. package/dist/tools/prune.js +28 -0
  156. package/dist/tools/recall.d.ts +4 -0
  157. package/dist/tools/recall.js +24 -0
  158. package/dist/tools/remember.d.ts +10 -0
  159. package/dist/tools/remember.js +43 -0
  160. package/dist/tools/restore.d.ts +2 -0
  161. package/dist/tools/restore.js +20 -0
  162. package/dist/tools/update-identity.d.ts +34 -0
  163. package/dist/tools/update-identity.js +194 -0
  164. package/dist/tools/update.d.ts +2 -0
  165. package/dist/tools/update.js +16 -0
  166. package/dist/transport/guards.d.ts +35 -0
  167. package/dist/transport/guards.js +107 -0
  168. package/dist/transport/http-server.d.ts +17 -0
  169. package/dist/transport/http-server.js +198 -0
  170. package/dist/transport/mcp-app-hello.d.ts +3 -0
  171. package/dist/transport/mcp-app-hello.js +66 -0
  172. package/dist/widgets/display-modes.html +131 -0
  173. package/dist/widgets/hello.html +138 -0
  174. package/dist/widgets/proposals.html +131 -0
  175. package/dist/widgets/top-of-mind.html +131 -0
  176. package/package.json +73 -0
@@ -0,0 +1,737 @@
1
+ /**
2
+ * SQLite + sqlite-vec memory backend.
3
+ *
4
+ * A single file holds all memories plus their embedding vectors.
5
+ * No external service, no daemon. Portable to any machine with Node.
6
+ *
7
+ * Schema:
8
+ * memories — regular table, one row per memory (payload, TTL, etc.)
9
+ * vec_memories — sqlite-vec virtual table, rowid = memories.id
10
+ */
11
+ import { randomUUID } from 'node:crypto';
12
+ import { mkdirSync } from 'node:fs';
13
+ import { dirname } from 'node:path';
14
+ import BetterSqlite3 from 'better-sqlite3';
15
+ import * as sqliteVec from 'sqlite-vec';
16
+ import { computeExpiresAt, isExpired } from './ttl.js';
17
+ import { mmrSelect, DEFAULT_DIVERSITY } from './mmr.js';
18
+ import { globToMatcher } from './glob.js';
19
+ import { runMigrations } from './migrations.js';
20
+ import { retryWrite } from './retry.js';
21
+ function slugify(text) {
22
+ return text
23
+ .toLowerCase()
24
+ .replace(/[^a-z0-9]+/g, '-')
25
+ .replace(/^-|-$/g, '')
26
+ .slice(0, 60);
27
+ }
28
+ /** Body snapshots retained per memory; oldest pruned beyond this cap (mirrors knowledge wing). */
29
+ const MAX_REVISIONS_PER_MEMORY = 10;
30
+ export class SqliteVecBackend {
31
+ config;
32
+ embedder;
33
+ db;
34
+ constructor(config, embedder) {
35
+ this.config = config;
36
+ this.embedder = embedder;
37
+ mkdirSync(dirname(config.dbPath), { recursive: true });
38
+ this.db = new BetterSqlite3(config.dbPath);
39
+ 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');
47
+ sqliteVec.load(this.db);
48
+ this.initSchema();
49
+ }
50
+ // ── MemoryBackend interface ──
51
+ async remember(input) {
52
+ const uuid = randomUUID();
53
+ const timestamp = new Date().toISOString();
54
+ const slug = slugify(input.title);
55
+ const ref = `${input.category}/${slug}-${uuid.slice(0, 8)}`;
56
+ const expiresAt = computeExpiresAt(timestamp, input.ttl);
57
+ const vector = await this.embedder.embed(`${input.title}\n\n${input.content}`);
58
+ const insertMem = this.db.prepare(`
59
+ INSERT INTO memories (
60
+ uuid, ref, title, category, project, content, metadata,
61
+ created, ttl, expires_at, salience
62
+ ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
63
+ `);
64
+ const insertVec = this.db.prepare('INSERT INTO vec_memories(rowid, embedding) VALUES (?, ?)');
65
+ 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);
67
+ insertVec.run(BigInt(result.lastInsertRowid), toVecBuffer(vector));
68
+ });
69
+ retryWrite(() => tx());
70
+ return {
71
+ ref,
72
+ category: input.category,
73
+ filename: `${slug}-${uuid.slice(0, 8)}`,
74
+ title: input.title,
75
+ };
76
+ }
77
+ async recall(input) {
78
+ const startedAt = performance.now();
79
+ const limit = input.limit ?? 10;
80
+ const diversity = clamp01(input.diversity ?? DEFAULT_DIVERSITY);
81
+ const queryVector = await (this.embedder.embedQuery?.(input.query) ??
82
+ this.embedder.embed(input.query));
83
+ const categoryFilter = input.category && input.category !== 'all' ? input.category : null;
84
+ const projectFilter = input.project ?? null;
85
+ // With diversity on, over-fetch a candidate pool so MMR has something to
86
+ // trade off. At diversity 0 this is exactly the pre-MMR search: pool == limit.
87
+ const poolSize = diversity > 0 ? Math.max(limit * 3, 12) : limit;
88
+ const candidates = this.searchVectors(queryVector, poolSize, poolSize * 4, (mem) => {
89
+ if (categoryFilter && mem.category !== categoryFilter)
90
+ return false;
91
+ if (projectFilter && mem.project !== projectFilter)
92
+ return false;
93
+ return true;
94
+ });
95
+ // MMR only re-orders/selects among candidates that already passed the
96
+ // filters; with <= limit candidates it returns them in relevance order.
97
+ let hits = candidates;
98
+ let diversityDrops = 0;
99
+ if (diversity > 0 && candidates.length > limit) {
100
+ const vectors = this.loadVectors(candidates.map((c) => c.mem.id));
101
+ const pool = candidates.map((hit) => ({
102
+ hit,
103
+ relevance: 1 - hit.distance,
104
+ vector: vectors.get(hit.mem.id) ?? EMPTY_VECTOR,
105
+ }));
106
+ const picked = mmrSelect(pool, limit, diversity);
107
+ hits = picked.selected.map((c) => c.hit);
108
+ diversityDrops = picked.diversityDrops;
109
+ }
110
+ const results = hits.map((h) => rowToMatch(h.mem, h.distance));
111
+ const hitIds = hits.map((h) => h.mem.id);
112
+ if (hitIds.length > 0) {
113
+ const now = new Date().toISOString();
114
+ const stamp = this.db.prepare('UPDATE memories SET last_accessed = ? WHERE id = ?');
115
+ const tx = this.db.transaction((ids) => {
116
+ for (const id of ids)
117
+ stamp.run(now, id);
118
+ });
119
+ retryWrite(() => tx(hitIds));
120
+ }
121
+ this.observeRecall({
122
+ ts: new Date().toISOString(),
123
+ tool: 'recall',
124
+ query: input.query,
125
+ category: categoryFilter ?? undefined,
126
+ project: projectFilter ?? undefined,
127
+ limit,
128
+ diversity,
129
+ candidates: candidates.length,
130
+ returned: results.length,
131
+ topScore: results.length > 0 ? results[0].relevance : null,
132
+ threshold: null,
133
+ diversityDrops,
134
+ latencyMs: Math.round(performance.now() - startedAt),
135
+ hit: results.length > 0,
136
+ });
137
+ return results;
138
+ }
139
+ async forget(input) {
140
+ if (input.ref) {
141
+ const row = this.db
142
+ .prepare('SELECT id, ref FROM memories WHERE ref = ?')
143
+ .get(input.ref);
144
+ if (!row)
145
+ return { deleted: [] };
146
+ this.deleteById([row.id]);
147
+ return { deleted: [row.ref] };
148
+ }
149
+ if (input.category && input.title && !input.title_pattern) {
150
+ const rows = this.db
151
+ .prepare('SELECT id, ref FROM memories WHERE category = ? AND title = ?')
152
+ .all(input.category, input.title);
153
+ if (rows.length === 0)
154
+ return { deleted: [] };
155
+ this.deleteById(rows.map((r) => r.id));
156
+ return { deleted: rows.map((r) => r.ref) };
157
+ }
158
+ const clauses = [];
159
+ const params = [];
160
+ if (input.category) {
161
+ clauses.push('category = ?');
162
+ params.push(input.category);
163
+ }
164
+ if (input.project) {
165
+ clauses.push('project = ?');
166
+ params.push(input.project);
167
+ }
168
+ if (clauses.length === 0)
169
+ return { deleted: [] };
170
+ const all = this.db
171
+ .prepare(`SELECT id, ref, title FROM memories WHERE ${clauses.join(' AND ')}`)
172
+ .all(...params);
173
+ let targets = all;
174
+ if (input.title_pattern) {
175
+ const matcher = globToMatcher(input.title_pattern);
176
+ targets = all.filter((r) => matcher(r.title));
177
+ }
178
+ if (targets.length === 0)
179
+ return { deleted: [] };
180
+ this.deleteById(targets.map((r) => r.id));
181
+ return { deleted: targets.map((r) => r.ref) };
182
+ }
183
+ async update(input) {
184
+ let row;
185
+ if (input.ref) {
186
+ row = this.db
187
+ .prepare('SELECT * FROM memories WHERE ref = ?')
188
+ .get(input.ref);
189
+ }
190
+ else if (input.category && input.title) {
191
+ row = this.db
192
+ .prepare('SELECT * FROM memories WHERE category = ? AND title = ?')
193
+ .get(input.category, input.title);
194
+ }
195
+ else {
196
+ return { updated: false };
197
+ }
198
+ if (!row)
199
+ 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;
205
+ 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
+ let snapshotId;
217
+ if (input.content !== undefined && input.content !== row.content) {
218
+ snapshotId = this.snapshotRevision(row.id, row.ref, row.content, 'update', updatedAt);
219
+ }
220
+ const tx = this.db.transaction(() => {
221
+ updateStmt.run(newContent, JSON.stringify(newMeta), updatedAt, row.id);
222
+ updateVec.run(toVecBuffer(vector), BigInt(row.id));
223
+ });
224
+ retryWrite(() => tx());
225
+ return { updated: true, ref: row.ref, snapshotId };
226
+ }
227
+ async prune(options) {
228
+ const dryRun = options?.dryRun ?? false;
229
+ const staleDays = options?.staleDays ?? 30;
230
+ const now = new Date();
231
+ const staleThreshold = new Date(now.getTime() - staleDays * 24 * 60 * 60 * 1000);
232
+ const rows = this.db
233
+ .prepare(`SELECT id, ref, ttl, expires_at, last_accessed, updated, created
234
+ FROM memories WHERE archived = 0`)
235
+ .all();
236
+ const expired = [];
237
+ const stale = [];
238
+ const expiredIds = [];
239
+ for (const r of rows) {
240
+ if (r.expires_at && isExpired(r.expires_at, now)) {
241
+ expired.push(r.ref);
242
+ expiredIds.push(r.id);
243
+ continue;
244
+ }
245
+ if (r.ttl === 'permanent')
246
+ continue;
247
+ const lastTouch = r.last_accessed ?? r.updated ?? r.created;
248
+ if (new Date(lastTouch) < staleThreshold) {
249
+ stale.push(r.ref);
250
+ }
251
+ }
252
+ if (!dryRun && expiredIds.length > 0) {
253
+ this.deleteById(expiredIds);
254
+ }
255
+ return { expired, stale };
256
+ }
257
+ async list(input) {
258
+ const clauses = [];
259
+ const params = [];
260
+ if (input.category) {
261
+ clauses.push('category = ?');
262
+ params.push(input.category);
263
+ }
264
+ if (input.project) {
265
+ clauses.push('project = ?');
266
+ params.push(input.project);
267
+ }
268
+ clauses.push('archived = 0');
269
+ const where = `WHERE ${clauses.join(' AND ')}`;
270
+ params.push(input.limit ?? 50);
271
+ const rows = this.db
272
+ .prepare(`SELECT ref, title, category, project, created
273
+ FROM memories ${where}
274
+ ORDER BY created DESC LIMIT ?`)
275
+ .all(...params);
276
+ return rows.map((r) => ({
277
+ ref: r.ref,
278
+ title: r.title,
279
+ category: r.category,
280
+ project: r.project ?? undefined,
281
+ created: r.created,
282
+ }));
283
+ }
284
+ async findSimilar(input) {
285
+ if (!input.ref && !input.text) {
286
+ throw new Error('findSimilar requires either ref or text');
287
+ }
288
+ let anchorId = null;
289
+ let queryVector;
290
+ if (input.ref) {
291
+ const row = this.db
292
+ .prepare('SELECT id FROM memories WHERE ref = ?')
293
+ .get(input.ref);
294
+ if (!row)
295
+ throw new Error(`findSimilar: memory not found: ${input.ref}`);
296
+ anchorId = row.id;
297
+ const vecRow = this.db
298
+ .prepare('SELECT embedding FROM vec_memories WHERE rowid = ?')
299
+ .get(BigInt(row.id));
300
+ if (!vecRow)
301
+ 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));
303
+ }
304
+ else {
305
+ queryVector = await (this.embedder.embedQuery?.(input.text) ??
306
+ this.embedder.embed(input.text));
307
+ }
308
+ const limit = input.limit ?? 10;
309
+ const minRelevance = input.minRelevance ?? 0;
310
+ const categoryFilter = input.category ?? null;
311
+ const projectFilter = input.project ?? null;
312
+ // Over-fetch so filters + self-exclusion don't starve the result set.
313
+ const startK = Math.max((limit + 1) * 4, 16);
314
+ const hits = this.searchVectors(queryVector, limit, startK, (mem, vr) => {
315
+ if (anchorId !== null && vr.rowid === anchorId)
316
+ return false;
317
+ if (categoryFilter && mem.category !== categoryFilter)
318
+ return false;
319
+ if (projectFilter && (mem.project ?? null) !== projectFilter)
320
+ return false;
321
+ if (1 - vr.distance < minRelevance)
322
+ return false;
323
+ return true;
324
+ });
325
+ return hits.map((h) => rowToMatch(h.mem, h.distance));
326
+ }
327
+ async audit(options) {
328
+ const staleDays = options?.staleDays ?? 30;
329
+ const similarityThreshold = options?.similarityThreshold ?? 0.85;
330
+ const maxDuplicates = options?.maxDuplicates ?? 20;
331
+ const now = new Date();
332
+ const staleThreshold = new Date(now.getTime() - staleDays * 24 * 60 * 60 * 1000);
333
+ const rows = this.db
334
+ .prepare(`SELECT id, ref, title, category, project, ttl, expires_at,
335
+ last_accessed, updated, created
336
+ FROM memories WHERE archived = 0`)
337
+ .all();
338
+ const byCategory = {};
339
+ const stale = [];
340
+ const expired = [];
341
+ for (const r of rows) {
342
+ byCategory[r.category] = (byCategory[r.category] ?? 0) + 1;
343
+ if (r.expires_at && isExpired(r.expires_at, now)) {
344
+ expired.push(r.ref);
345
+ continue;
346
+ }
347
+ if (r.ttl === 'permanent')
348
+ continue;
349
+ const lastTouch = r.last_accessed ?? r.updated ?? r.created;
350
+ if (new Date(lastTouch) < staleThreshold) {
351
+ stale.push({
352
+ ref: r.ref,
353
+ title: r.title,
354
+ category: r.category,
355
+ project: r.project ?? undefined,
356
+ lastTouch,
357
+ });
358
+ }
359
+ }
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 = [];
363
+ const seenPair = new Set();
364
+ const pairKey = (a, b) => a < b ? `${a}:${b}` : `${b}:${a}`;
365
+ const byIdRow = new Map(rows.map((r) => [r.id, r]));
366
+ const vecStmt = this.db.prepare(`SELECT rowid, distance FROM vec_memories
367
+ WHERE embedding MATCH ? AND k = 2
368
+ ORDER BY distance`);
369
+ 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)
376
+ continue;
377
+ const hits = vecStmt.all(stored.embedding);
378
+ for (const h of hits) {
379
+ if (h.rowid === r.id)
380
+ continue;
381
+ const relevance = 1 - h.distance;
382
+ if (relevance < similarityThreshold)
383
+ break;
384
+ const key = pairKey(r.id, h.rowid);
385
+ if (seenPair.has(key))
386
+ continue;
387
+ seenPair.add(key);
388
+ const other = byIdRow.get(h.rowid);
389
+ if (!other)
390
+ continue;
391
+ duplicates.push({
392
+ a: { ref: r.ref, title: r.title },
393
+ b: { ref: other.ref, title: other.title },
394
+ relevance,
395
+ });
396
+ if (duplicates.length >= maxDuplicates)
397
+ break;
398
+ }
399
+ }
400
+ return {
401
+ totalMemories: rows.length,
402
+ byCategory,
403
+ stale,
404
+ duplicates,
405
+ expired,
406
+ };
407
+ }
408
+ async archive(input) {
409
+ let rows = [];
410
+ if (input.ref) {
411
+ const row = this.db
412
+ .prepare('SELECT id, ref FROM memories WHERE ref = ? AND archived = 0')
413
+ .get(input.ref);
414
+ if (row)
415
+ rows = [row];
416
+ }
417
+ else if (input.category && input.title) {
418
+ rows = this.db
419
+ .prepare('SELECT id, ref FROM memories WHERE category = ? AND title = ? AND archived = 0')
420
+ .all(input.category, input.title);
421
+ }
422
+ else {
423
+ return { archived: [] };
424
+ }
425
+ if (rows.length === 0)
426
+ return { archived: [] };
427
+ const now = new Date().toISOString();
428
+ const tombstone = JSON.stringify({ note: input.note ?? null, archived_at: now });
429
+ const stmt = this.db.prepare('UPDATE memories SET archived = 1, archive_note = ?, updated = ? WHERE id = ?');
430
+ // Drop the embedding so archived rows stop occupying KNN slots.
431
+ // Content stays in `memories`; restore() re-embeds it.
432
+ const delVec = this.db.prepare('DELETE FROM vec_memories WHERE rowid = ?');
433
+ const tx = this.db.transaction(() => {
434
+ for (const row of rows) {
435
+ stmt.run(tombstone, now, row.id);
436
+ delVec.run(BigInt(row.id));
437
+ }
438
+ });
439
+ retryWrite(() => tx());
440
+ return { archived: rows.map((r) => r.ref) };
441
+ }
442
+ async restore(input) {
443
+ let rows = [];
444
+ if (input.ref) {
445
+ const row = this.db
446
+ .prepare('SELECT id, ref, title, content FROM memories WHERE ref = ? AND archived = 1')
447
+ .get(input.ref);
448
+ if (row)
449
+ rows = [row];
450
+ }
451
+ else if (input.category && input.title) {
452
+ rows = this.db
453
+ .prepare('SELECT id, ref, title, content FROM memories WHERE category = ? AND title = ? AND archived = 1')
454
+ .all(input.category, input.title);
455
+ }
456
+ else {
457
+ return { restored: [] };
458
+ }
459
+ if (rows.length === 0)
460
+ return { restored: [] };
461
+ // Archive dropped the embedding; rebuild it from the stored content.
462
+ const vectors = await this.embedder.embedBatch(rows.map((r) => `${r.title}\n\n${r.content}`));
463
+ const now = new Date().toISOString();
464
+ const stmt = this.db.prepare('UPDATE memories SET archived = 0, archive_note = NULL, updated = ? WHERE id = ?');
465
+ // Delete-then-insert: legacy rows archived before delete-on-archive
466
+ // may still hold a vector, and vec0 rowids must stay unique.
467
+ const delVec = this.db.prepare('DELETE FROM vec_memories WHERE rowid = ?');
468
+ const insVec = this.db.prepare('INSERT INTO vec_memories(rowid, embedding) VALUES (?, ?)');
469
+ const tx = this.db.transaction(() => {
470
+ rows.forEach((row, i) => {
471
+ stmt.run(now, row.id);
472
+ delVec.run(BigInt(row.id));
473
+ insVec.run(BigInt(row.id), toVecBuffer(vectors[i]));
474
+ });
475
+ });
476
+ retryWrite(() => tx());
477
+ return { restored: rows.map((r) => r.ref) };
478
+ }
479
+ listRevisions(ref) {
480
+ const row = this.db
481
+ .prepare('SELECT id FROM memories WHERE ref = ?')
482
+ .get(ref);
483
+ if (!row)
484
+ return Promise.resolve([]);
485
+ const rows = this.db
486
+ .prepare(`SELECT id, memory_id, ref, op, replaced_at, LENGTH(content) AS content_length
487
+ FROM memory_revisions WHERE memory_id = ? ORDER BY id DESC`)
488
+ .all(row.id);
489
+ return Promise.resolve(rows.map((r) => ({
490
+ id: r.id,
491
+ memory_id: r.memory_id,
492
+ ref: r.ref,
493
+ op: r.op,
494
+ replaced_at: r.replaced_at,
495
+ content_length: r.content_length,
496
+ })));
497
+ }
498
+ getRevision(revisionId) {
499
+ const row = this.db
500
+ .prepare('SELECT id, memory_id, ref, op, replaced_at, content FROM memory_revisions WHERE id = ?')
501
+ .get(revisionId);
502
+ return Promise.resolve(row ?? null);
503
+ }
504
+ async restoreRevision(input) {
505
+ const mem = this.db
506
+ .prepare('SELECT * FROM memories WHERE ref = ?')
507
+ .get(input.ref);
508
+ if (!mem)
509
+ return { ref: input.ref, revision_id: input.revision_id, restored: false, snapshot_id: 0 };
510
+ const rev = this.db
511
+ .prepare('SELECT id, memory_id, content FROM memory_revisions WHERE id = ?')
512
+ .get(input.revision_id);
513
+ if (!rev || rev.memory_id !== mem.id) {
514
+ return { ref: input.ref, revision_id: input.revision_id, restored: false, snapshot_id: 0 };
515
+ }
516
+ const timestamp = new Date().toISOString();
517
+ // Snapshot the current body before replacing it.
518
+ const snapshotId = this.snapshotRevision(mem.id, mem.ref, mem.content, 'revision-restore', timestamp);
519
+ 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 = ?');
521
+ const updateVec = this.db.prepare('UPDATE vec_memories SET embedding = ? WHERE rowid = ?');
522
+ const tx = this.db.transaction(() => {
523
+ updateStmt.run(rev.content, timestamp, mem.id);
524
+ updateVec.run(toVecBuffer(vector), BigInt(mem.id));
525
+ });
526
+ retryWrite(() => tx());
527
+ return { ref: input.ref, revision_id: input.revision_id, restored: true, snapshot_id: snapshotId };
528
+ }
529
+ supersede(input) {
530
+ const oldRow = this.db
531
+ .prepare('SELECT id, archived FROM memories WHERE ref = ?')
532
+ .get(input.old_ref);
533
+ if (!oldRow) {
534
+ return Promise.reject(new Error(`supersede: old_ref not found: ${input.old_ref}`));
535
+ }
536
+ const newRow = this.db
537
+ .prepare('SELECT id FROM memories WHERE ref = ?')
538
+ .get(input.new_ref);
539
+ if (!newRow) {
540
+ return Promise.reject(new Error(`supersede: new_ref not found: ${input.new_ref}`));
541
+ }
542
+ if (oldRow.archived === 1) {
543
+ // Already retired — record the supersession edge but don't re-archive.
544
+ const timestamp = new Date().toISOString();
545
+ retryWrite(() => this.db
546
+ .prepare('INSERT INTO memory_supersessions (old_ref, new_ref, note, created) VALUES (?, ?, ?, ?)')
547
+ .run(input.old_ref, input.new_ref, input.note ?? null, timestamp));
548
+ return Promise.resolve({ old_ref: input.old_ref, new_ref: input.new_ref, archived: false });
549
+ }
550
+ const timestamp = new Date().toISOString();
551
+ const tombstone = JSON.stringify({
552
+ note: `Superseded by ${input.new_ref}.${input.note ? ' ' + input.note : ''}`,
553
+ archived_at: timestamp,
554
+ });
555
+ const archiveStmt = this.db.prepare('UPDATE memories SET archived = 1, archive_note = ?, updated = ? WHERE id = ?');
556
+ const delVec = this.db.prepare('DELETE FROM vec_memories WHERE rowid = ?');
557
+ const insertSup = this.db.prepare('INSERT INTO memory_supersessions (old_ref, new_ref, note, created) VALUES (?, ?, ?, ?)');
558
+ const tx = this.db.transaction(() => {
559
+ archiveStmt.run(tombstone, timestamp, oldRow.id);
560
+ delVec.run(BigInt(oldRow.id));
561
+ insertSup.run(input.old_ref, input.new_ref, input.note ?? null, timestamp);
562
+ });
563
+ retryWrite(() => tx());
564
+ return Promise.resolve({ old_ref: input.old_ref, new_ref: input.new_ref, archived: true });
565
+ }
566
+ close() {
567
+ this.db.close();
568
+ }
569
+ getDatabase() {
570
+ return this.db;
571
+ }
572
+ // ── Internals ──
573
+ /**
574
+ * Insert a content snapshot into memory_revisions and prune beyond the per-memory
575
+ * cap. Mirrors the knowledge wing's snapshotRevision() in sqlite-knowledge.ts.
576
+ */
577
+ snapshotRevision(memoryId, ref, content, op, timestamp) {
578
+ const result = retryWrite(() => this.db
579
+ .prepare('INSERT INTO memory_revisions (memory_id, ref, content, op, replaced_at) VALUES (?, ?, ?, ?, ?)')
580
+ .run(memoryId, ref, content, op, timestamp));
581
+ retryWrite(() => this.db
582
+ .prepare(`DELETE FROM memory_revisions WHERE memory_id = ? AND id NOT IN (
583
+ SELECT id FROM memory_revisions WHERE memory_id = ? ORDER BY id DESC LIMIT ?
584
+ )`)
585
+ .run(memoryId, memoryId, MAX_REVISIONS_PER_MEMORY));
586
+ return Number(result.lastInsertRowid);
587
+ }
588
+ /**
589
+ * KNN search with a growing-k loop.
590
+ *
591
+ * sqlite-vec's KNN returns the k nearest rows regardless of archived
592
+ * status or category/project filters (those live in `memories`, not in
593
+ * the vec table). A single over-fetch can therefore starve the result
594
+ * set when many near neighbours are filtered out — e.g. legacy archived
595
+ * rows whose vectors predate delete-on-archive, or a narrow category.
596
+ *
597
+ * Policy: start at `startK`, multiply by 4 each round, stop when
598
+ * `limit` accepted matches are collected, k has covered every vec row,
599
+ * or a round surfaces no new candidates. Candidates are deduped across
600
+ * rounds; because each round's KNN is distance-ordered and new
601
+ * candidates always rank deeper than already-seen ones, appending
602
+ * preserves global distance order.
603
+ */
604
+ searchVectors(queryVector, limit, startK, accept) {
605
+ const totalVecRows = this.db.prepare('SELECT COUNT(*) AS c FROM vec_memories').get().c;
606
+ if (totalVecRows === 0)
607
+ return [];
608
+ const knnStmt = this.db.prepare(`SELECT rowid, distance FROM vec_memories
609
+ WHERE embedding MATCH ? AND k = ?
610
+ ORDER BY distance`);
611
+ const queryBuf = toVecBuffer(queryVector);
612
+ const out = [];
613
+ const seen = new Set();
614
+ let k = Math.max(1, startK);
615
+ for (;;) {
616
+ const vecRows = knnStmt.all(queryBuf, k);
617
+ const fresh = vecRows.filter((vr) => !seen.has(vr.rowid));
618
+ if (fresh.length === 0)
619
+ break;
620
+ for (const vr of fresh)
621
+ seen.add(vr.rowid);
622
+ const placeholders = fresh.map(() => '?').join(',');
623
+ const memRows = this.db
624
+ .prepare(`SELECT * FROM memories WHERE id IN (${placeholders}) AND archived = 0`)
625
+ .all(...fresh.map((vr) => vr.rowid));
626
+ const byId = new Map(memRows.map((r) => [r.id, r]));
627
+ for (const vr of fresh) {
628
+ const mem = byId.get(vr.rowid);
629
+ if (!mem)
630
+ continue;
631
+ if (!accept(mem, vr))
632
+ continue;
633
+ out.push({ mem, distance: vr.distance });
634
+ if (out.length >= limit)
635
+ break;
636
+ }
637
+ if (out.length >= limit)
638
+ break;
639
+ if (k >= totalVecRows)
640
+ break;
641
+ k = Math.min(k * 4, totalVecRows);
642
+ }
643
+ return out;
644
+ }
645
+ observeRecall(obs) {
646
+ if (!this.config.onRecall)
647
+ return;
648
+ try {
649
+ this.config.onRecall(obs);
650
+ }
651
+ catch {
652
+ // Telemetry never fails a search.
653
+ }
654
+ }
655
+ /** Stored embeddings for a set of memory ids (point lookups; pools are small). */
656
+ loadVectors(ids) {
657
+ const stmt = this.db.prepare('SELECT embedding FROM vec_memories WHERE rowid = ?');
658
+ const out = new Map();
659
+ for (const id of ids) {
660
+ const row = stmt.get(BigInt(id));
661
+ if (!row)
662
+ continue;
663
+ out.set(id, new Float32Array(row.embedding.buffer, row.embedding.byteOffset, row.embedding.byteLength / 4));
664
+ }
665
+ return out;
666
+ }
667
+ initSchema() {
668
+ const statements = [
669
+ `CREATE TABLE IF NOT EXISTS memories (
670
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
671
+ uuid TEXT NOT NULL UNIQUE,
672
+ ref TEXT NOT NULL UNIQUE,
673
+ title TEXT NOT NULL,
674
+ category TEXT NOT NULL,
675
+ project TEXT,
676
+ content TEXT NOT NULL,
677
+ metadata TEXT NOT NULL DEFAULT '{}',
678
+ created TEXT NOT NULL,
679
+ updated TEXT,
680
+ last_accessed TEXT,
681
+ ttl TEXT,
682
+ expires_at TEXT,
683
+ archived INTEGER NOT NULL DEFAULT 0,
684
+ archive_note TEXT
685
+ )`,
686
+ `CREATE INDEX IF NOT EXISTS idx_memories_category ON memories(category)`,
687
+ `CREATE INDEX IF NOT EXISTS idx_memories_project ON memories(project)`,
688
+ `CREATE INDEX IF NOT EXISTS idx_memories_ref ON memories(ref)`,
689
+ `CREATE VIRTUAL TABLE IF NOT EXISTS vec_memories USING vec0(
690
+ embedding float[${this.embedder.dimensions}] distance_metric=cosine
691
+ )`,
692
+ ];
693
+ for (const sql of statements) {
694
+ this.db.prepare(sql).run();
695
+ }
696
+ // Apply pending schema migrations. Throws on failure so a bad migration
697
+ // aborts startup loudly rather than leaving memory half-broken.
698
+ runMigrations(this.db, { strict: true });
699
+ }
700
+ deleteById(ids) {
701
+ if (ids.length === 0)
702
+ return;
703
+ const placeholders = ids.map(() => '?').join(',');
704
+ const delMem = this.db.prepare(`DELETE FROM memories WHERE id IN (${placeholders})`);
705
+ const delVec = this.db.prepare(`DELETE FROM vec_memories WHERE rowid IN (${placeholders})`);
706
+ const bigIds = ids.map((n) => BigInt(n));
707
+ const tx = this.db.transaction(() => {
708
+ delMem.run(...ids);
709
+ delVec.run(...bigIds);
710
+ });
711
+ retryWrite(() => tx());
712
+ }
713
+ }
714
+ // ── Helpers ──
715
+ const EMPTY_VECTOR = new Float32Array(0);
716
+ function clamp01(x) {
717
+ if (!Number.isFinite(x))
718
+ return DEFAULT_DIVERSITY;
719
+ return Math.min(1, Math.max(0, x));
720
+ }
721
+ function toVecBuffer(vector) {
722
+ return Buffer.from(new Float32Array(vector).buffer);
723
+ }
724
+ function rowToMatch(row, distance) {
725
+ return {
726
+ path: row.ref,
727
+ title: row.title,
728
+ category: row.category,
729
+ project: row.project ?? undefined,
730
+ created: row.created,
731
+ content: row.content,
732
+ relevance: 1 - distance,
733
+ lastAccessed: row.last_accessed ?? undefined,
734
+ ttl: row.ttl ?? undefined,
735
+ expiresAt: row.expires_at ?? undefined,
736
+ };
737
+ }