engrm 0.1.0 → 0.2.1

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/README.md +214 -73
  2. package/bin/build.mjs +97 -0
  3. package/bin/engrm.mjs +13 -0
  4. package/dist/cli.js +2712 -0
  5. package/dist/hooks/elicitation-result.js +1786 -0
  6. package/dist/hooks/post-tool-use.js +2357 -0
  7. package/dist/hooks/pre-compact.js +1321 -0
  8. package/dist/hooks/sentinel.js +1168 -0
  9. package/dist/hooks/session-start.js +1473 -0
  10. package/dist/hooks/stop.js +1834 -0
  11. package/dist/server.js +16628 -0
  12. package/package.json +34 -5
  13. package/packs/api-best-practices.json +182 -0
  14. package/packs/nextjs-patterns.json +68 -0
  15. package/packs/node-security.json +68 -0
  16. package/packs/python-django.json +68 -0
  17. package/packs/react-gotchas.json +182 -0
  18. package/packs/typescript-patterns.json +67 -0
  19. package/packs/web-security.json +182 -0
  20. package/.mcp.json +0 -9
  21. package/AUTH-DESIGN.md +0 -436
  22. package/BRIEF.md +0 -197
  23. package/CLAUDE.md +0 -44
  24. package/COMPETITIVE.md +0 -174
  25. package/CONTEXT-OPTIMIZATION.md +0 -305
  26. package/INFRASTRUCTURE.md +0 -252
  27. package/MARKET.md +0 -230
  28. package/PLAN.md +0 -278
  29. package/SENTINEL.md +0 -293
  30. package/SERVER-API-PLAN.md +0 -553
  31. package/SPEC.md +0 -843
  32. package/SWOT.md +0 -148
  33. package/SYNC-ARCHITECTURE.md +0 -294
  34. package/VIBE-CODER-STRATEGY.md +0 -250
  35. package/bun.lock +0 -375
  36. package/hooks/post-tool-use.ts +0 -144
  37. package/hooks/session-start.ts +0 -64
  38. package/hooks/stop.ts +0 -131
  39. package/mem-page.html +0 -1305
  40. package/src/capture/dedup.test.ts +0 -103
  41. package/src/capture/dedup.ts +0 -76
  42. package/src/capture/extractor.test.ts +0 -245
  43. package/src/capture/extractor.ts +0 -330
  44. package/src/capture/quality.test.ts +0 -168
  45. package/src/capture/quality.ts +0 -104
  46. package/src/capture/retrospective.test.ts +0 -115
  47. package/src/capture/retrospective.ts +0 -121
  48. package/src/capture/scanner.test.ts +0 -131
  49. package/src/capture/scanner.ts +0 -100
  50. package/src/capture/scrubber.test.ts +0 -144
  51. package/src/capture/scrubber.ts +0 -181
  52. package/src/cli.ts +0 -517
  53. package/src/config.ts +0 -238
  54. package/src/context/inject.test.ts +0 -940
  55. package/src/context/inject.ts +0 -382
  56. package/src/embeddings/backfill.ts +0 -50
  57. package/src/embeddings/embedder.test.ts +0 -76
  58. package/src/embeddings/embedder.ts +0 -139
  59. package/src/lifecycle/aging.test.ts +0 -103
  60. package/src/lifecycle/aging.ts +0 -36
  61. package/src/lifecycle/compaction.test.ts +0 -264
  62. package/src/lifecycle/compaction.ts +0 -190
  63. package/src/lifecycle/purge.test.ts +0 -100
  64. package/src/lifecycle/purge.ts +0 -37
  65. package/src/lifecycle/scheduler.test.ts +0 -120
  66. package/src/lifecycle/scheduler.ts +0 -101
  67. package/src/provisioning/browser-auth.ts +0 -172
  68. package/src/provisioning/provision.test.ts +0 -198
  69. package/src/provisioning/provision.ts +0 -94
  70. package/src/register.test.ts +0 -167
  71. package/src/register.ts +0 -178
  72. package/src/server.ts +0 -436
  73. package/src/storage/migrations.test.ts +0 -244
  74. package/src/storage/migrations.ts +0 -261
  75. package/src/storage/outbox.test.ts +0 -229
  76. package/src/storage/outbox.ts +0 -131
  77. package/src/storage/projects.test.ts +0 -137
  78. package/src/storage/projects.ts +0 -184
  79. package/src/storage/sqlite.test.ts +0 -798
  80. package/src/storage/sqlite.ts +0 -934
  81. package/src/storage/vec.test.ts +0 -198
  82. package/src/sync/auth.test.ts +0 -76
  83. package/src/sync/auth.ts +0 -68
  84. package/src/sync/client.ts +0 -183
  85. package/src/sync/engine.test.ts +0 -94
  86. package/src/sync/engine.ts +0 -127
  87. package/src/sync/pull.test.ts +0 -279
  88. package/src/sync/pull.ts +0 -170
  89. package/src/sync/push.test.ts +0 -117
  90. package/src/sync/push.ts +0 -230
  91. package/src/tools/get.ts +0 -34
  92. package/src/tools/pin.ts +0 -47
  93. package/src/tools/save.test.ts +0 -301
  94. package/src/tools/save.ts +0 -231
  95. package/src/tools/search.test.ts +0 -69
  96. package/src/tools/search.ts +0 -181
  97. package/src/tools/timeline.ts +0 -64
  98. package/tsconfig.json +0 -22
@@ -1,934 +0,0 @@
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
- }