@hyperlynq/synaptic 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 (98) hide show
  1. package/LICENSE +19 -0
  2. package/README.md +427 -0
  3. package/build/scripts/rebuild-index.d.ts +5 -0
  4. package/build/scripts/rebuild-index.js +33 -0
  5. package/build/src/cli/init.d.ts +13 -0
  6. package/build/src/cli/init.js +222 -0
  7. package/build/src/cli/init.js.map +1 -0
  8. package/build/src/cli/pre-commit.d.ts +6 -0
  9. package/build/src/cli/pre-commit.js +159 -0
  10. package/build/src/cli/pre-commit.js.map +1 -0
  11. package/build/src/cli.d.ts +2 -0
  12. package/build/src/cli.js +36 -0
  13. package/build/src/cli.js.map +1 -0
  14. package/build/src/hooks/pre-compact.d.ts +6 -0
  15. package/build/src/hooks/pre-compact.js +64 -0
  16. package/build/src/hooks/pre-compact.js.map +1 -0
  17. package/build/src/hooks/session-start.d.ts +13 -0
  18. package/build/src/hooks/session-start.js +277 -0
  19. package/build/src/hooks/session-start.js.map +1 -0
  20. package/build/src/hooks/stop.d.ts +7 -0
  21. package/build/src/hooks/stop.js +248 -0
  22. package/build/src/hooks/stop.js.map +1 -0
  23. package/build/src/index.d.ts +1 -0
  24. package/build/src/index.js +8 -0
  25. package/build/src/index.js.map +1 -0
  26. package/build/src/server.d.ts +6 -0
  27. package/build/src/server.js +133 -0
  28. package/build/src/server.js.map +1 -0
  29. package/build/src/storage/embedder.d.ts +27 -0
  30. package/build/src/storage/embedder.js +126 -0
  31. package/build/src/storage/embedder.js.map +1 -0
  32. package/build/src/storage/git.d.ts +20 -0
  33. package/build/src/storage/git.js +98 -0
  34. package/build/src/storage/git.js.map +1 -0
  35. package/build/src/storage/maintenance.d.ts +9 -0
  36. package/build/src/storage/maintenance.js +46 -0
  37. package/build/src/storage/maintenance.js.map +1 -0
  38. package/build/src/storage/markdown.d.ts +21 -0
  39. package/build/src/storage/markdown.js +79 -0
  40. package/build/src/storage/markdown.js.map +1 -0
  41. package/build/src/storage/paths.d.ts +6 -0
  42. package/build/src/storage/paths.js +17 -0
  43. package/build/src/storage/paths.js.map +1 -0
  44. package/build/src/storage/project.d.ts +2 -0
  45. package/build/src/storage/project.js +35 -0
  46. package/build/src/storage/project.js.map +1 -0
  47. package/build/src/storage/session.d.ts +1 -0
  48. package/build/src/storage/session.js +17 -0
  49. package/build/src/storage/session.js.map +1 -0
  50. package/build/src/storage/sqlite.d.ts +102 -0
  51. package/build/src/storage/sqlite.js +830 -0
  52. package/build/src/storage/sqlite.js.map +1 -0
  53. package/build/src/storage/watcher.d.ts +22 -0
  54. package/build/src/storage/watcher.js +126 -0
  55. package/build/src/storage/watcher.js.map +1 -0
  56. package/build/src/tools/context-archive.d.ts +11 -0
  57. package/build/src/tools/context-archive.js +13 -0
  58. package/build/src/tools/context-archive.js.map +1 -0
  59. package/build/src/tools/context-chain.d.ts +12 -0
  60. package/build/src/tools/context-chain.js +26 -0
  61. package/build/src/tools/context-chain.js.map +1 -0
  62. package/build/src/tools/context-cochanges.d.ts +20 -0
  63. package/build/src/tools/context-cochanges.js +25 -0
  64. package/build/src/tools/context-cochanges.js.map +1 -0
  65. package/build/src/tools/context-delete-rule.d.ts +11 -0
  66. package/build/src/tools/context-delete-rule.js +12 -0
  67. package/build/src/tools/context-delete-rule.js.map +1 -0
  68. package/build/src/tools/context-dna.d.ts +18 -0
  69. package/build/src/tools/context-dna.js +197 -0
  70. package/build/src/tools/context-dna.js.map +1 -0
  71. package/build/src/tools/context-git-index.d.ts +17 -0
  72. package/build/src/tools/context-git-index.js +59 -0
  73. package/build/src/tools/context-git-index.js.map +1 -0
  74. package/build/src/tools/context-list-rules.d.ts +8 -0
  75. package/build/src/tools/context-list-rules.js +11 -0
  76. package/build/src/tools/context-list-rules.js.map +1 -0
  77. package/build/src/tools/context-list.d.ts +26 -0
  78. package/build/src/tools/context-list.js +42 -0
  79. package/build/src/tools/context-list.js.map +1 -0
  80. package/build/src/tools/context-resolve-pattern.d.ts +11 -0
  81. package/build/src/tools/context-resolve-pattern.js +9 -0
  82. package/build/src/tools/context-resolve-pattern.js.map +1 -0
  83. package/build/src/tools/context-save-rule.d.ts +14 -0
  84. package/build/src/tools/context-save-rule.js +15 -0
  85. package/build/src/tools/context-save-rule.js.map +1 -0
  86. package/build/src/tools/context-save.d.ts +26 -0
  87. package/build/src/tools/context-save.js +68 -0
  88. package/build/src/tools/context-save.js.map +1 -0
  89. package/build/src/tools/context-search.d.ts +31 -0
  90. package/build/src/tools/context-search.js +99 -0
  91. package/build/src/tools/context-search.js.map +1 -0
  92. package/build/src/tools/context-session.d.ts +13 -0
  93. package/build/src/tools/context-session.js +29 -0
  94. package/build/src/tools/context-session.js.map +1 -0
  95. package/build/src/tools/context-status.d.ts +13 -0
  96. package/build/src/tools/context-status.js +15 -0
  97. package/build/src/tools/context-status.js.map +1 -0
  98. package/package.json +57 -0
@@ -0,0 +1,830 @@
1
+ import { DatabaseSync } from "node:sqlite";
2
+ import * as sqliteVec from "sqlite-vec";
3
+ import { DB_PATH, ensureDirs } from "./paths.js";
4
+ function cosineSimilarity(a, b) {
5
+ let dot = 0, normA = 0, normB = 0;
6
+ for (let i = 0; i < a.length; i++) {
7
+ dot += a[i] * b[i];
8
+ normA += a[i] * a[i];
9
+ normB += b[i] * b[i];
10
+ }
11
+ return dot / (Math.sqrt(normA) * Math.sqrt(normB));
12
+ }
13
+ export class ContextIndex {
14
+ db;
15
+ static assignTier(type, explicitTier) {
16
+ if (explicitTier)
17
+ return explicitTier;
18
+ switch (type) {
19
+ case "handoff":
20
+ case "progress":
21
+ return "ephemeral";
22
+ case "reference":
23
+ return "longterm";
24
+ default:
25
+ return "working";
26
+ }
27
+ }
28
+ constructor(dbPath = DB_PATH) {
29
+ ensureDirs();
30
+ this.db = new DatabaseSync(dbPath, { allowExtension: true });
31
+ sqliteVec.load(this.db);
32
+ this.init();
33
+ }
34
+ init() {
35
+ this.db.exec("PRAGMA journal_mode=WAL");
36
+ this.db.exec("PRAGMA busy_timeout=5000");
37
+ this.db.exec(`
38
+ CREATE TABLE IF NOT EXISTS entries (
39
+ id TEXT PRIMARY KEY,
40
+ date TEXT NOT NULL,
41
+ time TEXT NOT NULL,
42
+ type TEXT NOT NULL,
43
+ tags TEXT NOT NULL,
44
+ content TEXT NOT NULL,
45
+ source_file TEXT NOT NULL
46
+ )
47
+ `);
48
+ this.db.exec(`
49
+ CREATE VIRTUAL TABLE IF NOT EXISTS entries_fts USING fts5(
50
+ content,
51
+ tags,
52
+ type,
53
+ content_rowid='rowid',
54
+ tokenize='porter unicode61'
55
+ )
56
+ `);
57
+ // Triggers to keep FTS in sync
58
+ this.db.exec(`
59
+ CREATE TRIGGER IF NOT EXISTS entries_ai AFTER INSERT ON entries BEGIN
60
+ INSERT INTO entries_fts(rowid, content, tags, type)
61
+ VALUES (new.rowid, new.content, new.tags, new.type);
62
+ END
63
+ `);
64
+ this.db.exec(`
65
+ CREATE TRIGGER IF NOT EXISTS entries_ad AFTER DELETE ON entries BEGIN
66
+ INSERT INTO entries_fts(entries_fts, rowid, content, tags, type)
67
+ VALUES ('delete', old.rowid, old.content, old.tags, old.type);
68
+ END
69
+ `);
70
+ this.db.exec(`
71
+ CREATE INDEX IF NOT EXISTS idx_entries_date ON entries(date)
72
+ `);
73
+ this.db.exec(`
74
+ CREATE INDEX IF NOT EXISTS idx_entries_type ON entries(type)
75
+ `);
76
+ this.db.exec(`
77
+ CREATE VIRTUAL TABLE IF NOT EXISTS vec_entries USING vec0(
78
+ embedding FLOAT[384]
79
+ )
80
+ `);
81
+ this.migrate();
82
+ }
83
+ migrate() {
84
+ // Check if tier column already exists
85
+ const columns = this.db.prepare("PRAGMA table_info(entries)").all();
86
+ const hasTier = columns.some((col) => col.name === "tier");
87
+ if (!hasTier) {
88
+ this.db.exec("ALTER TABLE entries ADD COLUMN tier TEXT DEFAULT 'working'");
89
+ this.db.exec("ALTER TABLE entries ADD COLUMN access_count INTEGER DEFAULT 0");
90
+ this.db.exec("ALTER TABLE entries ADD COLUMN last_accessed TEXT");
91
+ this.db.exec("ALTER TABLE entries ADD COLUMN pinned INTEGER DEFAULT 0");
92
+ this.db.exec("ALTER TABLE entries ADD COLUMN archived INTEGER DEFAULT 0");
93
+ // Backfill tiers based on entry type
94
+ this.db.exec("UPDATE entries SET tier = 'ephemeral' WHERE type IN ('handoff', 'progress')");
95
+ this.db.exec("UPDATE entries SET tier = 'longterm' WHERE type = 'reference'");
96
+ }
97
+ // Create patterns table for Phase 3c
98
+ this.db.exec(`
99
+ CREATE TABLE IF NOT EXISTS patterns (
100
+ id TEXT PRIMARY KEY,
101
+ label TEXT NOT NULL,
102
+ entry_ids TEXT NOT NULL,
103
+ occurrence_count INTEGER NOT NULL DEFAULT 0,
104
+ first_seen TEXT NOT NULL,
105
+ last_seen TEXT NOT NULL,
106
+ resolved INTEGER DEFAULT 0
107
+ )
108
+ `);
109
+ // Create indexes for new columns
110
+ this.db.exec("CREATE INDEX IF NOT EXISTS idx_entries_tier ON entries(tier)");
111
+ this.db.exec("CREATE INDEX IF NOT EXISTS idx_entries_archived ON entries(archived)");
112
+ const hasLabel = columns.some((col) => col.name === "label");
113
+ if (!hasLabel) {
114
+ this.db.exec("ALTER TABLE entries ADD COLUMN label TEXT");
115
+ this.db.exec("CREATE UNIQUE INDEX IF NOT EXISTS idx_entries_rule_label ON entries(label) WHERE type = 'rule'");
116
+ }
117
+ // v0.5.0 migration: project, session_id, agent_id columns
118
+ const hasProject = columns.some((col) => col.name === "project");
119
+ if (!hasProject) {
120
+ this.db.exec("ALTER TABLE entries ADD COLUMN project TEXT DEFAULT NULL");
121
+ this.db.exec("ALTER TABLE entries ADD COLUMN session_id TEXT DEFAULT NULL");
122
+ this.db.exec("ALTER TABLE entries ADD COLUMN agent_id TEXT DEFAULT NULL");
123
+ this.db.exec("CREATE INDEX IF NOT EXISTS idx_entries_project ON entries(project)");
124
+ this.db.exec("CREATE INDEX IF NOT EXISTS idx_entries_session ON entries(session_id)");
125
+ }
126
+ // v0.5.0: file_pairs table for co-change tracking
127
+ this.db.exec(`
128
+ CREATE TABLE IF NOT EXISTS file_pairs (
129
+ project TEXT NOT NULL,
130
+ file_a TEXT NOT NULL,
131
+ file_b TEXT NOT NULL,
132
+ co_change_count INTEGER DEFAULT 1,
133
+ last_seen TEXT NOT NULL,
134
+ PRIMARY KEY (project, file_a, file_b)
135
+ )
136
+ `);
137
+ this.db.exec("CREATE INDEX IF NOT EXISTS idx_file_pairs_lookup ON file_pairs(project, file_a)");
138
+ }
139
+ insert(entry) {
140
+ const stmt = this.db.prepare(`
141
+ INSERT OR REPLACE INTO entries (id, date, time, type, tags, content, source_file, tier, access_count, last_accessed, pinned, archived, label, project, session_id, agent_id)
142
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
143
+ `);
144
+ stmt.run(entry.id, entry.date, entry.time, entry.type, entry.tags.join(", "), entry.content, entry.sourceFile, entry.tier ?? "working", entry.accessCount ?? 0, entry.lastAccessed ?? null, entry.pinned ? 1 : 0, entry.archived ? 1 : 0, entry.label ?? null, entry.project ?? null, entry.sessionId ?? null, entry.agentId ?? null);
145
+ const row = this.db.prepare("SELECT last_insert_rowid() as rowid").get();
146
+ return row.rowid;
147
+ }
148
+ insertVec(entryRowid, embedding) {
149
+ const stmt = this.db.prepare(`
150
+ INSERT INTO vec_entries(rowid, embedding)
151
+ VALUES (CAST(? AS INTEGER), ?)
152
+ `);
153
+ stmt.run(entryRowid, new Uint8Array(embedding.buffer));
154
+ }
155
+ search(query, opts = {}) {
156
+ const limit = opts.limit ?? 20;
157
+ const conditions = [];
158
+ const params = [];
159
+ conditions.push("entries_fts MATCH ?");
160
+ params.push(query);
161
+ if (opts.type) {
162
+ conditions.push("e.type = ?");
163
+ params.push(opts.type);
164
+ }
165
+ if (opts.days) {
166
+ conditions.push("e.date >= date('now', '-' || ? || ' days')");
167
+ params.push(opts.days);
168
+ }
169
+ if (!opts.includeArchived) {
170
+ conditions.push("e.archived = 0");
171
+ }
172
+ params.push(limit);
173
+ const sql = `
174
+ SELECT e.id, e.date, e.time, e.type, e.tags, e.content, e.source_file,
175
+ e.tier, e.access_count, e.last_accessed, e.pinned, e.archived,
176
+ e.project, e.session_id, e.agent_id,
177
+ rank
178
+ FROM entries_fts
179
+ JOIN entries e ON entries_fts.rowid = e.rowid
180
+ WHERE ${conditions.join(" AND ")}
181
+ ORDER BY rank
182
+ LIMIT ?
183
+ `;
184
+ const stmt = this.db.prepare(sql);
185
+ const rows = stmt.all(...params);
186
+ return rows.map((row) => ({
187
+ id: row.id,
188
+ date: row.date,
189
+ time: row.time,
190
+ type: row.type,
191
+ tags: row.tags.split(", ").filter(Boolean),
192
+ content: row.content,
193
+ sourceFile: row.source_file,
194
+ tier: row.tier,
195
+ accessCount: row.access_count,
196
+ lastAccessed: row.last_accessed,
197
+ pinned: !!row.pinned,
198
+ archived: !!row.archived,
199
+ project: row.project,
200
+ sessionId: row.session_id,
201
+ agentId: row.agent_id,
202
+ }));
203
+ }
204
+ searchVec(embedding, limit) {
205
+ const stmt = this.db.prepare(`
206
+ SELECT rowid, distance
207
+ FROM vec_entries
208
+ WHERE embedding MATCH ?
209
+ ORDER BY distance
210
+ LIMIT ?
211
+ `);
212
+ const rows = stmt.all(new Uint8Array(embedding.buffer), limit);
213
+ return rows.map((r) => ({
214
+ rowid: r.rowid,
215
+ distance: r.distance,
216
+ }));
217
+ }
218
+ getByRowids(rowids) {
219
+ if (rowids.length === 0)
220
+ return [];
221
+ const placeholders = rowids.map(() => "?").join(", ");
222
+ const stmt = this.db.prepare(`
223
+ SELECT rowid, id, date, time, type, tags, content, source_file, tier, access_count, last_accessed, pinned, archived, project, session_id, agent_id
224
+ FROM entries
225
+ WHERE rowid IN (${placeholders})
226
+ `);
227
+ const rows = stmt.all(...rowids);
228
+ return rows.map((row) => ({
229
+ id: row.id,
230
+ date: row.date,
231
+ time: row.time,
232
+ type: row.type,
233
+ tags: row.tags.split(", ").filter(Boolean),
234
+ content: row.content,
235
+ sourceFile: row.source_file,
236
+ tier: row.tier,
237
+ accessCount: row.access_count,
238
+ lastAccessed: row.last_accessed,
239
+ pinned: !!row.pinned,
240
+ archived: !!row.archived,
241
+ project: row.project,
242
+ sessionId: row.session_id,
243
+ agentId: row.agent_id,
244
+ }));
245
+ }
246
+ list(opts = {}) {
247
+ const conditions = [];
248
+ const params = [];
249
+ if (opts.type) {
250
+ conditions.push("type = ?");
251
+ params.push(opts.type);
252
+ }
253
+ if (opts.days) {
254
+ conditions.push("date >= date('now', '-' || ? || ' days')");
255
+ params.push(opts.days);
256
+ }
257
+ if (!opts.includeArchived) {
258
+ conditions.push("archived = 0");
259
+ }
260
+ const where = conditions.length ? `WHERE ${conditions.join(" AND ")}` : "";
261
+ const sql = `
262
+ SELECT id, date, time, type, tags, content, source_file, tier, access_count, last_accessed, pinned, archived, project, session_id, agent_id
263
+ FROM entries
264
+ ${where}
265
+ ORDER BY date DESC, time DESC
266
+ `;
267
+ const stmt = this.db.prepare(sql);
268
+ const rows = stmt.all(...params);
269
+ return rows.map((row) => ({
270
+ id: row.id,
271
+ date: row.date,
272
+ time: row.time,
273
+ type: row.type,
274
+ tags: row.tags.split(", ").filter(Boolean),
275
+ content: row.content,
276
+ sourceFile: row.source_file,
277
+ tier: row.tier,
278
+ accessCount: row.access_count,
279
+ lastAccessed: row.last_accessed,
280
+ pinned: !!row.pinned,
281
+ archived: !!row.archived,
282
+ project: row.project,
283
+ sessionId: row.session_id,
284
+ agentId: row.agent_id,
285
+ }));
286
+ }
287
+ status() {
288
+ const countRow = this.db.prepare("SELECT COUNT(*) as count FROM entries").get();
289
+ const total = countRow.count;
290
+ let dateRange = null;
291
+ if (total > 0) {
292
+ const rangeRow = this.db.prepare("SELECT MIN(date) as earliest, MAX(date) as latest FROM entries").get();
293
+ dateRange = {
294
+ earliest: rangeRow.earliest,
295
+ latest: rangeRow.latest,
296
+ };
297
+ }
298
+ let dbSizeBytes = 0;
299
+ try {
300
+ const sizeRow = this.db.prepare("SELECT page_count * page_size as size FROM pragma_page_count(), pragma_page_size()").get();
301
+ dbSizeBytes = sizeRow.size;
302
+ }
303
+ catch {
304
+ // Ignore if pragma fails
305
+ }
306
+ // Tier distribution (non-archived only)
307
+ const tierRows = this.db.prepare("SELECT tier, COUNT(*) as count FROM entries WHERE archived = 0 GROUP BY tier").all();
308
+ const tierDistribution = {};
309
+ for (const row of tierRows) {
310
+ tierDistribution[row.tier] = row.count;
311
+ }
312
+ // Archived count
313
+ const archivedRow = this.db.prepare("SELECT COUNT(*) as count FROM entries WHERE archived = 1").get();
314
+ // Active patterns count
315
+ const patternRow = this.db.prepare("SELECT COUNT(*) as count FROM patterns WHERE resolved = 0").get();
316
+ return {
317
+ totalEntries: total,
318
+ dateRange,
319
+ dbSizeBytes,
320
+ tierDistribution,
321
+ archivedCount: archivedRow.count,
322
+ activePatterns: patternRow.count,
323
+ };
324
+ }
325
+ clearAll() {
326
+ this.db.exec("DROP TRIGGER IF EXISTS entries_ai");
327
+ this.db.exec("DROP TRIGGER IF EXISTS entries_ad");
328
+ this.db.exec("DELETE FROM entries_fts");
329
+ this.db.exec("DELETE FROM entries");
330
+ this.db.exec("DELETE FROM vec_entries");
331
+ this.db.exec("DELETE FROM patterns");
332
+ this.db.exec("DELETE FROM file_pairs");
333
+ // Recreate triggers
334
+ this.db.exec(`
335
+ CREATE TRIGGER entries_ai AFTER INSERT ON entries BEGIN
336
+ INSERT INTO entries_fts(rowid, content, tags, type)
337
+ VALUES (new.rowid, new.content, new.tags, new.type);
338
+ END
339
+ `);
340
+ this.db.exec(`
341
+ CREATE TRIGGER entries_ad AFTER DELETE ON entries BEGIN
342
+ INSERT INTO entries_fts(entries_fts, rowid, content, tags, type)
343
+ VALUES ('delete', old.rowid, old.content, old.tags, old.type);
344
+ END
345
+ `);
346
+ }
347
+ getRowidsByIds(ids) {
348
+ if (ids.length === 0)
349
+ return [];
350
+ return ids.map((id) => {
351
+ const row = this.db.prepare("SELECT rowid FROM entries WHERE id = ?").get(id);
352
+ return row?.rowid ?? -1;
353
+ });
354
+ }
355
+ archiveEntries(ids) {
356
+ if (ids.length === 0)
357
+ return 0;
358
+ const placeholders = ids.map(() => "?").join(", ");
359
+ const stmt = this.db.prepare(`UPDATE entries SET archived = 1 WHERE id IN (${placeholders}) AND pinned = 0`);
360
+ const result = stmt.run(...ids);
361
+ return Number(result.changes);
362
+ }
363
+ bumpAccess(ids) {
364
+ if (ids.length === 0)
365
+ return;
366
+ const now = new Date().toISOString().slice(0, 10);
367
+ const placeholders = ids.map(() => "?").join(", ");
368
+ this.db.prepare(`UPDATE entries SET access_count = access_count + 1, last_accessed = ? WHERE id IN (${placeholders})`).run(now, ...ids);
369
+ }
370
+ /** Increment access_count and set last_accessed for a given entry ID */
371
+ touchEntry(id) {
372
+ const today = new Date().toISOString().slice(0, 10);
373
+ const result = this.db.prepare("UPDATE entries SET access_count = access_count + 1, last_accessed = ? WHERE id = ? AND archived = 0").run(today, id);
374
+ return result.changes > 0;
375
+ }
376
+ hybridSearch(query, embedding, opts = {}) {
377
+ const limit = opts.limit ?? 20;
378
+ // Fetch more candidates than needed for RRF merging
379
+ const candidateLimit = limit * 3;
380
+ // 1. BM25 search
381
+ const bm25Results = this.search(query, {
382
+ type: opts.type,
383
+ days: opts.days,
384
+ limit: candidateLimit,
385
+ includeArchived: opts.includeArchived,
386
+ });
387
+ // 2. Vector search
388
+ const vecResults = this.searchVec(embedding, candidateLimit);
389
+ // 3. RRF merge
390
+ const K = 60;
391
+ const scores = new Map(); // rowid -> rrf score
392
+ // Get rowids for BM25 results
393
+ const bm25Ids = bm25Results.map((e) => e.id);
394
+ const bm25Rowids = this.getRowidsByIds(bm25Ids);
395
+ bm25Rowids.forEach((rowid, rank) => {
396
+ scores.set(rowid, (scores.get(rowid) ?? 0) + 1 / (K + rank + 1));
397
+ });
398
+ vecResults.forEach(({ rowid }, rank) => {
399
+ scores.set(rowid, (scores.get(rowid) ?? 0) + 1 / (K + rank + 1));
400
+ });
401
+ // 4. Temporal decay
402
+ const allRowids = Array.from(scores.keys());
403
+ const entries = this.getByRowids(allRowids);
404
+ const entryMap = new Map();
405
+ // Build rowid -> entry map
406
+ const rowidLookup = this.getRowidsByIds(entries.map((e) => e.id));
407
+ entries.forEach((entry, i) => {
408
+ entryMap.set(rowidLookup[i], entry);
409
+ });
410
+ const today = new Date();
411
+ const currentProject = opts.project ?? null;
412
+ const projectBoost = (entryProject, curProject) => {
413
+ if (!curProject || !entryProject)
414
+ return 1.0;
415
+ return entryProject === curProject ? 1.5 : 1.0;
416
+ };
417
+ const tierWeight = (tier) => {
418
+ switch (tier) {
419
+ case "longterm": return 1.5;
420
+ case "ephemeral": return 0.5;
421
+ default: return 1.0;
422
+ }
423
+ };
424
+ const confidenceBoost = (accessCount) => {
425
+ if (accessCount === 0)
426
+ return 0.7;
427
+ if (accessCount <= 2)
428
+ return 1.0;
429
+ if (accessCount <= 5)
430
+ return 1.2;
431
+ return 1.4;
432
+ };
433
+ const scored = allRowids.map((rowid) => {
434
+ const entry = entryMap.get(rowid);
435
+ const entryDate = new Date(entry.date);
436
+ const ageDays = (today.getTime() - entryDate.getTime()) / (1000 * 60 * 60 * 24);
437
+ const decay = Math.pow(0.5, ageDays / 30);
438
+ return {
439
+ entry,
440
+ score: (scores.get(rowid) ?? 0) * decay * tierWeight(entry.tier) * confidenceBoost(entry.accessCount ?? 0) * projectBoost(entry.project, currentProject),
441
+ };
442
+ });
443
+ // 5. Filter, sort by score descending, return top N
444
+ const filtered = scored.filter((s) => {
445
+ if (!opts.includeArchived && s.entry.archived)
446
+ return false;
447
+ if (opts.tier && s.entry.tier !== opts.tier)
448
+ return false;
449
+ return true;
450
+ });
451
+ filtered.sort((a, b) => b.score - a.score);
452
+ const result = filtered.slice(0, limit).map((s) => s.entry);
453
+ this.bumpAccess(result.map((e) => e.id));
454
+ return result;
455
+ }
456
+ /** Archive ephemeral entries based on access-aware windows */
457
+ decayEphemeral() {
458
+ // 0 accesses: 3 days, 1-2 accesses: 7 days, 3+ accesses: 14 days
459
+ const stmt = this.db.prepare(`
460
+ UPDATE entries SET archived = 1
461
+ WHERE tier = 'ephemeral' AND pinned = 0 AND archived = 0
462
+ AND (
463
+ (access_count = 0 AND date < date('now', '-3 days'))
464
+ OR (access_count BETWEEN 1 AND 2 AND date < date('now', '-7 days'))
465
+ OR (access_count >= 3 AND date < date('now', '-14 days'))
466
+ )
467
+ `);
468
+ return Number(stmt.run().changes);
469
+ }
470
+ /** Demote working entries based on access-aware idle windows */
471
+ demoteIdle() {
472
+ // 0 accesses: 15 days, 1-2 accesses: 30 days, 3+ accesses: 60 days
473
+ const stmt = this.db.prepare(`
474
+ UPDATE entries SET tier = 'ephemeral'
475
+ WHERE tier = 'working' AND pinned = 0 AND archived = 0
476
+ AND (
477
+ (access_count = 0 AND COALESCE(last_accessed, date) < date('now', '-15 days'))
478
+ OR (access_count BETWEEN 1 AND 2 AND COALESCE(last_accessed, date) < date('now', '-30 days'))
479
+ OR (access_count >= 3 AND COALESCE(last_accessed, date) < date('now', '-60 days'))
480
+ )
481
+ `);
482
+ return Number(stmt.run().changes);
483
+ }
484
+ /** Promote decisions/insights older than 7 days to longterm */
485
+ promoteStable() {
486
+ const stmt = this.db.prepare(`
487
+ UPDATE entries SET tier = 'longterm'
488
+ WHERE tier = 'working' AND archived = 0
489
+ AND type IN ('decision', 'insight')
490
+ AND date < date('now', '-7 days')
491
+ `);
492
+ return Number(stmt.run().changes);
493
+ }
494
+ /** Promote ephemeral entries accessed 3+ times to working */
495
+ promoteFrequent() {
496
+ const stmt = this.db.prepare(`
497
+ UPDATE entries SET tier = 'working'
498
+ WHERE tier = 'ephemeral' AND archived = 0
499
+ AND access_count >= 3
500
+ `);
501
+ return Number(stmt.run().changes);
502
+ }
503
+ getEmbedding(entryId) {
504
+ const rowidRow = this.db.prepare("SELECT rowid FROM entries WHERE id = ?").get(entryId);
505
+ if (!rowidRow)
506
+ return null;
507
+ try {
508
+ const vecRow = this.db.prepare("SELECT embedding FROM vec_entries WHERE rowid = CAST(? AS INTEGER)").get(rowidRow.rowid);
509
+ if (!vecRow)
510
+ return null;
511
+ if (vecRow.embedding instanceof Uint8Array) {
512
+ return new Float32Array(vecRow.embedding.buffer, vecRow.embedding.byteOffset, vecRow.embedding.byteLength / 4);
513
+ }
514
+ return new Float32Array(vecRow.embedding);
515
+ }
516
+ catch {
517
+ return null;
518
+ }
519
+ }
520
+ findConsolidationCandidates(threshold = 0.75) {
521
+ // Get all non-archived issue/decision entries from last 30 days
522
+ const candidates = this.list({ days: 30, includeArchived: false })
523
+ .filter(e => e.type === "issue" || e.type === "decision");
524
+ if (candidates.length < 3)
525
+ return [];
526
+ // Simple greedy clustering by cosine similarity
527
+ const used = new Set();
528
+ const groups = [];
529
+ for (let i = 0; i < candidates.length; i++) {
530
+ if (used.has(candidates[i].id))
531
+ continue;
532
+ const embA = this.getEmbedding(candidates[i].id);
533
+ if (!embA)
534
+ continue;
535
+ const cluster = [candidates[i]];
536
+ for (let j = i + 1; j < candidates.length; j++) {
537
+ if (used.has(candidates[j].id))
538
+ continue;
539
+ const embB = this.getEmbedding(candidates[j].id);
540
+ if (!embB)
541
+ continue;
542
+ if (cosineSimilarity(embA, embB) >= threshold) {
543
+ cluster.push(candidates[j]);
544
+ }
545
+ }
546
+ if (cluster.length >= 3) {
547
+ cluster.forEach(e => used.add(e.id));
548
+ groups.push({
549
+ label: cluster[0].content.slice(0, 80),
550
+ entries: cluster,
551
+ });
552
+ }
553
+ }
554
+ return groups;
555
+ }
556
+ hasEntryWithTag(tag) {
557
+ const row = this.db.prepare("SELECT 1 FROM entries WHERE tags LIKE ? LIMIT 1").get(`%${tag}%`);
558
+ return !!row;
559
+ }
560
+ findByTag(tag) {
561
+ const sql = `
562
+ SELECT id, date, time, type, tags, content, source_file, tier, access_count, last_accessed, pinned, archived, project, session_id, agent_id
563
+ FROM entries
564
+ WHERE tags LIKE ? AND archived = 0
565
+ ORDER BY date ASC, time ASC
566
+ `;
567
+ const rows = this.db.prepare(sql).all(`%${tag}%`);
568
+ return rows.map((row) => ({
569
+ id: row.id,
570
+ date: row.date,
571
+ time: row.time,
572
+ type: row.type,
573
+ tags: row.tags.split(", ").filter(Boolean),
574
+ content: row.content,
575
+ sourceFile: row.source_file,
576
+ tier: row.tier,
577
+ accessCount: row.access_count,
578
+ lastAccessed: row.last_accessed,
579
+ pinned: !!row.pinned,
580
+ archived: !!row.archived,
581
+ project: row.project,
582
+ sessionId: row.session_id,
583
+ agentId: row.agent_id,
584
+ }));
585
+ }
586
+ findSimilarIssues(embedding, days = 30, distanceThreshold = 0.5) {
587
+ // sqlite-vec distance: lower = more similar. ~0.5 distance ≈ ~0.75 cosine similarity for normalized vectors
588
+ const vecResults = this.searchVec(embedding, 20);
589
+ const matchingRowids = vecResults
590
+ .filter(r => r.distance <= distanceThreshold)
591
+ .map(r => r.rowid);
592
+ if (matchingRowids.length === 0)
593
+ return [];
594
+ const entries = this.getByRowids(matchingRowids);
595
+ const cutoff = new Date();
596
+ cutoff.setDate(cutoff.getDate() - days);
597
+ const cutoffStr = cutoff.toISOString().slice(0, 10);
598
+ return entries.filter(e => e.type === "issue" && !e.archived && e.date >= cutoffStr);
599
+ }
600
+ createOrUpdatePattern(label, entryIds) {
601
+ // Check if any existing unresolved pattern overlaps with these entries
602
+ const patterns = this.db.prepare("SELECT id, entry_ids, occurrence_count, first_seen FROM patterns WHERE resolved = 0").all();
603
+ const entryIdSet = new Set(entryIds);
604
+ for (const pat of patterns) {
605
+ const existing = JSON.parse(pat.entry_ids);
606
+ const overlap = existing.some(id => entryIdSet.has(id));
607
+ if (overlap) {
608
+ // Merge into existing pattern
609
+ const merged = Array.from(new Set([...existing, ...entryIds]));
610
+ const now = new Date().toISOString().slice(0, 10);
611
+ this.db.prepare(`
612
+ UPDATE patterns SET entry_ids = ?, occurrence_count = ?, last_seen = ?, label = ?
613
+ WHERE id = ?
614
+ `).run(JSON.stringify(merged), merged.length, now, label.slice(0, 80), pat.id);
615
+ return pat.id;
616
+ }
617
+ }
618
+ // Create new pattern
619
+ const id = Date.now().toString(36) + Math.random().toString(36).slice(2, 6);
620
+ const now = new Date().toISOString().slice(0, 10);
621
+ this.db.prepare(`
622
+ INSERT INTO patterns (id, label, entry_ids, occurrence_count, first_seen, last_seen)
623
+ VALUES (?, ?, ?, ?, ?, ?)
624
+ `).run(id, label.slice(0, 80), JSON.stringify(entryIds), entryIds.length, now, now);
625
+ return id;
626
+ }
627
+ getActivePatterns() {
628
+ const rows = this.db.prepare("SELECT * FROM patterns WHERE resolved = 0 AND occurrence_count >= 3 ORDER BY last_seen DESC").all();
629
+ return rows.map(r => ({
630
+ id: r.id,
631
+ label: r.label,
632
+ entryIds: JSON.parse(r.entry_ids),
633
+ occurrenceCount: r.occurrence_count,
634
+ firstSeen: r.first_seen,
635
+ lastSeen: r.last_seen,
636
+ }));
637
+ }
638
+ getPatternForEntry(entryId) {
639
+ const rows = this.db.prepare("SELECT id, occurrence_count, entry_ids FROM patterns WHERE resolved = 0").all();
640
+ for (const row of rows) {
641
+ const ids = JSON.parse(row.entry_ids);
642
+ if (ids.includes(entryId)) {
643
+ return { id: row.id, occurrenceCount: row.occurrence_count };
644
+ }
645
+ }
646
+ return null;
647
+ }
648
+ resolvePattern(patternId) {
649
+ const result = this.db.prepare("UPDATE patterns SET resolved = 1 WHERE id = ?").run(patternId);
650
+ return Number(result.changes) > 0;
651
+ }
652
+ saveRule(label, content) {
653
+ const now = new Date();
654
+ const date = now.toISOString().slice(0, 10);
655
+ const time = now.toTimeString().slice(0, 5);
656
+ const id = Date.now().toString(36) + Math.random().toString(36).slice(2, 6);
657
+ // Upsert: clean up old rule with same label (manually sync FTS to avoid trigger issue)
658
+ const existing = this.db.prepare("SELECT rowid FROM entries WHERE type = 'rule' AND label = ?").get(label);
659
+ if (existing) {
660
+ this.db.prepare("DELETE FROM entries_fts WHERE rowid = ?").run(existing.rowid);
661
+ this.db.exec("DROP TRIGGER IF EXISTS entries_ad");
662
+ this.db.prepare("DELETE FROM entries WHERE rowid = ?").run(existing.rowid);
663
+ this.db.exec(`
664
+ CREATE TRIGGER entries_ad AFTER DELETE ON entries BEGIN
665
+ INSERT INTO entries_fts(entries_fts, rowid, content, tags, type)
666
+ VALUES ('delete', old.rowid, old.content, old.tags, old.type);
667
+ END
668
+ `);
669
+ }
670
+ const stmt = this.db.prepare(`
671
+ INSERT INTO entries (id, date, time, type, tags, content, source_file, tier, access_count, last_accessed, pinned, archived, label)
672
+ VALUES (?, ?, ?, 'rule', '', ?, 'rule', 'longterm', 0, NULL, 1, 0, ?)
673
+ `);
674
+ stmt.run(id, date, time, content, label);
675
+ const row = this.db.prepare("SELECT last_insert_rowid() as rowid").get();
676
+ return row.rowid;
677
+ }
678
+ listRules() {
679
+ const rows = this.db.prepare("SELECT id, date, time, type, tags, content, source_file, tier, access_count, last_accessed, pinned, archived, label FROM entries WHERE type = 'rule' AND archived = 0 ORDER BY date DESC").all();
680
+ return rows.map((row) => ({
681
+ id: row.id,
682
+ date: row.date,
683
+ time: row.time,
684
+ type: row.type,
685
+ tags: row.tags.split(", ").filter(Boolean),
686
+ content: row.content,
687
+ sourceFile: row.source_file,
688
+ tier: row.tier,
689
+ accessCount: row.access_count,
690
+ lastAccessed: row.last_accessed,
691
+ pinned: !!row.pinned,
692
+ archived: !!row.archived,
693
+ label: row.label,
694
+ }));
695
+ }
696
+ deleteRule(label) {
697
+ const existing = this.db.prepare("SELECT rowid FROM entries WHERE type = 'rule' AND label = ?").get(label);
698
+ if (!existing)
699
+ return false;
700
+ this.db.prepare("DELETE FROM entries_fts WHERE rowid = ?").run(existing.rowid);
701
+ this.db.exec("DROP TRIGGER IF EXISTS entries_ad");
702
+ this.db.prepare("DELETE FROM entries WHERE rowid = ?").run(existing.rowid);
703
+ this.db.exec(`
704
+ CREATE TRIGGER entries_ad AFTER DELETE ON entries BEGIN
705
+ INSERT INTO entries_fts(entries_fts, rowid, content, tags, type)
706
+ VALUES ('delete', old.rowid, old.content, old.tags, old.type);
707
+ END
708
+ `);
709
+ return true;
710
+ }
711
+ upsertFilePair(project, fileA, fileB, date) {
712
+ // Ensure consistent ordering (a < b) to avoid duplicates
713
+ const [f1, f2] = fileA < fileB ? [fileA, fileB] : [fileB, fileA];
714
+ this.db.prepare(`
715
+ INSERT INTO file_pairs (project, file_a, file_b, co_change_count, last_seen)
716
+ VALUES (?, ?, ?, 1, ?)
717
+ ON CONFLICT(project, file_a, file_b) DO UPDATE SET
718
+ co_change_count = co_change_count + 1,
719
+ last_seen = ?
720
+ `).run(project, f1, f2, date, date);
721
+ }
722
+ getCoChanges(project, file, limit = 10) {
723
+ const rows = this.db.prepare(`
724
+ SELECT
725
+ CASE WHEN file_a = ? THEN file_b ELSE file_a END as paired_file,
726
+ co_change_count,
727
+ last_seen
728
+ FROM file_pairs
729
+ WHERE project = ? AND (file_a = ? OR file_b = ?)
730
+ ORDER BY co_change_count DESC
731
+ LIMIT ?
732
+ `).all(file, project, file, file, limit);
733
+ return rows.map(r => ({
734
+ file: r.paired_file,
735
+ count: r.co_change_count,
736
+ lastSeen: r.last_seen,
737
+ }));
738
+ }
739
+ listBySession(sessionId, opts = {}) {
740
+ const conditions = ["session_id = ?"];
741
+ const params = [sessionId];
742
+ if (opts.type) {
743
+ conditions.push("type = ?");
744
+ params.push(opts.type);
745
+ }
746
+ const sql = `
747
+ SELECT id, date, time, type, tags, content, source_file, tier, access_count,
748
+ last_accessed, pinned, archived, project, session_id, agent_id
749
+ FROM entries
750
+ WHERE ${conditions.join(" AND ")}
751
+ ORDER BY date ASC, time ASC
752
+ `;
753
+ const rows = this.db.prepare(sql).all(...params);
754
+ return rows.map((row) => ({
755
+ id: row.id,
756
+ date: row.date,
757
+ time: row.time,
758
+ type: row.type,
759
+ tags: row.tags.split(", ").filter(Boolean),
760
+ content: row.content,
761
+ sourceFile: row.source_file,
762
+ tier: row.tier,
763
+ accessCount: row.access_count,
764
+ lastAccessed: row.last_accessed,
765
+ pinned: !!row.pinned,
766
+ archived: !!row.archived,
767
+ project: row.project,
768
+ sessionId: row.session_id,
769
+ agentId: row.agent_id,
770
+ }));
771
+ }
772
+ /** Update an entry's content (for consolidation) */
773
+ updateEntryContent(id, newContent) {
774
+ // Fetch current row data for FTS removal
775
+ const row = this.db.prepare("SELECT rowid, content, tags, type FROM entries WHERE id = ?").get(id);
776
+ if (!row)
777
+ return false;
778
+ // Remove old FTS entry using the delete command, then update, then re-insert.
779
+ // We temporarily drop and recreate triggers to avoid interference.
780
+ this.db.exec("DROP TRIGGER IF EXISTS entries_ai");
781
+ this.db.exec("DROP TRIGGER IF EXISTS entries_ad");
782
+ // Delete the old FTS row by rowid
783
+ this.db.prepare("DELETE FROM entries_fts WHERE rowid = ?").run(row.rowid);
784
+ // Update the content in entries table
785
+ const result = this.db.prepare("UPDATE entries SET content = ? WHERE id = ?").run(newContent, id);
786
+ // Re-insert FTS entry with updated content
787
+ if (result.changes > 0) {
788
+ this.db.prepare("INSERT INTO entries_fts(rowid, content, tags, type) VALUES (?, ?, ?, ?)").run(row.rowid, newContent, row.tags, row.type);
789
+ }
790
+ // Recreate triggers
791
+ this.db.exec(`
792
+ CREATE TRIGGER entries_ai AFTER INSERT ON entries BEGIN
793
+ INSERT INTO entries_fts(rowid, content, tags, type)
794
+ VALUES (new.rowid, new.content, new.tags, new.type);
795
+ END
796
+ `);
797
+ this.db.exec(`
798
+ CREATE TRIGGER entries_ad AFTER DELETE ON entries BEGIN
799
+ INSERT INTO entries_fts(entries_fts, rowid, content, tags, type)
800
+ VALUES ('delete', old.rowid, old.content, old.tags, old.type);
801
+ END
802
+ `);
803
+ return result.changes > 0;
804
+ }
805
+ /** Merge tags from source entries into a target entry */
806
+ mergeTagsInto(targetId, sourceIds) {
807
+ const targetRow = this.db.prepare("SELECT tags FROM entries WHERE id = ?").get(targetId);
808
+ if (!targetRow)
809
+ return;
810
+ const allTags = new Set(targetRow.tags.split(", ").filter(Boolean));
811
+ for (const srcId of sourceIds) {
812
+ const srcRow = this.db.prepare("SELECT tags FROM entries WHERE id = ?").get(srcId);
813
+ if (srcRow) {
814
+ for (const tag of srcRow.tags.split(", ").filter(Boolean)) {
815
+ allTags.add(tag);
816
+ }
817
+ }
818
+ }
819
+ this.db.prepare("UPDATE entries SET tags = ? WHERE id = ?").run([...allTags].join(", "), targetId);
820
+ }
821
+ /** Change tier for an entry */
822
+ changeTier(id, tier) {
823
+ const result = this.db.prepare("UPDATE entries SET tier = ? WHERE id = ?").run(tier, id);
824
+ return result.changes > 0;
825
+ }
826
+ close() {
827
+ this.db.close();
828
+ }
829
+ }
830
+ //# sourceMappingURL=sqlite.js.map