engrm 0.1.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 (82) hide show
  1. package/.mcp.json +9 -0
  2. package/AUTH-DESIGN.md +436 -0
  3. package/BRIEF.md +197 -0
  4. package/CLAUDE.md +44 -0
  5. package/COMPETITIVE.md +174 -0
  6. package/CONTEXT-OPTIMIZATION.md +305 -0
  7. package/INFRASTRUCTURE.md +252 -0
  8. package/LICENSE +105 -0
  9. package/MARKET.md +230 -0
  10. package/PLAN.md +278 -0
  11. package/README.md +121 -0
  12. package/SENTINEL.md +293 -0
  13. package/SERVER-API-PLAN.md +553 -0
  14. package/SPEC.md +843 -0
  15. package/SWOT.md +148 -0
  16. package/SYNC-ARCHITECTURE.md +294 -0
  17. package/VIBE-CODER-STRATEGY.md +250 -0
  18. package/bun.lock +375 -0
  19. package/hooks/post-tool-use.ts +144 -0
  20. package/hooks/session-start.ts +64 -0
  21. package/hooks/stop.ts +131 -0
  22. package/mem-page.html +1305 -0
  23. package/package.json +30 -0
  24. package/src/capture/dedup.test.ts +103 -0
  25. package/src/capture/dedup.ts +76 -0
  26. package/src/capture/extractor.test.ts +245 -0
  27. package/src/capture/extractor.ts +330 -0
  28. package/src/capture/quality.test.ts +168 -0
  29. package/src/capture/quality.ts +104 -0
  30. package/src/capture/retrospective.test.ts +115 -0
  31. package/src/capture/retrospective.ts +121 -0
  32. package/src/capture/scanner.test.ts +131 -0
  33. package/src/capture/scanner.ts +100 -0
  34. package/src/capture/scrubber.test.ts +144 -0
  35. package/src/capture/scrubber.ts +181 -0
  36. package/src/cli.ts +517 -0
  37. package/src/config.ts +238 -0
  38. package/src/context/inject.test.ts +940 -0
  39. package/src/context/inject.ts +382 -0
  40. package/src/embeddings/backfill.ts +50 -0
  41. package/src/embeddings/embedder.test.ts +76 -0
  42. package/src/embeddings/embedder.ts +139 -0
  43. package/src/lifecycle/aging.test.ts +103 -0
  44. package/src/lifecycle/aging.ts +36 -0
  45. package/src/lifecycle/compaction.test.ts +264 -0
  46. package/src/lifecycle/compaction.ts +190 -0
  47. package/src/lifecycle/purge.test.ts +100 -0
  48. package/src/lifecycle/purge.ts +37 -0
  49. package/src/lifecycle/scheduler.test.ts +120 -0
  50. package/src/lifecycle/scheduler.ts +101 -0
  51. package/src/provisioning/browser-auth.ts +172 -0
  52. package/src/provisioning/provision.test.ts +198 -0
  53. package/src/provisioning/provision.ts +94 -0
  54. package/src/register.test.ts +167 -0
  55. package/src/register.ts +178 -0
  56. package/src/server.ts +436 -0
  57. package/src/storage/migrations.test.ts +244 -0
  58. package/src/storage/migrations.ts +261 -0
  59. package/src/storage/outbox.test.ts +229 -0
  60. package/src/storage/outbox.ts +131 -0
  61. package/src/storage/projects.test.ts +137 -0
  62. package/src/storage/projects.ts +184 -0
  63. package/src/storage/sqlite.test.ts +798 -0
  64. package/src/storage/sqlite.ts +934 -0
  65. package/src/storage/vec.test.ts +198 -0
  66. package/src/sync/auth.test.ts +76 -0
  67. package/src/sync/auth.ts +68 -0
  68. package/src/sync/client.ts +183 -0
  69. package/src/sync/engine.test.ts +94 -0
  70. package/src/sync/engine.ts +127 -0
  71. package/src/sync/pull.test.ts +279 -0
  72. package/src/sync/pull.ts +170 -0
  73. package/src/sync/push.test.ts +117 -0
  74. package/src/sync/push.ts +230 -0
  75. package/src/tools/get.ts +34 -0
  76. package/src/tools/pin.ts +47 -0
  77. package/src/tools/save.test.ts +301 -0
  78. package/src/tools/save.ts +231 -0
  79. package/src/tools/search.test.ts +69 -0
  80. package/src/tools/search.ts +181 -0
  81. package/src/tools/timeline.ts +64 -0
  82. package/tsconfig.json +22 -0
@@ -0,0 +1,934 @@
1
+ import { Database as BunDatabase } from "bun:sqlite";
2
+ import { existsSync } from "node:fs";
3
+ import { runMigrations } from "./migrations.js";
4
+
5
+ // macOS ships a SQLite build that blocks extensions.
6
+ // Use Homebrew's vanilla SQLite if available (must be set before any Database instantiation).
7
+ const HOMEBREW_SQLITE_PATHS = [
8
+ "/opt/homebrew/opt/sqlite/lib/libsqlite3.dylib", // Apple Silicon
9
+ "/usr/local/opt/sqlite3/lib/libsqlite3.dylib", // Intel
10
+ ];
11
+
12
+ let _customSqliteSet = false;
13
+ function ensureCustomSqlite(): void {
14
+ if (_customSqliteSet) return;
15
+ _customSqliteSet = true;
16
+ if (process.platform !== "darwin") return;
17
+ for (const p of HOMEBREW_SQLITE_PATHS) {
18
+ if (existsSync(p)) {
19
+ try {
20
+ BunDatabase.setCustomSQLite(p);
21
+ } catch {
22
+ // Already set or not supported — ignore
23
+ }
24
+ return;
25
+ }
26
+ }
27
+ }
28
+
29
+ // --- Row types ---
30
+
31
+ export interface ProjectRow {
32
+ id: number;
33
+ canonical_id: string;
34
+ name: string;
35
+ local_path: string | null;
36
+ remote_url: string | null;
37
+ first_seen_epoch: number;
38
+ last_active_epoch: number;
39
+ }
40
+
41
+ export interface ObservationRow {
42
+ id: number;
43
+ session_id: string | null;
44
+ project_id: number;
45
+ type: string;
46
+ title: string;
47
+ narrative: string | null;
48
+ facts: string | null;
49
+ concepts: string | null;
50
+ files_read: string | null;
51
+ files_modified: string | null;
52
+ quality: number;
53
+ lifecycle: string;
54
+ sensitivity: string;
55
+ user_id: string;
56
+ device_id: string;
57
+ agent: string;
58
+ created_at: string;
59
+ created_at_epoch: number;
60
+ archived_at_epoch: number | null;
61
+ compacted_into: number | null;
62
+ superseded_by: number | null;
63
+ remote_source_id: string | null;
64
+ }
65
+
66
+ export interface SessionRow {
67
+ id: number;
68
+ session_id: string;
69
+ project_id: number | null;
70
+ user_id: string;
71
+ device_id: string;
72
+ agent: string;
73
+ status: string;
74
+ observation_count: number;
75
+ started_at_epoch: number | null;
76
+ completed_at_epoch: number | null;
77
+ }
78
+
79
+ export interface FtsMatchRow {
80
+ id: number;
81
+ rank: number;
82
+ }
83
+
84
+ export interface VecMatchRow {
85
+ observation_id: number;
86
+ distance: number;
87
+ }
88
+
89
+ export interface SessionSummaryRow {
90
+ id: number;
91
+ session_id: string;
92
+ project_id: number | null;
93
+ user_id: string;
94
+ request: string | null;
95
+ investigated: string | null;
96
+ learned: string | null;
97
+ completed: string | null;
98
+ next_steps: string | null;
99
+ created_at_epoch: number | null;
100
+ }
101
+
102
+ export interface SecurityFindingRow {
103
+ id: number;
104
+ session_id: string | null;
105
+ project_id: number;
106
+ finding_type: string;
107
+ severity: string;
108
+ pattern_name: string;
109
+ file_path: string | null;
110
+ snippet: string | null;
111
+ tool_name: string | null;
112
+ user_id: string;
113
+ device_id: string;
114
+ created_at_epoch: number;
115
+ }
116
+
117
+ // --- Insert types ---
118
+
119
+ export interface InsertObservation {
120
+ session_id?: string | null;
121
+ project_id: number;
122
+ type: string;
123
+ title: string;
124
+ narrative?: string | null;
125
+ facts?: string | null;
126
+ concepts?: string | null;
127
+ files_read?: string | null;
128
+ files_modified?: string | null;
129
+ quality: number;
130
+ lifecycle?: string;
131
+ sensitivity?: string;
132
+ user_id: string;
133
+ device_id: string;
134
+ agent?: string;
135
+ }
136
+
137
+ export interface InsertProject {
138
+ canonical_id: string;
139
+ name: string;
140
+ local_path?: string | null;
141
+ remote_url?: string | null;
142
+ }
143
+
144
+ export interface InsertSessionSummary {
145
+ session_id: string;
146
+ project_id: number | null;
147
+ user_id: string;
148
+ request: string | null;
149
+ investigated: string | null;
150
+ learned: string | null;
151
+ completed: string | null;
152
+ next_steps: string | null;
153
+ }
154
+
155
+ export interface InsertSecurityFinding {
156
+ session_id?: string | null;
157
+ project_id: number;
158
+ finding_type: string;
159
+ severity: string;
160
+ pattern_name: string;
161
+ file_path?: string | null;
162
+ snippet?: string | null;
163
+ tool_name?: string | null;
164
+ user_id: string;
165
+ device_id: string;
166
+ }
167
+
168
+ // --- Database class ---
169
+
170
+ export class MemDatabase {
171
+ readonly db: BunDatabase;
172
+ readonly vecAvailable: boolean;
173
+
174
+ constructor(dbPath: string) {
175
+ ensureCustomSqlite();
176
+ this.db = new BunDatabase(dbPath);
177
+ this.db.exec("PRAGMA journal_mode = WAL");
178
+ this.db.exec("PRAGMA foreign_keys = ON");
179
+
180
+ // Attempt to load sqlite-vec extension before migrations
181
+ this.vecAvailable = this.loadVecExtension();
182
+
183
+ runMigrations(this.db);
184
+ }
185
+
186
+ private loadVecExtension(): boolean {
187
+ try {
188
+ const sqliteVec = require("sqlite-vec");
189
+ sqliteVec.load(this.db);
190
+ return true;
191
+ } catch {
192
+ return false;
193
+ }
194
+ }
195
+
196
+ close(): void {
197
+ this.db.close();
198
+ }
199
+
200
+ // --- Projects ---
201
+
202
+ upsertProject(project: InsertProject): ProjectRow {
203
+ const now = Math.floor(Date.now() / 1000);
204
+ const existing = this.db
205
+ .query<ProjectRow, [string]>(
206
+ "SELECT * FROM projects WHERE canonical_id = ?"
207
+ )
208
+ .get(project.canonical_id);
209
+
210
+ if (existing) {
211
+ this.db
212
+ .query(
213
+ `UPDATE projects SET
214
+ local_path = COALESCE(?, local_path),
215
+ remote_url = COALESCE(?, remote_url),
216
+ last_active_epoch = ?
217
+ WHERE id = ?`
218
+ )
219
+ .run(
220
+ project.local_path ?? null,
221
+ project.remote_url ?? null,
222
+ now,
223
+ existing.id
224
+ );
225
+ return {
226
+ ...existing,
227
+ local_path: project.local_path ?? existing.local_path,
228
+ remote_url: project.remote_url ?? existing.remote_url,
229
+ last_active_epoch: now,
230
+ };
231
+ }
232
+
233
+ const result = this.db
234
+ .query(
235
+ `INSERT INTO projects (canonical_id, name, local_path, remote_url, first_seen_epoch, last_active_epoch)
236
+ VALUES (?, ?, ?, ?, ?, ?)`
237
+ )
238
+ .run(
239
+ project.canonical_id,
240
+ project.name,
241
+ project.local_path ?? null,
242
+ project.remote_url ?? null,
243
+ now,
244
+ now
245
+ );
246
+
247
+ return this.db
248
+ .query<ProjectRow, [number]>("SELECT * FROM projects WHERE id = ?")
249
+ .get(Number(result.lastInsertRowid))!;
250
+ }
251
+
252
+ getProjectByCanonicalId(canonicalId: string): ProjectRow | null {
253
+ return (
254
+ this.db
255
+ .query<ProjectRow, [string]>(
256
+ "SELECT * FROM projects WHERE canonical_id = ?"
257
+ )
258
+ .get(canonicalId) ?? null
259
+ );
260
+ }
261
+
262
+ getProjectById(id: number): ProjectRow | null {
263
+ return (
264
+ this.db
265
+ .query<ProjectRow, [number]>("SELECT * FROM projects WHERE id = ?")
266
+ .get(id) ?? null
267
+ );
268
+ }
269
+
270
+ // --- Observations ---
271
+
272
+ insertObservation(obs: InsertObservation): ObservationRow {
273
+ const now = Math.floor(Date.now() / 1000);
274
+ const createdAt = new Date().toISOString();
275
+
276
+ const result = this.db
277
+ .query(
278
+ `INSERT INTO observations (
279
+ session_id, project_id, type, title, narrative, facts, concepts,
280
+ files_read, files_modified, quality, lifecycle, sensitivity,
281
+ user_id, device_id, agent, created_at, created_at_epoch
282
+ ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`
283
+ )
284
+ .run(
285
+ obs.session_id ?? null,
286
+ obs.project_id,
287
+ obs.type,
288
+ obs.title,
289
+ obs.narrative ?? null,
290
+ obs.facts ?? null,
291
+ obs.concepts ?? null,
292
+ obs.files_read ?? null,
293
+ obs.files_modified ?? null,
294
+ obs.quality,
295
+ obs.lifecycle ?? "active",
296
+ obs.sensitivity ?? "shared",
297
+ obs.user_id,
298
+ obs.device_id,
299
+ obs.agent ?? "claude-code",
300
+ createdAt,
301
+ now
302
+ );
303
+
304
+ const id = Number(result.lastInsertRowid);
305
+ const row = this.getObservationById(id)!;
306
+
307
+ // Maintain FTS5 index (external content mode — manual sync)
308
+ this.ftsInsert(row);
309
+
310
+ // Increment session observation count if applicable
311
+ if (obs.session_id) {
312
+ this.db
313
+ .query(
314
+ "UPDATE sessions SET observation_count = observation_count + 1 WHERE session_id = ?"
315
+ )
316
+ .run(obs.session_id);
317
+ }
318
+
319
+ return row;
320
+ }
321
+
322
+ getObservationById(id: number): ObservationRow | null {
323
+ return (
324
+ this.db
325
+ .query<ObservationRow, [number]>(
326
+ "SELECT * FROM observations WHERE id = ?"
327
+ )
328
+ .get(id) ?? null
329
+ );
330
+ }
331
+
332
+ getObservationsByIds(ids: number[]): ObservationRow[] {
333
+ if (ids.length === 0) return [];
334
+ const placeholders = ids.map(() => "?").join(",");
335
+ return this.db
336
+ .query<ObservationRow, number[]>(
337
+ `SELECT * FROM observations WHERE id IN (${placeholders}) ORDER BY created_at_epoch DESC`
338
+ )
339
+ .all(...ids);
340
+ }
341
+
342
+ /**
343
+ * Get recent observations for a project within a time window.
344
+ * Used for deduplication checks.
345
+ */
346
+ getRecentObservations(
347
+ projectId: number,
348
+ sincEpoch: number,
349
+ limit: number = 50
350
+ ): ObservationRow[] {
351
+ return this.db
352
+ .query<ObservationRow, [number, number, number]>(
353
+ `SELECT * FROM observations
354
+ WHERE project_id = ? AND created_at_epoch > ?
355
+ ORDER BY created_at_epoch DESC
356
+ LIMIT ?`
357
+ )
358
+ .all(projectId, sincEpoch, limit);
359
+ }
360
+
361
+ /**
362
+ * FTS5 search scoped to a project. Returns observation IDs with BM25 rank.
363
+ */
364
+ searchFts(
365
+ query: string,
366
+ projectId: number | null,
367
+ lifecycles: string[] = ["active", "aging", "pinned"],
368
+ limit: number = 20
369
+ ): FtsMatchRow[] {
370
+ const lifecyclePlaceholders = lifecycles.map(() => "?").join(",");
371
+
372
+ if (projectId !== null) {
373
+ return this.db
374
+ .query<FtsMatchRow, [string, number, ...string[], number]>(
375
+ `SELECT o.id, observations_fts.rank
376
+ FROM observations_fts
377
+ JOIN observations o ON o.id = observations_fts.rowid
378
+ WHERE observations_fts MATCH ?
379
+ AND o.project_id = ?
380
+ AND o.lifecycle IN (${lifecyclePlaceholders})
381
+ ORDER BY observations_fts.rank
382
+ LIMIT ?`
383
+ )
384
+ .all(query, projectId, ...lifecycles, limit);
385
+ }
386
+
387
+ return this.db
388
+ .query<FtsMatchRow, [string, ...string[], number]>(
389
+ `SELECT o.id, observations_fts.rank
390
+ FROM observations_fts
391
+ JOIN observations o ON o.id = observations_fts.rowid
392
+ WHERE observations_fts MATCH ?
393
+ AND o.lifecycle IN (${lifecyclePlaceholders})
394
+ ORDER BY observations_fts.rank
395
+ LIMIT ?`
396
+ )
397
+ .all(query, ...lifecycles, limit);
398
+ }
399
+
400
+ /**
401
+ * Get chronological observations around an anchor.
402
+ */
403
+ getTimeline(
404
+ anchorId: number,
405
+ projectId: number | null,
406
+ depthBefore: number = 3,
407
+ depthAfter: number = 3
408
+ ): ObservationRow[] {
409
+ const anchor = this.getObservationById(anchorId);
410
+ if (!anchor) return [];
411
+
412
+ const projectFilter = projectId !== null ? "AND project_id = ?" : "";
413
+ const projectParams = projectId !== null ? [projectId] : [];
414
+
415
+ const before = this.db
416
+ .query<ObservationRow, (number | null)[]>(
417
+ `SELECT * FROM observations
418
+ WHERE created_at_epoch < ? ${projectFilter}
419
+ AND lifecycle IN ('active', 'aging', 'pinned')
420
+ ORDER BY created_at_epoch DESC
421
+ LIMIT ?`
422
+ )
423
+ .all(anchor.created_at_epoch, ...projectParams, depthBefore);
424
+
425
+ const after = this.db
426
+ .query<ObservationRow, (number | null)[]>(
427
+ `SELECT * FROM observations
428
+ WHERE created_at_epoch > ? ${projectFilter}
429
+ AND lifecycle IN ('active', 'aging', 'pinned')
430
+ ORDER BY created_at_epoch ASC
431
+ LIMIT ?`
432
+ )
433
+ .all(anchor.created_at_epoch, ...projectParams, depthAfter);
434
+
435
+ return [...before.reverse(), anchor, ...after];
436
+ }
437
+
438
+ /**
439
+ * Pin or unpin an observation.
440
+ */
441
+ pinObservation(id: number, pinned: boolean): boolean {
442
+ const obs = this.getObservationById(id);
443
+ if (!obs) return false;
444
+
445
+ // Only active or aging observations can be pinned.
446
+ // Pinned observations can be unpinned back to active.
447
+ if (pinned) {
448
+ if (obs.lifecycle !== "active" && obs.lifecycle !== "aging") return false;
449
+ this.db
450
+ .query("UPDATE observations SET lifecycle = 'pinned' WHERE id = ?")
451
+ .run(id);
452
+ } else {
453
+ if (obs.lifecycle !== "pinned") return false;
454
+ this.db
455
+ .query("UPDATE observations SET lifecycle = 'active' WHERE id = ?")
456
+ .run(id);
457
+ }
458
+ return true;
459
+ }
460
+
461
+ /**
462
+ * Count active + aging observations (for quota checks).
463
+ */
464
+ getActiveObservationCount(userId?: string): number {
465
+ if (userId) {
466
+ const result = this.db
467
+ .query<{ count: number }, [string]>(
468
+ `SELECT COUNT(*) as count FROM observations
469
+ WHERE lifecycle IN ('active', 'aging')
470
+ AND sensitivity != 'secret'
471
+ AND user_id = ?`
472
+ )
473
+ .get(userId);
474
+ return result?.count ?? 0;
475
+ }
476
+
477
+ const result = this.db
478
+ .query<{ count: number }, []>(
479
+ `SELECT COUNT(*) as count FROM observations
480
+ WHERE lifecycle IN ('active', 'aging')
481
+ AND sensitivity != 'secret'`
482
+ )
483
+ .get();
484
+ return result?.count ?? 0;
485
+ }
486
+
487
+ // --- Supersession ---
488
+
489
+ /**
490
+ * Mark an observation as superseded by a newer one.
491
+ * The old observation is archived and excluded from context/search.
492
+ *
493
+ * Supports chains: if oldId is already superseded, resolves to the
494
+ * current chain head and supersedes that instead. Max depth 10.
495
+ */
496
+ supersedeObservation(oldId: number, newId: number): boolean {
497
+ // Don't allow self-supersession
498
+ if (oldId === newId) return false;
499
+
500
+ const replacement = this.getObservationById(newId);
501
+ if (!replacement) return false;
502
+
503
+ // Resolve to the current chain head (follow superseded_by links)
504
+ let targetId = oldId;
505
+ const visited = new Set<number>();
506
+ for (let depth = 0; depth < 10; depth++) {
507
+ const target = this.getObservationById(targetId);
508
+ if (!target) return false;
509
+
510
+ // If not superseded, this is the head — supersede it
511
+ if (target.superseded_by === null) break;
512
+
513
+ // If the head is already the replacement, nothing to do
514
+ if (target.superseded_by === newId) return true;
515
+
516
+ // Follow the chain
517
+ visited.add(targetId);
518
+ targetId = target.superseded_by;
519
+
520
+ // Cycle detection
521
+ if (visited.has(targetId)) return false;
522
+ }
523
+
524
+ const target = this.getObservationById(targetId);
525
+ if (!target) return false;
526
+ if (target.superseded_by !== null) return false; // chain too deep
527
+ if (targetId === newId) return false; // would self-supersede after resolution
528
+
529
+ const now = Math.floor(Date.now() / 1000);
530
+ this.db
531
+ .query(
532
+ `UPDATE observations
533
+ SET superseded_by = ?, lifecycle = 'archived', archived_at_epoch = ?
534
+ WHERE id = ?`
535
+ )
536
+ .run(newId, now, targetId);
537
+
538
+ // Remove from search indexes (archived observations shouldn't appear)
539
+ this.ftsDelete(target);
540
+ this.vecDelete(targetId);
541
+
542
+ return true;
543
+ }
544
+
545
+ /**
546
+ * Check if an observation has been superseded.
547
+ */
548
+ isSuperseded(id: number): boolean {
549
+ const obs = this.getObservationById(id);
550
+ return obs !== null && obs.superseded_by !== null;
551
+ }
552
+
553
+ // --- Sessions ---
554
+
555
+ upsertSession(
556
+ sessionId: string,
557
+ projectId: number | null,
558
+ userId: string,
559
+ deviceId: string,
560
+ agent: string = "claude-code"
561
+ ): SessionRow {
562
+ const existing = this.db
563
+ .query<SessionRow, [string]>(
564
+ "SELECT * FROM sessions WHERE session_id = ?"
565
+ )
566
+ .get(sessionId);
567
+
568
+ if (existing) return existing;
569
+
570
+ const now = Math.floor(Date.now() / 1000);
571
+ this.db
572
+ .query(
573
+ `INSERT INTO sessions (session_id, project_id, user_id, device_id, agent, started_at_epoch)
574
+ VALUES (?, ?, ?, ?, ?, ?)`
575
+ )
576
+ .run(sessionId, projectId, userId, deviceId, agent, now);
577
+
578
+ return this.db
579
+ .query<SessionRow, [string]>(
580
+ "SELECT * FROM sessions WHERE session_id = ?"
581
+ )
582
+ .get(sessionId)!;
583
+ }
584
+
585
+ completeSession(sessionId: string): void {
586
+ const now = Math.floor(Date.now() / 1000);
587
+ this.db
588
+ .query(
589
+ "UPDATE sessions SET status = 'completed', completed_at_epoch = ? WHERE session_id = ?"
590
+ )
591
+ .run(now, sessionId);
592
+ }
593
+
594
+ // --- Sync outbox ---
595
+
596
+ addToOutbox(recordType: "observation" | "summary", recordId: number): void {
597
+ const now = Math.floor(Date.now() / 1000);
598
+ this.db
599
+ .query(
600
+ `INSERT INTO sync_outbox (record_type, record_id, created_at_epoch)
601
+ VALUES (?, ?, ?)`
602
+ )
603
+ .run(recordType, recordId, now);
604
+ }
605
+
606
+ // --- Sync state ---
607
+
608
+ getSyncState(key: string): string | null {
609
+ const row = this.db
610
+ .query<{ value: string }, [string]>(
611
+ "SELECT value FROM sync_state WHERE key = ?"
612
+ )
613
+ .get(key);
614
+ return row?.value ?? null;
615
+ }
616
+
617
+ setSyncState(key: string, value: string): void {
618
+ this.db
619
+ .query(
620
+ "INSERT INTO sync_state (key, value) VALUES (?, ?) ON CONFLICT(key) DO UPDATE SET value = ?"
621
+ )
622
+ .run(key, value, value);
623
+ }
624
+
625
+ // --- FTS5 maintenance (external content mode) ---
626
+
627
+ private ftsInsert(obs: ObservationRow): void {
628
+ this.db
629
+ .query(
630
+ `INSERT INTO observations_fts (rowid, title, narrative, facts, concepts)
631
+ VALUES (?, ?, ?, ?, ?)`
632
+ )
633
+ .run(obs.id, obs.title, obs.narrative, obs.facts, obs.concepts);
634
+ }
635
+
636
+ ftsDelete(obs: ObservationRow): void {
637
+ this.db
638
+ .query(
639
+ `INSERT INTO observations_fts (observations_fts, rowid, title, narrative, facts, concepts)
640
+ VALUES ('delete', ?, ?, ?, ?, ?)`
641
+ )
642
+ .run(obs.id, obs.title, obs.narrative, obs.facts, obs.concepts);
643
+ }
644
+
645
+ // --- sqlite-vec (local semantic search) ---
646
+
647
+ /**
648
+ * Insert an embedding for an observation.
649
+ */
650
+ vecInsert(observationId: number, embedding: Float32Array): void {
651
+ if (!this.vecAvailable) return;
652
+ this.db
653
+ .query(
654
+ "INSERT OR REPLACE INTO vec_observations (observation_id, embedding) VALUES (?, ?)"
655
+ )
656
+ .run(observationId, new Uint8Array(embedding.buffer));
657
+ }
658
+
659
+ /**
660
+ * Delete an embedding when observation is superseded/archived.
661
+ */
662
+ vecDelete(observationId: number): void {
663
+ if (!this.vecAvailable) return;
664
+ this.db
665
+ .query("DELETE FROM vec_observations WHERE observation_id = ?")
666
+ .run(observationId);
667
+ }
668
+
669
+ /**
670
+ * KNN search returning observation IDs with distance.
671
+ * Results filtered by project and lifecycle via JOIN.
672
+ */
673
+ searchVec(
674
+ queryEmbedding: Float32Array,
675
+ projectId: number | null,
676
+ lifecycles: string[] = ["active", "aging", "pinned"],
677
+ limit: number = 20
678
+ ): VecMatchRow[] {
679
+ if (!this.vecAvailable) return [];
680
+
681
+ const lifecyclePlaceholders = lifecycles.map(() => "?").join(",");
682
+ const embeddingBlob = new Uint8Array(queryEmbedding.buffer);
683
+
684
+ if (projectId !== null) {
685
+ return this.db
686
+ .query<VecMatchRow, any[]>(
687
+ `SELECT v.observation_id, v.distance
688
+ FROM vec_observations v
689
+ JOIN observations o ON o.id = v.observation_id
690
+ WHERE v.embedding MATCH ?
691
+ AND k = ?
692
+ AND o.project_id = ?
693
+ AND o.lifecycle IN (${lifecyclePlaceholders})
694
+ AND o.superseded_by IS NULL`
695
+ )
696
+ .all(embeddingBlob, limit, projectId, ...lifecycles);
697
+ }
698
+
699
+ return this.db
700
+ .query<VecMatchRow, any[]>(
701
+ `SELECT v.observation_id, v.distance
702
+ FROM vec_observations v
703
+ JOIN observations o ON o.id = v.observation_id
704
+ WHERE v.embedding MATCH ?
705
+ AND k = ?
706
+ AND o.lifecycle IN (${lifecyclePlaceholders})
707
+ AND o.superseded_by IS NULL`
708
+ )
709
+ .all(embeddingBlob, limit, ...lifecycles);
710
+ }
711
+
712
+ /**
713
+ * Count observations without embeddings (for backfill progress).
714
+ */
715
+ getUnembeddedCount(): number {
716
+ if (!this.vecAvailable) return 0;
717
+ const result = this.db
718
+ .query<{ count: number }, []>(
719
+ `SELECT COUNT(*) as count FROM observations o
720
+ WHERE o.lifecycle IN ('active', 'aging', 'pinned')
721
+ AND o.superseded_by IS NULL
722
+ AND NOT EXISTS (
723
+ SELECT 1 FROM vec_observations v WHERE v.observation_id = o.id
724
+ )`
725
+ )
726
+ .get();
727
+ return result?.count ?? 0;
728
+ }
729
+
730
+ /**
731
+ * Get unembedded observations for backfill.
732
+ */
733
+ getUnembeddedObservations(limit: number = 100): ObservationRow[] {
734
+ if (!this.vecAvailable) return [];
735
+ return this.db
736
+ .query<ObservationRow, [number]>(
737
+ `SELECT o.* FROM observations o
738
+ WHERE o.lifecycle IN ('active', 'aging', 'pinned')
739
+ AND o.superseded_by IS NULL
740
+ AND NOT EXISTS (
741
+ SELECT 1 FROM vec_observations v WHERE v.observation_id = o.id
742
+ )
743
+ ORDER BY o.created_at_epoch DESC
744
+ LIMIT ?`
745
+ )
746
+ .all(limit);
747
+ }
748
+
749
+ // --- Session summaries ---
750
+
751
+ insertSessionSummary(summary: InsertSessionSummary): SessionSummaryRow {
752
+ const now = Math.floor(Date.now() / 1000);
753
+ const result = this.db
754
+ .query(
755
+ `INSERT INTO session_summaries (session_id, project_id, user_id, request, investigated, learned, completed, next_steps, created_at_epoch)
756
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`
757
+ )
758
+ .run(
759
+ summary.session_id,
760
+ summary.project_id,
761
+ summary.user_id,
762
+ summary.request,
763
+ summary.investigated,
764
+ summary.learned,
765
+ summary.completed,
766
+ summary.next_steps,
767
+ now
768
+ );
769
+
770
+ const id = Number(result.lastInsertRowid);
771
+ return this.db
772
+ .query<SessionSummaryRow, [number]>(
773
+ "SELECT * FROM session_summaries WHERE id = ?"
774
+ )
775
+ .get(id)!;
776
+ }
777
+
778
+ getSessionSummary(sessionId: string): SessionSummaryRow | null {
779
+ return (
780
+ this.db
781
+ .query<SessionSummaryRow, [string]>(
782
+ "SELECT * FROM session_summaries WHERE session_id = ?"
783
+ )
784
+ .get(sessionId) ?? null
785
+ );
786
+ }
787
+
788
+ getRecentSummaries(projectId: number, limit: number = 5): SessionSummaryRow[] {
789
+ return this.db
790
+ .query<SessionSummaryRow, [number, number]>(
791
+ `SELECT * FROM session_summaries
792
+ WHERE project_id = ?
793
+ ORDER BY created_at_epoch DESC, id DESC
794
+ LIMIT ?`
795
+ )
796
+ .all(projectId, limit);
797
+ }
798
+
799
+ // --- Session metrics ---
800
+
801
+ incrementSessionMetrics(
802
+ sessionId: string,
803
+ increments: { files?: number; searches?: number; toolCalls?: number }
804
+ ): void {
805
+ const sets: string[] = [];
806
+ const params: (number | string)[] = [];
807
+
808
+ if (increments.files) {
809
+ sets.push("files_touched_count = files_touched_count + ?");
810
+ params.push(increments.files);
811
+ }
812
+ if (increments.searches) {
813
+ sets.push("searches_performed = searches_performed + ?");
814
+ params.push(increments.searches);
815
+ }
816
+ if (increments.toolCalls) {
817
+ sets.push("tool_calls_count = tool_calls_count + ?");
818
+ params.push(increments.toolCalls);
819
+ }
820
+
821
+ if (sets.length === 0) return;
822
+
823
+ params.push(sessionId);
824
+ this.db
825
+ .query(`UPDATE sessions SET ${sets.join(", ")} WHERE session_id = ?`)
826
+ .run(...params);
827
+ }
828
+
829
+ getSessionMetrics(sessionId: string): SessionRow & {
830
+ files_touched_count: number;
831
+ searches_performed: number;
832
+ tool_calls_count: number;
833
+ } | null {
834
+ return (
835
+ this.db
836
+ .query<
837
+ SessionRow & {
838
+ files_touched_count: number;
839
+ searches_performed: number;
840
+ tool_calls_count: number;
841
+ },
842
+ [string]
843
+ >("SELECT * FROM sessions WHERE session_id = ?")
844
+ .get(sessionId) ?? null
845
+ );
846
+ }
847
+
848
+ // --- Security findings ---
849
+
850
+ insertSecurityFinding(finding: InsertSecurityFinding): SecurityFindingRow {
851
+ const now = Math.floor(Date.now() / 1000);
852
+ const result = this.db
853
+ .query(
854
+ `INSERT INTO security_findings (session_id, project_id, finding_type, severity, pattern_name, file_path, snippet, tool_name, user_id, device_id, created_at_epoch)
855
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`
856
+ )
857
+ .run(
858
+ finding.session_id ?? null,
859
+ finding.project_id,
860
+ finding.finding_type,
861
+ finding.severity,
862
+ finding.pattern_name,
863
+ finding.file_path ?? null,
864
+ finding.snippet ?? null,
865
+ finding.tool_name ?? null,
866
+ finding.user_id,
867
+ finding.device_id,
868
+ now
869
+ );
870
+
871
+ const id = Number(result.lastInsertRowid);
872
+ return this.db
873
+ .query<SecurityFindingRow, [number]>(
874
+ "SELECT * FROM security_findings WHERE id = ?"
875
+ )
876
+ .get(id)!;
877
+ }
878
+
879
+ getSecurityFindings(
880
+ projectId: number,
881
+ options: { severity?: string; limit?: number } = {}
882
+ ): SecurityFindingRow[] {
883
+ const limit = options.limit ?? 50;
884
+ if (options.severity) {
885
+ return this.db
886
+ .query<SecurityFindingRow, [number, string, number]>(
887
+ `SELECT * FROM security_findings
888
+ WHERE project_id = ? AND severity = ?
889
+ ORDER BY created_at_epoch DESC
890
+ LIMIT ?`
891
+ )
892
+ .all(projectId, options.severity, limit);
893
+ }
894
+ return this.db
895
+ .query<SecurityFindingRow, [number, number]>(
896
+ `SELECT * FROM security_findings
897
+ WHERE project_id = ?
898
+ ORDER BY created_at_epoch DESC
899
+ LIMIT ?`
900
+ )
901
+ .all(projectId, limit);
902
+ }
903
+
904
+ getSecurityFindingsCount(projectId: number): Record<string, number> {
905
+ const rows = this.db
906
+ .query<{ severity: string; count: number }, [number]>(
907
+ `SELECT severity, COUNT(*) as count FROM security_findings
908
+ WHERE project_id = ?
909
+ GROUP BY severity`
910
+ )
911
+ .all(projectId);
912
+
913
+ const counts: Record<string, number> = {
914
+ critical: 0,
915
+ high: 0,
916
+ medium: 0,
917
+ low: 0,
918
+ };
919
+ for (const row of rows) {
920
+ counts[row.severity] = row.count;
921
+ }
922
+ return counts;
923
+ }
924
+
925
+ // --- Observations by session ---
926
+
927
+ getObservationsBySession(sessionId: string): ObservationRow[] {
928
+ return this.db
929
+ .query<ObservationRow, [string]>(
930
+ `SELECT * FROM observations WHERE session_id = ? ORDER BY created_at_epoch ASC`
931
+ )
932
+ .all(sessionId);
933
+ }
934
+ }