engrm 0.1.0 → 0.2.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/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 +29 -4
  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
@@ -0,0 +1,2357 @@
1
+ #!/usr/bin/env node
2
+ import { createRequire } from "node:module";
3
+ var __require = /* @__PURE__ */ createRequire(import.meta.url);
4
+
5
+ // src/config.ts
6
+ import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
7
+ import { homedir, hostname } from "node:os";
8
+ import { join } from "node:path";
9
+ import { randomBytes } from "node:crypto";
10
+ var CONFIG_DIR = join(homedir(), ".engrm");
11
+ var SETTINGS_PATH = join(CONFIG_DIR, "settings.json");
12
+ var DB_PATH = join(CONFIG_DIR, "engrm.db");
13
+ function getDbPath() {
14
+ return DB_PATH;
15
+ }
16
+ function generateDeviceId() {
17
+ const host = hostname().toLowerCase().replace(/[^a-z0-9-]/g, "");
18
+ const suffix = randomBytes(4).toString("hex");
19
+ return `${host}-${suffix}`;
20
+ }
21
+ function createDefaultConfig() {
22
+ return {
23
+ candengo_url: "",
24
+ candengo_api_key: "",
25
+ site_id: "",
26
+ namespace: "",
27
+ user_id: "",
28
+ user_email: "",
29
+ device_id: generateDeviceId(),
30
+ teams: [],
31
+ sync: {
32
+ enabled: true,
33
+ interval_seconds: 30,
34
+ batch_size: 50
35
+ },
36
+ search: {
37
+ default_limit: 10,
38
+ local_boost: 1.2,
39
+ scope: "all"
40
+ },
41
+ scrubbing: {
42
+ enabled: true,
43
+ custom_patterns: [],
44
+ default_sensitivity: "shared"
45
+ },
46
+ sentinel: {
47
+ enabled: false,
48
+ mode: "advisory",
49
+ provider: "openai",
50
+ model: "gpt-4o-mini",
51
+ api_key: "",
52
+ base_url: "",
53
+ skip_patterns: [],
54
+ daily_limit: 100,
55
+ tier: "free"
56
+ },
57
+ observer: {
58
+ enabled: true,
59
+ mode: "per_event",
60
+ model: "haiku"
61
+ }
62
+ };
63
+ }
64
+ function loadConfig() {
65
+ if (!existsSync(SETTINGS_PATH)) {
66
+ throw new Error(`Config not found at ${SETTINGS_PATH}. Run 'engrm init --manual' to configure.`);
67
+ }
68
+ const raw = readFileSync(SETTINGS_PATH, "utf-8");
69
+ let parsed;
70
+ try {
71
+ parsed = JSON.parse(raw);
72
+ } catch {
73
+ throw new Error(`Invalid JSON in ${SETTINGS_PATH}`);
74
+ }
75
+ if (typeof parsed !== "object" || parsed === null) {
76
+ throw new Error(`Config at ${SETTINGS_PATH} is not a JSON object`);
77
+ }
78
+ const config = parsed;
79
+ const defaults = createDefaultConfig();
80
+ return {
81
+ candengo_url: asString(config["candengo_url"], defaults.candengo_url),
82
+ candengo_api_key: asString(config["candengo_api_key"], defaults.candengo_api_key),
83
+ site_id: asString(config["site_id"], defaults.site_id),
84
+ namespace: asString(config["namespace"], defaults.namespace),
85
+ user_id: asString(config["user_id"], defaults.user_id),
86
+ user_email: asString(config["user_email"], defaults.user_email),
87
+ device_id: asString(config["device_id"], defaults.device_id),
88
+ teams: asTeams(config["teams"], defaults.teams),
89
+ sync: {
90
+ enabled: asBool(config["sync"]?.["enabled"], defaults.sync.enabled),
91
+ interval_seconds: asNumber(config["sync"]?.["interval_seconds"], defaults.sync.interval_seconds),
92
+ batch_size: asNumber(config["sync"]?.["batch_size"], defaults.sync.batch_size)
93
+ },
94
+ search: {
95
+ default_limit: asNumber(config["search"]?.["default_limit"], defaults.search.default_limit),
96
+ local_boost: asNumber(config["search"]?.["local_boost"], defaults.search.local_boost),
97
+ scope: asScope(config["search"]?.["scope"], defaults.search.scope)
98
+ },
99
+ scrubbing: {
100
+ enabled: asBool(config["scrubbing"]?.["enabled"], defaults.scrubbing.enabled),
101
+ custom_patterns: asStringArray(config["scrubbing"]?.["custom_patterns"], defaults.scrubbing.custom_patterns),
102
+ default_sensitivity: asSensitivity(config["scrubbing"]?.["default_sensitivity"], defaults.scrubbing.default_sensitivity)
103
+ },
104
+ sentinel: {
105
+ enabled: asBool(config["sentinel"]?.["enabled"], defaults.sentinel.enabled),
106
+ mode: asSentinelMode(config["sentinel"]?.["mode"], defaults.sentinel.mode),
107
+ provider: asLlmProvider(config["sentinel"]?.["provider"], defaults.sentinel.provider),
108
+ model: asString(config["sentinel"]?.["model"], defaults.sentinel.model),
109
+ api_key: asString(config["sentinel"]?.["api_key"], defaults.sentinel.api_key),
110
+ base_url: asString(config["sentinel"]?.["base_url"], defaults.sentinel.base_url),
111
+ skip_patterns: asStringArray(config["sentinel"]?.["skip_patterns"], defaults.sentinel.skip_patterns),
112
+ daily_limit: asNumber(config["sentinel"]?.["daily_limit"], defaults.sentinel.daily_limit),
113
+ tier: asTier(config["sentinel"]?.["tier"], defaults.sentinel.tier)
114
+ },
115
+ observer: {
116
+ enabled: asBool(config["observer"]?.["enabled"], defaults.observer.enabled),
117
+ mode: asObserverMode(config["observer"]?.["mode"], defaults.observer.mode),
118
+ model: asString(config["observer"]?.["model"], defaults.observer.model)
119
+ }
120
+ };
121
+ }
122
+ function configExists() {
123
+ return existsSync(SETTINGS_PATH);
124
+ }
125
+ function asString(value, fallback) {
126
+ return typeof value === "string" ? value : fallback;
127
+ }
128
+ function asNumber(value, fallback) {
129
+ return typeof value === "number" && !Number.isNaN(value) ? value : fallback;
130
+ }
131
+ function asBool(value, fallback) {
132
+ return typeof value === "boolean" ? value : fallback;
133
+ }
134
+ function asStringArray(value, fallback) {
135
+ return Array.isArray(value) && value.every((v) => typeof v === "string") ? value : fallback;
136
+ }
137
+ function asScope(value, fallback) {
138
+ if (value === "personal" || value === "team" || value === "all")
139
+ return value;
140
+ return fallback;
141
+ }
142
+ function asSensitivity(value, fallback) {
143
+ if (value === "shared" || value === "personal" || value === "secret")
144
+ return value;
145
+ return fallback;
146
+ }
147
+ function asSentinelMode(value, fallback) {
148
+ if (value === "advisory" || value === "blocking")
149
+ return value;
150
+ return fallback;
151
+ }
152
+ function asLlmProvider(value, fallback) {
153
+ if (value === "openai" || value === "anthropic" || value === "ollama" || value === "custom")
154
+ return value;
155
+ return fallback;
156
+ }
157
+ function asTier(value, fallback) {
158
+ if (value === "free" || value === "vibe" || value === "solo" || value === "pro" || value === "team" || value === "enterprise")
159
+ return value;
160
+ return fallback;
161
+ }
162
+ function asObserverMode(value, fallback) {
163
+ if (value === "per_event" || value === "per_session")
164
+ return value;
165
+ return fallback;
166
+ }
167
+ function asTeams(value, fallback) {
168
+ if (!Array.isArray(value))
169
+ return fallback;
170
+ return value.filter((t) => typeof t === "object" && t !== null && typeof t.id === "string" && typeof t.name === "string" && typeof t.namespace === "string");
171
+ }
172
+
173
+ // src/storage/migrations.ts
174
+ var MIGRATIONS = [
175
+ {
176
+ version: 1,
177
+ description: "Initial schema: projects, observations, sessions, sync, FTS5",
178
+ sql: `
179
+ -- Projects (canonical identity across machines)
180
+ CREATE TABLE projects (
181
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
182
+ canonical_id TEXT UNIQUE NOT NULL,
183
+ name TEXT NOT NULL,
184
+ local_path TEXT,
185
+ remote_url TEXT,
186
+ first_seen_epoch INTEGER NOT NULL,
187
+ last_active_epoch INTEGER NOT NULL
188
+ );
189
+
190
+ -- Core observations table
191
+ CREATE TABLE observations (
192
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
193
+ session_id TEXT,
194
+ project_id INTEGER NOT NULL REFERENCES projects(id),
195
+ type TEXT NOT NULL CHECK (type IN (
196
+ 'bugfix', 'discovery', 'decision', 'pattern',
197
+ 'change', 'feature', 'refactor', 'digest'
198
+ )),
199
+ title TEXT NOT NULL,
200
+ narrative TEXT,
201
+ facts TEXT,
202
+ concepts TEXT,
203
+ files_read TEXT,
204
+ files_modified TEXT,
205
+ quality REAL DEFAULT 0.5 CHECK (quality BETWEEN 0.0 AND 1.0),
206
+ lifecycle TEXT DEFAULT 'active' CHECK (lifecycle IN (
207
+ 'active', 'aging', 'archived', 'purged', 'pinned'
208
+ )),
209
+ sensitivity TEXT DEFAULT 'shared' CHECK (sensitivity IN (
210
+ 'shared', 'personal', 'secret'
211
+ )),
212
+ user_id TEXT NOT NULL,
213
+ device_id TEXT NOT NULL,
214
+ agent TEXT DEFAULT 'claude-code',
215
+ created_at TEXT NOT NULL,
216
+ created_at_epoch INTEGER NOT NULL,
217
+ archived_at_epoch INTEGER,
218
+ compacted_into INTEGER REFERENCES observations(id) ON DELETE SET NULL
219
+ );
220
+
221
+ -- Session tracking
222
+ CREATE TABLE sessions (
223
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
224
+ session_id TEXT UNIQUE NOT NULL,
225
+ project_id INTEGER REFERENCES projects(id),
226
+ user_id TEXT NOT NULL,
227
+ device_id TEXT NOT NULL,
228
+ agent TEXT DEFAULT 'claude-code',
229
+ status TEXT DEFAULT 'active' CHECK (status IN ('active', 'completed')),
230
+ observation_count INTEGER DEFAULT 0,
231
+ started_at_epoch INTEGER,
232
+ completed_at_epoch INTEGER
233
+ );
234
+
235
+ -- Session summaries (generated on Stop hook)
236
+ CREATE TABLE session_summaries (
237
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
238
+ session_id TEXT UNIQUE NOT NULL,
239
+ project_id INTEGER REFERENCES projects(id),
240
+ user_id TEXT NOT NULL,
241
+ request TEXT,
242
+ investigated TEXT,
243
+ learned TEXT,
244
+ completed TEXT,
245
+ next_steps TEXT,
246
+ created_at_epoch INTEGER
247
+ );
248
+
249
+ -- Sync outbox (offline-first queue)
250
+ CREATE TABLE sync_outbox (
251
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
252
+ record_type TEXT NOT NULL CHECK (record_type IN ('observation', 'summary')),
253
+ record_id INTEGER NOT NULL,
254
+ status TEXT DEFAULT 'pending' CHECK (status IN (
255
+ 'pending', 'syncing', 'synced', 'failed'
256
+ )),
257
+ retry_count INTEGER DEFAULT 0,
258
+ max_retries INTEGER DEFAULT 10,
259
+ last_error TEXT,
260
+ created_at_epoch INTEGER NOT NULL,
261
+ synced_at_epoch INTEGER,
262
+ next_retry_epoch INTEGER
263
+ );
264
+
265
+ -- Sync high-water mark and lifecycle job tracking
266
+ CREATE TABLE sync_state (
267
+ key TEXT PRIMARY KEY,
268
+ value TEXT NOT NULL
269
+ );
270
+
271
+ -- FTS5 for local offline search (external content mode)
272
+ CREATE VIRTUAL TABLE observations_fts USING fts5(
273
+ title, narrative, facts, concepts,
274
+ content=observations,
275
+ content_rowid=id
276
+ );
277
+
278
+ -- Indexes: observations
279
+ CREATE INDEX idx_observations_project ON observations(project_id);
280
+ CREATE INDEX idx_observations_project_lifecycle ON observations(project_id, lifecycle);
281
+ CREATE INDEX idx_observations_type ON observations(type);
282
+ CREATE INDEX idx_observations_created ON observations(created_at_epoch);
283
+ CREATE INDEX idx_observations_session ON observations(session_id);
284
+ CREATE INDEX idx_observations_lifecycle ON observations(lifecycle);
285
+ CREATE INDEX idx_observations_quality ON observations(quality);
286
+ CREATE INDEX idx_observations_user ON observations(user_id);
287
+
288
+ -- Indexes: sessions
289
+ CREATE INDEX idx_sessions_project ON sessions(project_id);
290
+
291
+ -- Indexes: sync outbox
292
+ CREATE INDEX idx_outbox_status ON sync_outbox(status, next_retry_epoch);
293
+ CREATE INDEX idx_outbox_record ON sync_outbox(record_type, record_id);
294
+ `
295
+ },
296
+ {
297
+ version: 2,
298
+ description: "Add superseded_by for knowledge supersession",
299
+ sql: `
300
+ ALTER TABLE observations ADD COLUMN superseded_by INTEGER REFERENCES observations(id) ON DELETE SET NULL;
301
+ CREATE INDEX idx_observations_superseded ON observations(superseded_by);
302
+ `
303
+ },
304
+ {
305
+ version: 3,
306
+ description: "Add remote_source_id for pull deduplication",
307
+ sql: `
308
+ ALTER TABLE observations ADD COLUMN remote_source_id TEXT;
309
+ CREATE UNIQUE INDEX idx_observations_remote_source ON observations(remote_source_id) WHERE remote_source_id IS NOT NULL;
310
+ `
311
+ },
312
+ {
313
+ version: 4,
314
+ description: "Add sqlite-vec for local semantic search",
315
+ sql: `
316
+ CREATE VIRTUAL TABLE vec_observations USING vec0(
317
+ observation_id INTEGER PRIMARY KEY,
318
+ embedding float[384]
319
+ );
320
+ `,
321
+ condition: (db) => isVecExtensionLoaded(db)
322
+ },
323
+ {
324
+ version: 5,
325
+ description: "Session metrics and security findings",
326
+ sql: `
327
+ ALTER TABLE sessions ADD COLUMN files_touched_count INTEGER DEFAULT 0;
328
+ ALTER TABLE sessions ADD COLUMN searches_performed INTEGER DEFAULT 0;
329
+ ALTER TABLE sessions ADD COLUMN tool_calls_count INTEGER DEFAULT 0;
330
+
331
+ CREATE TABLE security_findings (
332
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
333
+ session_id TEXT,
334
+ project_id INTEGER NOT NULL REFERENCES projects(id),
335
+ finding_type TEXT NOT NULL,
336
+ severity TEXT NOT NULL DEFAULT 'medium' CHECK (severity IN ('critical', 'high', 'medium', 'low')),
337
+ pattern_name TEXT NOT NULL,
338
+ file_path TEXT,
339
+ snippet TEXT,
340
+ tool_name TEXT,
341
+ user_id TEXT NOT NULL,
342
+ device_id TEXT NOT NULL,
343
+ created_at_epoch INTEGER NOT NULL
344
+ );
345
+
346
+ CREATE INDEX idx_security_findings_session ON security_findings(session_id);
347
+ CREATE INDEX idx_security_findings_project ON security_findings(project_id, created_at_epoch);
348
+ CREATE INDEX idx_security_findings_severity ON security_findings(severity);
349
+ `
350
+ },
351
+ {
352
+ version: 6,
353
+ description: "Add risk_score, expand observation types to include standard",
354
+ sql: `
355
+ ALTER TABLE sessions ADD COLUMN risk_score INTEGER;
356
+
357
+ -- Recreate observations table with expanded type CHECK to include 'standard'
358
+ -- SQLite doesn't support ALTER CHECK, so we recreate the table
359
+ CREATE TABLE observations_new (
360
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
361
+ session_id TEXT,
362
+ project_id INTEGER NOT NULL REFERENCES projects(id),
363
+ type TEXT NOT NULL CHECK (type IN (
364
+ 'bugfix', 'discovery', 'decision', 'pattern',
365
+ 'change', 'feature', 'refactor', 'digest', 'standard'
366
+ )),
367
+ title TEXT NOT NULL,
368
+ narrative TEXT,
369
+ facts TEXT,
370
+ concepts TEXT,
371
+ files_read TEXT,
372
+ files_modified TEXT,
373
+ quality REAL DEFAULT 0.5 CHECK (quality BETWEEN 0.0 AND 1.0),
374
+ lifecycle TEXT DEFAULT 'active' CHECK (lifecycle IN (
375
+ 'active', 'aging', 'archived', 'purged', 'pinned'
376
+ )),
377
+ sensitivity TEXT DEFAULT 'shared' CHECK (sensitivity IN (
378
+ 'shared', 'personal', 'secret'
379
+ )),
380
+ user_id TEXT NOT NULL,
381
+ device_id TEXT NOT NULL,
382
+ agent TEXT DEFAULT 'claude-code',
383
+ created_at TEXT NOT NULL,
384
+ created_at_epoch INTEGER NOT NULL,
385
+ archived_at_epoch INTEGER,
386
+ compacted_into INTEGER REFERENCES observations(id) ON DELETE SET NULL,
387
+ superseded_by INTEGER REFERENCES observations(id) ON DELETE SET NULL,
388
+ remote_source_id TEXT
389
+ );
390
+
391
+ INSERT INTO observations_new SELECT * FROM observations;
392
+
393
+ DROP TABLE observations;
394
+ ALTER TABLE observations_new RENAME TO observations;
395
+
396
+ -- Recreate indexes
397
+ CREATE INDEX idx_observations_project ON observations(project_id);
398
+ CREATE INDEX idx_observations_project_lifecycle ON observations(project_id, lifecycle);
399
+ CREATE INDEX idx_observations_type ON observations(type);
400
+ CREATE INDEX idx_observations_created ON observations(created_at_epoch);
401
+ CREATE INDEX idx_observations_session ON observations(session_id);
402
+ CREATE INDEX idx_observations_lifecycle ON observations(lifecycle);
403
+ CREATE INDEX idx_observations_quality ON observations(quality);
404
+ CREATE INDEX idx_observations_user ON observations(user_id);
405
+ CREATE INDEX idx_observations_superseded ON observations(superseded_by);
406
+ CREATE UNIQUE INDEX idx_observations_remote_source ON observations(remote_source_id) WHERE remote_source_id IS NOT NULL;
407
+
408
+ -- Recreate FTS5 (external content mode — must rebuild after table recreation)
409
+ DROP TABLE IF EXISTS observations_fts;
410
+ CREATE VIRTUAL TABLE observations_fts USING fts5(
411
+ title, narrative, facts, concepts,
412
+ content=observations,
413
+ content_rowid=id
414
+ );
415
+ -- Rebuild FTS index
416
+ INSERT INTO observations_fts(observations_fts) VALUES('rebuild');
417
+ `
418
+ },
419
+ {
420
+ version: 7,
421
+ description: "Add packs_installed table for help pack tracking",
422
+ sql: `
423
+ CREATE TABLE IF NOT EXISTS packs_installed (
424
+ name TEXT PRIMARY KEY,
425
+ installed_at INTEGER NOT NULL,
426
+ observation_count INTEGER DEFAULT 0
427
+ );
428
+ `
429
+ }
430
+ ];
431
+ function isVecExtensionLoaded(db) {
432
+ try {
433
+ db.exec("SELECT vec_version()");
434
+ return true;
435
+ } catch {
436
+ return false;
437
+ }
438
+ }
439
+ function runMigrations(db) {
440
+ const currentVersion = db.query("PRAGMA user_version").get();
441
+ let version = currentVersion.user_version;
442
+ for (const migration of MIGRATIONS) {
443
+ if (migration.version <= version)
444
+ continue;
445
+ if (migration.condition && !migration.condition(db)) {
446
+ continue;
447
+ }
448
+ db.exec("BEGIN TRANSACTION");
449
+ try {
450
+ db.exec(migration.sql);
451
+ db.exec(`PRAGMA user_version = ${migration.version}`);
452
+ db.exec("COMMIT");
453
+ version = migration.version;
454
+ } catch (error) {
455
+ db.exec("ROLLBACK");
456
+ throw new Error(`Migration ${migration.version} (${migration.description}) failed: ${error instanceof Error ? error.message : String(error)}`);
457
+ }
458
+ }
459
+ }
460
+ var LATEST_SCHEMA_VERSION = MIGRATIONS.filter((m) => !m.condition).reduce((max, m) => Math.max(max, m.version), 0);
461
+
462
+ // src/storage/sqlite.ts
463
+ var IS_BUN = typeof globalThis.Bun !== "undefined";
464
+ function openDatabase(dbPath) {
465
+ if (IS_BUN) {
466
+ return openBunDatabase(dbPath);
467
+ }
468
+ return openNodeDatabase(dbPath);
469
+ }
470
+ function openBunDatabase(dbPath) {
471
+ const { Database } = __require("bun:sqlite");
472
+ if (process.platform === "darwin") {
473
+ const { existsSync: existsSync2 } = __require("node:fs");
474
+ const paths = [
475
+ "/opt/homebrew/opt/sqlite/lib/libsqlite3.dylib",
476
+ "/usr/local/opt/sqlite3/lib/libsqlite3.dylib"
477
+ ];
478
+ for (const p of paths) {
479
+ if (existsSync2(p)) {
480
+ try {
481
+ Database.setCustomSQLite(p);
482
+ } catch {}
483
+ break;
484
+ }
485
+ }
486
+ }
487
+ const db = new Database(dbPath);
488
+ return db;
489
+ }
490
+ function openNodeDatabase(dbPath) {
491
+ const BetterSqlite3 = __require("better-sqlite3");
492
+ const raw = new BetterSqlite3(dbPath);
493
+ return {
494
+ query(sql) {
495
+ const stmt = raw.prepare(sql);
496
+ return {
497
+ get(...params) {
498
+ return stmt.get(...params);
499
+ },
500
+ all(...params) {
501
+ return stmt.all(...params);
502
+ },
503
+ run(...params) {
504
+ return stmt.run(...params);
505
+ }
506
+ };
507
+ },
508
+ exec(sql) {
509
+ raw.exec(sql);
510
+ },
511
+ close() {
512
+ raw.close();
513
+ }
514
+ };
515
+ }
516
+
517
+ class MemDatabase {
518
+ db;
519
+ vecAvailable;
520
+ constructor(dbPath) {
521
+ this.db = openDatabase(dbPath);
522
+ this.db.exec("PRAGMA journal_mode = WAL");
523
+ this.db.exec("PRAGMA foreign_keys = ON");
524
+ this.vecAvailable = this.loadVecExtension();
525
+ runMigrations(this.db);
526
+ }
527
+ loadVecExtension() {
528
+ try {
529
+ const sqliteVec = __require("sqlite-vec");
530
+ sqliteVec.load(this.db);
531
+ return true;
532
+ } catch {
533
+ return false;
534
+ }
535
+ }
536
+ close() {
537
+ this.db.close();
538
+ }
539
+ upsertProject(project) {
540
+ const now = Math.floor(Date.now() / 1000);
541
+ const existing = this.db.query("SELECT * FROM projects WHERE canonical_id = ?").get(project.canonical_id);
542
+ if (existing) {
543
+ this.db.query(`UPDATE projects SET
544
+ local_path = COALESCE(?, local_path),
545
+ remote_url = COALESCE(?, remote_url),
546
+ last_active_epoch = ?
547
+ WHERE id = ?`).run(project.local_path ?? null, project.remote_url ?? null, now, existing.id);
548
+ return {
549
+ ...existing,
550
+ local_path: project.local_path ?? existing.local_path,
551
+ remote_url: project.remote_url ?? existing.remote_url,
552
+ last_active_epoch: now
553
+ };
554
+ }
555
+ const result = this.db.query(`INSERT INTO projects (canonical_id, name, local_path, remote_url, first_seen_epoch, last_active_epoch)
556
+ VALUES (?, ?, ?, ?, ?, ?)`).run(project.canonical_id, project.name, project.local_path ?? null, project.remote_url ?? null, now, now);
557
+ return this.db.query("SELECT * FROM projects WHERE id = ?").get(Number(result.lastInsertRowid));
558
+ }
559
+ getProjectByCanonicalId(canonicalId) {
560
+ return this.db.query("SELECT * FROM projects WHERE canonical_id = ?").get(canonicalId) ?? null;
561
+ }
562
+ getProjectById(id) {
563
+ return this.db.query("SELECT * FROM projects WHERE id = ?").get(id) ?? null;
564
+ }
565
+ insertObservation(obs) {
566
+ const now = Math.floor(Date.now() / 1000);
567
+ const createdAt = new Date().toISOString();
568
+ const result = this.db.query(`INSERT INTO observations (
569
+ session_id, project_id, type, title, narrative, facts, concepts,
570
+ files_read, files_modified, quality, lifecycle, sensitivity,
571
+ user_id, device_id, agent, created_at, created_at_epoch
572
+ ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`).run(obs.session_id ?? null, obs.project_id, obs.type, obs.title, obs.narrative ?? null, obs.facts ?? null, obs.concepts ?? null, obs.files_read ?? null, obs.files_modified ?? null, obs.quality, obs.lifecycle ?? "active", obs.sensitivity ?? "shared", obs.user_id, obs.device_id, obs.agent ?? "claude-code", createdAt, now);
573
+ const id = Number(result.lastInsertRowid);
574
+ const row = this.getObservationById(id);
575
+ this.ftsInsert(row);
576
+ if (obs.session_id) {
577
+ this.db.query("UPDATE sessions SET observation_count = observation_count + 1 WHERE session_id = ?").run(obs.session_id);
578
+ }
579
+ return row;
580
+ }
581
+ getObservationById(id) {
582
+ return this.db.query("SELECT * FROM observations WHERE id = ?").get(id) ?? null;
583
+ }
584
+ getObservationsByIds(ids) {
585
+ if (ids.length === 0)
586
+ return [];
587
+ const placeholders = ids.map(() => "?").join(",");
588
+ return this.db.query(`SELECT * FROM observations WHERE id IN (${placeholders}) ORDER BY created_at_epoch DESC`).all(...ids);
589
+ }
590
+ getRecentObservations(projectId, sincEpoch, limit = 50) {
591
+ return this.db.query(`SELECT * FROM observations
592
+ WHERE project_id = ? AND created_at_epoch > ?
593
+ ORDER BY created_at_epoch DESC
594
+ LIMIT ?`).all(projectId, sincEpoch, limit);
595
+ }
596
+ searchFts(query, projectId, lifecycles = ["active", "aging", "pinned"], limit = 20) {
597
+ const lifecyclePlaceholders = lifecycles.map(() => "?").join(",");
598
+ if (projectId !== null) {
599
+ return this.db.query(`SELECT o.id, observations_fts.rank
600
+ FROM observations_fts
601
+ JOIN observations o ON o.id = observations_fts.rowid
602
+ WHERE observations_fts MATCH ?
603
+ AND o.project_id = ?
604
+ AND o.lifecycle IN (${lifecyclePlaceholders})
605
+ ORDER BY observations_fts.rank
606
+ LIMIT ?`).all(query, projectId, ...lifecycles, limit);
607
+ }
608
+ return this.db.query(`SELECT o.id, observations_fts.rank
609
+ FROM observations_fts
610
+ JOIN observations o ON o.id = observations_fts.rowid
611
+ WHERE observations_fts MATCH ?
612
+ AND o.lifecycle IN (${lifecyclePlaceholders})
613
+ ORDER BY observations_fts.rank
614
+ LIMIT ?`).all(query, ...lifecycles, limit);
615
+ }
616
+ getTimeline(anchorId, projectId, depthBefore = 3, depthAfter = 3) {
617
+ const anchor = this.getObservationById(anchorId);
618
+ if (!anchor)
619
+ return [];
620
+ const projectFilter = projectId !== null ? "AND project_id = ?" : "";
621
+ const projectParams = projectId !== null ? [projectId] : [];
622
+ const before = this.db.query(`SELECT * FROM observations
623
+ WHERE created_at_epoch < ? ${projectFilter}
624
+ AND lifecycle IN ('active', 'aging', 'pinned')
625
+ ORDER BY created_at_epoch DESC
626
+ LIMIT ?`).all(anchor.created_at_epoch, ...projectParams, depthBefore);
627
+ const after = this.db.query(`SELECT * FROM observations
628
+ WHERE created_at_epoch > ? ${projectFilter}
629
+ AND lifecycle IN ('active', 'aging', 'pinned')
630
+ ORDER BY created_at_epoch ASC
631
+ LIMIT ?`).all(anchor.created_at_epoch, ...projectParams, depthAfter);
632
+ return [...before.reverse(), anchor, ...after];
633
+ }
634
+ pinObservation(id, pinned) {
635
+ const obs = this.getObservationById(id);
636
+ if (!obs)
637
+ return false;
638
+ if (pinned) {
639
+ if (obs.lifecycle !== "active" && obs.lifecycle !== "aging")
640
+ return false;
641
+ this.db.query("UPDATE observations SET lifecycle = 'pinned' WHERE id = ?").run(id);
642
+ } else {
643
+ if (obs.lifecycle !== "pinned")
644
+ return false;
645
+ this.db.query("UPDATE observations SET lifecycle = 'active' WHERE id = ?").run(id);
646
+ }
647
+ return true;
648
+ }
649
+ getActiveObservationCount(userId) {
650
+ if (userId) {
651
+ const result2 = this.db.query(`SELECT COUNT(*) as count FROM observations
652
+ WHERE lifecycle IN ('active', 'aging')
653
+ AND sensitivity != 'secret'
654
+ AND user_id = ?`).get(userId);
655
+ return result2?.count ?? 0;
656
+ }
657
+ const result = this.db.query(`SELECT COUNT(*) as count FROM observations
658
+ WHERE lifecycle IN ('active', 'aging')
659
+ AND sensitivity != 'secret'`).get();
660
+ return result?.count ?? 0;
661
+ }
662
+ supersedeObservation(oldId, newId) {
663
+ if (oldId === newId)
664
+ return false;
665
+ const replacement = this.getObservationById(newId);
666
+ if (!replacement)
667
+ return false;
668
+ let targetId = oldId;
669
+ const visited = new Set;
670
+ for (let depth = 0;depth < 10; depth++) {
671
+ const target2 = this.getObservationById(targetId);
672
+ if (!target2)
673
+ return false;
674
+ if (target2.superseded_by === null)
675
+ break;
676
+ if (target2.superseded_by === newId)
677
+ return true;
678
+ visited.add(targetId);
679
+ targetId = target2.superseded_by;
680
+ if (visited.has(targetId))
681
+ return false;
682
+ }
683
+ const target = this.getObservationById(targetId);
684
+ if (!target)
685
+ return false;
686
+ if (target.superseded_by !== null)
687
+ return false;
688
+ if (targetId === newId)
689
+ return false;
690
+ const now = Math.floor(Date.now() / 1000);
691
+ this.db.query(`UPDATE observations
692
+ SET superseded_by = ?, lifecycle = 'archived', archived_at_epoch = ?
693
+ WHERE id = ?`).run(newId, now, targetId);
694
+ this.ftsDelete(target);
695
+ this.vecDelete(targetId);
696
+ return true;
697
+ }
698
+ isSuperseded(id) {
699
+ const obs = this.getObservationById(id);
700
+ return obs !== null && obs.superseded_by !== null;
701
+ }
702
+ upsertSession(sessionId, projectId, userId, deviceId, agent = "claude-code") {
703
+ const existing = this.db.query("SELECT * FROM sessions WHERE session_id = ?").get(sessionId);
704
+ if (existing)
705
+ return existing;
706
+ const now = Math.floor(Date.now() / 1000);
707
+ this.db.query(`INSERT INTO sessions (session_id, project_id, user_id, device_id, agent, started_at_epoch)
708
+ VALUES (?, ?, ?, ?, ?, ?)`).run(sessionId, projectId, userId, deviceId, agent, now);
709
+ return this.db.query("SELECT * FROM sessions WHERE session_id = ?").get(sessionId);
710
+ }
711
+ completeSession(sessionId) {
712
+ const now = Math.floor(Date.now() / 1000);
713
+ this.db.query("UPDATE sessions SET status = 'completed', completed_at_epoch = ? WHERE session_id = ?").run(now, sessionId);
714
+ }
715
+ addToOutbox(recordType, recordId) {
716
+ const now = Math.floor(Date.now() / 1000);
717
+ this.db.query(`INSERT INTO sync_outbox (record_type, record_id, created_at_epoch)
718
+ VALUES (?, ?, ?)`).run(recordType, recordId, now);
719
+ }
720
+ getSyncState(key) {
721
+ const row = this.db.query("SELECT value FROM sync_state WHERE key = ?").get(key);
722
+ return row?.value ?? null;
723
+ }
724
+ setSyncState(key, value) {
725
+ this.db.query("INSERT INTO sync_state (key, value) VALUES (?, ?) ON CONFLICT(key) DO UPDATE SET value = ?").run(key, value, value);
726
+ }
727
+ ftsInsert(obs) {
728
+ this.db.query(`INSERT INTO observations_fts (rowid, title, narrative, facts, concepts)
729
+ VALUES (?, ?, ?, ?, ?)`).run(obs.id, obs.title, obs.narrative, obs.facts, obs.concepts);
730
+ }
731
+ ftsDelete(obs) {
732
+ this.db.query(`INSERT INTO observations_fts (observations_fts, rowid, title, narrative, facts, concepts)
733
+ VALUES ('delete', ?, ?, ?, ?, ?)`).run(obs.id, obs.title, obs.narrative, obs.facts, obs.concepts);
734
+ }
735
+ vecInsert(observationId, embedding) {
736
+ if (!this.vecAvailable)
737
+ return;
738
+ this.db.query("INSERT OR REPLACE INTO vec_observations (observation_id, embedding) VALUES (?, ?)").run(observationId, new Uint8Array(embedding.buffer));
739
+ }
740
+ vecDelete(observationId) {
741
+ if (!this.vecAvailable)
742
+ return;
743
+ this.db.query("DELETE FROM vec_observations WHERE observation_id = ?").run(observationId);
744
+ }
745
+ searchVec(queryEmbedding, projectId, lifecycles = ["active", "aging", "pinned"], limit = 20) {
746
+ if (!this.vecAvailable)
747
+ return [];
748
+ const lifecyclePlaceholders = lifecycles.map(() => "?").join(",");
749
+ const embeddingBlob = new Uint8Array(queryEmbedding.buffer);
750
+ if (projectId !== null) {
751
+ return this.db.query(`SELECT v.observation_id, v.distance
752
+ FROM vec_observations v
753
+ JOIN observations o ON o.id = v.observation_id
754
+ WHERE v.embedding MATCH ?
755
+ AND k = ?
756
+ AND o.project_id = ?
757
+ AND o.lifecycle IN (${lifecyclePlaceholders})
758
+ AND o.superseded_by IS NULL`).all(embeddingBlob, limit, projectId, ...lifecycles);
759
+ }
760
+ return this.db.query(`SELECT v.observation_id, v.distance
761
+ FROM vec_observations v
762
+ JOIN observations o ON o.id = v.observation_id
763
+ WHERE v.embedding MATCH ?
764
+ AND k = ?
765
+ AND o.lifecycle IN (${lifecyclePlaceholders})
766
+ AND o.superseded_by IS NULL`).all(embeddingBlob, limit, ...lifecycles);
767
+ }
768
+ getUnembeddedCount() {
769
+ if (!this.vecAvailable)
770
+ return 0;
771
+ const result = this.db.query(`SELECT COUNT(*) as count FROM observations o
772
+ WHERE o.lifecycle IN ('active', 'aging', 'pinned')
773
+ AND o.superseded_by IS NULL
774
+ AND NOT EXISTS (
775
+ SELECT 1 FROM vec_observations v WHERE v.observation_id = o.id
776
+ )`).get();
777
+ return result?.count ?? 0;
778
+ }
779
+ getUnembeddedObservations(limit = 100) {
780
+ if (!this.vecAvailable)
781
+ return [];
782
+ return this.db.query(`SELECT o.* FROM observations o
783
+ WHERE o.lifecycle IN ('active', 'aging', 'pinned')
784
+ AND o.superseded_by IS NULL
785
+ AND NOT EXISTS (
786
+ SELECT 1 FROM vec_observations v WHERE v.observation_id = o.id
787
+ )
788
+ ORDER BY o.created_at_epoch DESC
789
+ LIMIT ?`).all(limit);
790
+ }
791
+ insertSessionSummary(summary) {
792
+ const now = Math.floor(Date.now() / 1000);
793
+ const result = this.db.query(`INSERT INTO session_summaries (session_id, project_id, user_id, request, investigated, learned, completed, next_steps, created_at_epoch)
794
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`).run(summary.session_id, summary.project_id, summary.user_id, summary.request, summary.investigated, summary.learned, summary.completed, summary.next_steps, now);
795
+ const id = Number(result.lastInsertRowid);
796
+ return this.db.query("SELECT * FROM session_summaries WHERE id = ?").get(id);
797
+ }
798
+ getSessionSummary(sessionId) {
799
+ return this.db.query("SELECT * FROM session_summaries WHERE session_id = ?").get(sessionId) ?? null;
800
+ }
801
+ getRecentSummaries(projectId, limit = 5) {
802
+ return this.db.query(`SELECT * FROM session_summaries
803
+ WHERE project_id = ?
804
+ ORDER BY created_at_epoch DESC, id DESC
805
+ LIMIT ?`).all(projectId, limit);
806
+ }
807
+ incrementSessionMetrics(sessionId, increments) {
808
+ const sets = [];
809
+ const params = [];
810
+ if (increments.files) {
811
+ sets.push("files_touched_count = files_touched_count + ?");
812
+ params.push(increments.files);
813
+ }
814
+ if (increments.searches) {
815
+ sets.push("searches_performed = searches_performed + ?");
816
+ params.push(increments.searches);
817
+ }
818
+ if (increments.toolCalls) {
819
+ sets.push("tool_calls_count = tool_calls_count + ?");
820
+ params.push(increments.toolCalls);
821
+ }
822
+ if (sets.length === 0)
823
+ return;
824
+ params.push(sessionId);
825
+ this.db.query(`UPDATE sessions SET ${sets.join(", ")} WHERE session_id = ?`).run(...params);
826
+ }
827
+ getSessionMetrics(sessionId) {
828
+ return this.db.query("SELECT * FROM sessions WHERE session_id = ?").get(sessionId) ?? null;
829
+ }
830
+ insertSecurityFinding(finding) {
831
+ const now = Math.floor(Date.now() / 1000);
832
+ const result = this.db.query(`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)
833
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`).run(finding.session_id ?? null, finding.project_id, finding.finding_type, finding.severity, finding.pattern_name, finding.file_path ?? null, finding.snippet ?? null, finding.tool_name ?? null, finding.user_id, finding.device_id, now);
834
+ const id = Number(result.lastInsertRowid);
835
+ return this.db.query("SELECT * FROM security_findings WHERE id = ?").get(id);
836
+ }
837
+ getSecurityFindings(projectId, options = {}) {
838
+ const limit = options.limit ?? 50;
839
+ if (options.severity) {
840
+ return this.db.query(`SELECT * FROM security_findings
841
+ WHERE project_id = ? AND severity = ?
842
+ ORDER BY created_at_epoch DESC
843
+ LIMIT ?`).all(projectId, options.severity, limit);
844
+ }
845
+ return this.db.query(`SELECT * FROM security_findings
846
+ WHERE project_id = ?
847
+ ORDER BY created_at_epoch DESC
848
+ LIMIT ?`).all(projectId, limit);
849
+ }
850
+ getSecurityFindingsCount(projectId) {
851
+ const rows = this.db.query(`SELECT severity, COUNT(*) as count FROM security_findings
852
+ WHERE project_id = ?
853
+ GROUP BY severity`).all(projectId);
854
+ const counts = {
855
+ critical: 0,
856
+ high: 0,
857
+ medium: 0,
858
+ low: 0
859
+ };
860
+ for (const row of rows) {
861
+ counts[row.severity] = row.count;
862
+ }
863
+ return counts;
864
+ }
865
+ setSessionRiskScore(sessionId, score) {
866
+ this.db.query("UPDATE sessions SET risk_score = ? WHERE session_id = ?").run(score, sessionId);
867
+ }
868
+ getObservationsBySession(sessionId) {
869
+ return this.db.query(`SELECT * FROM observations WHERE session_id = ? ORDER BY created_at_epoch ASC`).all(sessionId);
870
+ }
871
+ getInstalledPacks() {
872
+ try {
873
+ const rows = this.db.query("SELECT name FROM packs_installed").all();
874
+ return rows.map((r) => r.name);
875
+ } catch {
876
+ return [];
877
+ }
878
+ }
879
+ markPackInstalled(name, observationCount) {
880
+ const now = Math.floor(Date.now() / 1000);
881
+ this.db.query("INSERT OR REPLACE INTO packs_installed (name, installed_at, observation_count) VALUES (?, ?, ?)").run(name, now, observationCount);
882
+ }
883
+ }
884
+
885
+ // src/capture/extractor.ts
886
+ var SKIP_TOOLS = new Set([
887
+ "Glob",
888
+ "Grep",
889
+ "Read",
890
+ "WebSearch",
891
+ "WebFetch",
892
+ "Agent"
893
+ ]);
894
+ var SKIP_BASH_PATTERNS = [
895
+ /^\s*(ls|pwd|cd|echo|cat|head|tail|wc|which|whoami|date|uname)\b/,
896
+ /^\s*git\s+(status|log|branch|diff|show|remote)\b/,
897
+ /^\s*(node|bun|npm|npx|yarn|pnpm)\s+--?version\b/,
898
+ /^\s*export\s+/,
899
+ /^\s*#/
900
+ ];
901
+ var TRIVIAL_RESPONSE_PATTERNS = [
902
+ /^$/,
903
+ /^\s*$/,
904
+ /^Already up to date\.$/
905
+ ];
906
+ function extractObservation(event) {
907
+ const { tool_name, tool_input, tool_response } = event;
908
+ if (SKIP_TOOLS.has(tool_name)) {
909
+ return null;
910
+ }
911
+ switch (tool_name) {
912
+ case "Edit":
913
+ return extractFromEdit(tool_input, tool_response);
914
+ case "Write":
915
+ return extractFromWrite(tool_input, tool_response);
916
+ case "Bash":
917
+ return extractFromBash(tool_input, tool_response);
918
+ default:
919
+ if (tool_name.startsWith("mcp__")) {
920
+ return extractFromMcpTool(tool_name, tool_input, tool_response);
921
+ }
922
+ return null;
923
+ }
924
+ }
925
+ function extractFromEdit(input, response) {
926
+ const filePath = input["file_path"];
927
+ if (!filePath)
928
+ return null;
929
+ const oldStr = input["old_string"];
930
+ const newStr = input["new_string"];
931
+ if (!oldStr && !newStr)
932
+ return null;
933
+ if (oldStr && newStr) {
934
+ const oldTrimmed = oldStr.trim();
935
+ const newTrimmed = newStr.trim();
936
+ if (oldTrimmed === newTrimmed)
937
+ return null;
938
+ if (Math.abs(oldTrimmed.length - newTrimmed.length) < 3 && oldTrimmed.length < 20) {
939
+ return null;
940
+ }
941
+ }
942
+ const fileName = filePath.split("/").pop() ?? filePath;
943
+ const changeSize = (newStr?.length ?? 0) - (oldStr?.length ?? 0);
944
+ const verb = changeSize > 50 ? "Extended" : changeSize < -50 ? "Reduced" : "Modified";
945
+ return {
946
+ type: "change",
947
+ title: `${verb} ${fileName}`,
948
+ narrative: buildEditNarrative(oldStr, newStr, filePath),
949
+ files_modified: [filePath]
950
+ };
951
+ }
952
+ function extractFromWrite(input, response) {
953
+ const filePath = input["file_path"];
954
+ if (!filePath)
955
+ return null;
956
+ const content = input["content"];
957
+ const fileName = filePath.split("/").pop() ?? filePath;
958
+ if (content === undefined || content.length < 50)
959
+ return null;
960
+ return {
961
+ type: "change",
962
+ title: `Created ${fileName}`,
963
+ narrative: `New file created: ${filePath}`,
964
+ files_modified: [filePath]
965
+ };
966
+ }
967
+ function extractFromBash(input, response) {
968
+ const command = input["command"];
969
+ if (!command)
970
+ return null;
971
+ for (const pattern of SKIP_BASH_PATTERNS) {
972
+ if (pattern.test(command))
973
+ return null;
974
+ }
975
+ for (const pattern of TRIVIAL_RESPONSE_PATTERNS) {
976
+ if (pattern.test(response.trim()))
977
+ return null;
978
+ }
979
+ const hasError = detectError(response);
980
+ const isTestRun = detectTestRun(command);
981
+ if (isTestRun) {
982
+ return extractTestResult(command, response);
983
+ }
984
+ if (hasError) {
985
+ return {
986
+ type: "bugfix",
987
+ title: summariseCommand(command) + " (error)",
988
+ narrative: `Command: ${truncate(command, 200)}
989
+ Error: ${truncate(response, 500)}`
990
+ };
991
+ }
992
+ if (/\b(npm|bun|yarn|pnpm)\s+(install|add|remove|uninstall)\b/.test(command)) {
993
+ return {
994
+ type: "change",
995
+ title: `Dependency change: ${summariseCommand(command)}`,
996
+ narrative: `Command: ${truncate(command, 200)}
997
+ Output: ${truncate(response, 300)}`
998
+ };
999
+ }
1000
+ if (/\b(npm|bun|yarn)\s+(run\s+)?(build|compile|bundle)\b/.test(command)) {
1001
+ if (hasError) {
1002
+ return {
1003
+ type: "bugfix",
1004
+ title: `Build failure: ${summariseCommand(command)}`,
1005
+ narrative: `Build command failed.
1006
+ Command: ${truncate(command, 200)}
1007
+ Output: ${truncate(response, 500)}`
1008
+ };
1009
+ }
1010
+ return null;
1011
+ }
1012
+ if (response.length > 200) {
1013
+ return {
1014
+ type: "change",
1015
+ title: summariseCommand(command),
1016
+ narrative: `Command: ${truncate(command, 200)}
1017
+ Output: ${truncate(response, 300)}`
1018
+ };
1019
+ }
1020
+ return null;
1021
+ }
1022
+ function extractFromMcpTool(toolName, input, response) {
1023
+ if (toolName.startsWith("mcp__engrm__"))
1024
+ return null;
1025
+ if (response.length < 100)
1026
+ return null;
1027
+ const parts = toolName.split("__");
1028
+ const serverName = parts[1] ?? "unknown";
1029
+ const toolAction = parts[2] ?? "unknown";
1030
+ return {
1031
+ type: "change",
1032
+ title: `${serverName}: ${toolAction}`,
1033
+ narrative: `MCP tool ${toolName} called.
1034
+ Response: ${truncate(response, 300)}`
1035
+ };
1036
+ }
1037
+ function detectError(response) {
1038
+ const lower = response.toLowerCase();
1039
+ return lower.includes("error:") || lower.includes("error[") || lower.includes("failed") || lower.includes("exception") || lower.includes("traceback") || lower.includes("panic:") || lower.includes("fatal:") || /exit code [1-9]/.test(lower);
1040
+ }
1041
+ function detectTestRun(command) {
1042
+ return /\b(test|spec|jest|vitest|mocha|pytest|cargo\s+test|go\s+test|bun\s+test)\b/i.test(command);
1043
+ }
1044
+ function extractTestResult(command, response) {
1045
+ const hasFailure = /[1-9]\d*\s+(fail|failed|failures?)\b/i.test(response) || /\bFAILED\b/.test(response) || /\berror\b/i.test(response);
1046
+ const hasPass = /\d+\s+(pass|passed|ok)\b/i.test(response) || /\bPASS\b/.test(response);
1047
+ if (hasFailure) {
1048
+ return {
1049
+ type: "bugfix",
1050
+ title: `Test failure: ${summariseCommand(command)}`,
1051
+ narrative: `Test run failed.
1052
+ Command: ${truncate(command, 200)}
1053
+ Output: ${truncate(response, 500)}`
1054
+ };
1055
+ }
1056
+ if (hasPass && !hasFailure) {
1057
+ return null;
1058
+ }
1059
+ return null;
1060
+ }
1061
+ function buildEditNarrative(oldStr, newStr, filePath) {
1062
+ const parts = [`File: ${filePath}`];
1063
+ if (oldStr && newStr) {
1064
+ const oldLines = oldStr.split(`
1065
+ `).length;
1066
+ const newLines = newStr.split(`
1067
+ `).length;
1068
+ if (oldLines !== newLines) {
1069
+ parts.push(`Lines: ${oldLines} → ${newLines}`);
1070
+ }
1071
+ parts.push(`Replaced: ${truncate(oldStr, 100)}`);
1072
+ parts.push(`With: ${truncate(newStr, 100)}`);
1073
+ } else if (newStr) {
1074
+ parts.push(`Added: ${truncate(newStr, 150)}`);
1075
+ }
1076
+ return parts.join(`
1077
+ `);
1078
+ }
1079
+ function summariseCommand(command) {
1080
+ const trimmed = command.trim();
1081
+ const firstLine = trimmed.split(`
1082
+ `)[0] ?? trimmed;
1083
+ return truncate(firstLine, 80);
1084
+ }
1085
+ function truncate(text, maxLen) {
1086
+ if (text.length <= maxLen)
1087
+ return text;
1088
+ return text.slice(0, maxLen - 3) + "...";
1089
+ }
1090
+
1091
+ // src/tools/save.ts
1092
+ import { relative, isAbsolute } from "node:path";
1093
+
1094
+ // src/capture/scrubber.ts
1095
+ var DEFAULT_PATTERNS = [
1096
+ {
1097
+ source: "sk-[a-zA-Z0-9]{20,}",
1098
+ flags: "g",
1099
+ replacement: "[REDACTED_API_KEY]",
1100
+ description: "OpenAI API keys",
1101
+ category: "api_key",
1102
+ severity: "critical"
1103
+ },
1104
+ {
1105
+ source: "Bearer [a-zA-Z0-9\\-._~+/]+=*",
1106
+ flags: "g",
1107
+ replacement: "[REDACTED_BEARER]",
1108
+ description: "Bearer auth tokens",
1109
+ category: "token",
1110
+ severity: "medium"
1111
+ },
1112
+ {
1113
+ source: "password[=:]\\s*\\S+",
1114
+ flags: "gi",
1115
+ replacement: "password=[REDACTED]",
1116
+ description: "Passwords in config",
1117
+ category: "password",
1118
+ severity: "high"
1119
+ },
1120
+ {
1121
+ source: "postgresql://[^\\s]+",
1122
+ flags: "g",
1123
+ replacement: "[REDACTED_DB_URL]",
1124
+ description: "PostgreSQL connection strings",
1125
+ category: "db_url",
1126
+ severity: "high"
1127
+ },
1128
+ {
1129
+ source: "mongodb://[^\\s]+",
1130
+ flags: "g",
1131
+ replacement: "[REDACTED_DB_URL]",
1132
+ description: "MongoDB connection strings",
1133
+ category: "db_url",
1134
+ severity: "high"
1135
+ },
1136
+ {
1137
+ source: "mysql://[^\\s]+",
1138
+ flags: "g",
1139
+ replacement: "[REDACTED_DB_URL]",
1140
+ description: "MySQL connection strings",
1141
+ category: "db_url",
1142
+ severity: "high"
1143
+ },
1144
+ {
1145
+ source: "AKIA[A-Z0-9]{16}",
1146
+ flags: "g",
1147
+ replacement: "[REDACTED_AWS_KEY]",
1148
+ description: "AWS access keys",
1149
+ category: "api_key",
1150
+ severity: "critical"
1151
+ },
1152
+ {
1153
+ source: "ghp_[a-zA-Z0-9]{36}",
1154
+ flags: "g",
1155
+ replacement: "[REDACTED_GH_TOKEN]",
1156
+ description: "GitHub personal access tokens",
1157
+ category: "token",
1158
+ severity: "high"
1159
+ },
1160
+ {
1161
+ source: "gho_[a-zA-Z0-9]{36}",
1162
+ flags: "g",
1163
+ replacement: "[REDACTED_GH_TOKEN]",
1164
+ description: "GitHub OAuth tokens",
1165
+ category: "token",
1166
+ severity: "high"
1167
+ },
1168
+ {
1169
+ source: "github_pat_[a-zA-Z0-9_]{22,}",
1170
+ flags: "g",
1171
+ replacement: "[REDACTED_GH_TOKEN]",
1172
+ description: "GitHub fine-grained PATs",
1173
+ category: "token",
1174
+ severity: "high"
1175
+ },
1176
+ {
1177
+ source: "cvk_[a-f0-9]{64}",
1178
+ flags: "g",
1179
+ replacement: "[REDACTED_CANDENGO_KEY]",
1180
+ description: "Candengo API keys",
1181
+ category: "api_key",
1182
+ severity: "critical"
1183
+ },
1184
+ {
1185
+ source: "xox[bpras]-[a-zA-Z0-9\\-]+",
1186
+ flags: "g",
1187
+ replacement: "[REDACTED_SLACK_TOKEN]",
1188
+ description: "Slack tokens",
1189
+ category: "token",
1190
+ severity: "high"
1191
+ }
1192
+ ];
1193
+ function compileCustomPatterns(patterns) {
1194
+ const compiled = [];
1195
+ for (const pattern of patterns) {
1196
+ try {
1197
+ new RegExp(pattern);
1198
+ compiled.push({
1199
+ source: pattern,
1200
+ flags: "g",
1201
+ replacement: "[REDACTED_CUSTOM]",
1202
+ description: `Custom pattern: ${pattern}`,
1203
+ category: "custom",
1204
+ severity: "medium"
1205
+ });
1206
+ } catch {}
1207
+ }
1208
+ return compiled;
1209
+ }
1210
+ function scrubSecrets(text, customPatterns = []) {
1211
+ let result = text;
1212
+ const allPatterns = [...DEFAULT_PATTERNS, ...compileCustomPatterns(customPatterns)];
1213
+ for (const pattern of allPatterns) {
1214
+ result = result.replace(new RegExp(pattern.source, pattern.flags), pattern.replacement);
1215
+ }
1216
+ return result;
1217
+ }
1218
+ function containsSecrets(text, customPatterns = []) {
1219
+ const allPatterns = [...DEFAULT_PATTERNS, ...compileCustomPatterns(customPatterns)];
1220
+ for (const pattern of allPatterns) {
1221
+ if (new RegExp(pattern.source, pattern.flags).test(text))
1222
+ return true;
1223
+ }
1224
+ return false;
1225
+ }
1226
+
1227
+ // src/capture/quality.ts
1228
+ var QUALITY_THRESHOLD = 0.1;
1229
+ function scoreQuality(input) {
1230
+ let score = 0;
1231
+ switch (input.type) {
1232
+ case "bugfix":
1233
+ score += 0.3;
1234
+ break;
1235
+ case "decision":
1236
+ score += 0.3;
1237
+ break;
1238
+ case "discovery":
1239
+ score += 0.2;
1240
+ break;
1241
+ case "pattern":
1242
+ score += 0.2;
1243
+ break;
1244
+ case "feature":
1245
+ score += 0.15;
1246
+ break;
1247
+ case "refactor":
1248
+ score += 0.15;
1249
+ break;
1250
+ case "change":
1251
+ score += 0.05;
1252
+ break;
1253
+ case "digest":
1254
+ score += 0.3;
1255
+ break;
1256
+ }
1257
+ if (input.narrative && input.narrative.length > 50) {
1258
+ score += 0.15;
1259
+ }
1260
+ if (input.facts) {
1261
+ try {
1262
+ const factsArray = JSON.parse(input.facts);
1263
+ if (factsArray.length >= 2)
1264
+ score += 0.15;
1265
+ else if (factsArray.length === 1)
1266
+ score += 0.05;
1267
+ } catch {
1268
+ if (input.facts.length > 20)
1269
+ score += 0.05;
1270
+ }
1271
+ }
1272
+ if (input.concepts) {
1273
+ try {
1274
+ const conceptsArray = JSON.parse(input.concepts);
1275
+ if (conceptsArray.length >= 1)
1276
+ score += 0.1;
1277
+ } catch {
1278
+ if (input.concepts.length > 10)
1279
+ score += 0.05;
1280
+ }
1281
+ }
1282
+ const modifiedCount = input.filesModified?.length ?? 0;
1283
+ if (modifiedCount >= 3)
1284
+ score += 0.2;
1285
+ else if (modifiedCount >= 1)
1286
+ score += 0.1;
1287
+ if (input.isDuplicate) {
1288
+ score -= 0.3;
1289
+ }
1290
+ return Math.max(0, Math.min(1, score));
1291
+ }
1292
+ function meetsQualityThreshold(input) {
1293
+ return scoreQuality(input) >= QUALITY_THRESHOLD;
1294
+ }
1295
+
1296
+ // src/capture/dedup.ts
1297
+ function tokenise(text) {
1298
+ const cleaned = text.toLowerCase().replace(/[^a-z0-9\s]/g, " ").trim();
1299
+ const tokens = cleaned.split(/\s+/).filter((t) => t.length > 0);
1300
+ return new Set(tokens);
1301
+ }
1302
+ function jaccardSimilarity(a, b) {
1303
+ const tokensA = tokenise(a);
1304
+ const tokensB = tokenise(b);
1305
+ if (tokensA.size === 0 && tokensB.size === 0)
1306
+ return 1;
1307
+ if (tokensA.size === 0 || tokensB.size === 0)
1308
+ return 0;
1309
+ let intersectionSize = 0;
1310
+ for (const token of tokensA) {
1311
+ if (tokensB.has(token))
1312
+ intersectionSize++;
1313
+ }
1314
+ const unionSize = tokensA.size + tokensB.size - intersectionSize;
1315
+ if (unionSize === 0)
1316
+ return 0;
1317
+ return intersectionSize / unionSize;
1318
+ }
1319
+ var DEDUP_THRESHOLD = 0.8;
1320
+ function findDuplicate(newTitle, candidates) {
1321
+ let bestMatch = null;
1322
+ let bestScore = 0;
1323
+ for (const candidate of candidates) {
1324
+ const similarity = jaccardSimilarity(newTitle, candidate.title);
1325
+ if (similarity > DEDUP_THRESHOLD && similarity > bestScore) {
1326
+ bestScore = similarity;
1327
+ bestMatch = candidate;
1328
+ }
1329
+ }
1330
+ return bestMatch;
1331
+ }
1332
+
1333
+ // src/storage/projects.ts
1334
+ import { execSync } from "node:child_process";
1335
+ import { existsSync as existsSync2, readFileSync as readFileSync2 } from "node:fs";
1336
+ import { basename, join as join2 } from "node:path";
1337
+ function normaliseGitRemoteUrl(remoteUrl) {
1338
+ let url = remoteUrl.trim();
1339
+ url = url.replace(/^(?:https?|ssh|git):\/\//, "");
1340
+ url = url.replace(/^[^@]+@/, "");
1341
+ url = url.replace(/^([^/:]+):(?!\d)/, "$1/");
1342
+ url = url.replace(/\.git$/, "");
1343
+ url = url.replace(/\/+$/, "");
1344
+ const slashIndex = url.indexOf("/");
1345
+ if (slashIndex !== -1) {
1346
+ const host = url.substring(0, slashIndex).toLowerCase();
1347
+ const path = url.substring(slashIndex);
1348
+ url = host + path;
1349
+ } else {
1350
+ url = url.toLowerCase();
1351
+ }
1352
+ return url;
1353
+ }
1354
+ function projectNameFromCanonicalId(canonicalId) {
1355
+ const parts = canonicalId.split("/");
1356
+ return parts[parts.length - 1] ?? canonicalId;
1357
+ }
1358
+ function getGitRemoteUrl(directory) {
1359
+ try {
1360
+ const url = execSync("git remote get-url origin", {
1361
+ cwd: directory,
1362
+ encoding: "utf-8",
1363
+ timeout: 5000,
1364
+ stdio: ["pipe", "pipe", "pipe"]
1365
+ }).trim();
1366
+ return url || null;
1367
+ } catch {
1368
+ try {
1369
+ const remotes = execSync("git remote", {
1370
+ cwd: directory,
1371
+ encoding: "utf-8",
1372
+ timeout: 5000,
1373
+ stdio: ["pipe", "pipe", "pipe"]
1374
+ }).trim().split(`
1375
+ `).filter(Boolean);
1376
+ if (remotes.length === 0)
1377
+ return null;
1378
+ const url = execSync(`git remote get-url ${remotes[0]}`, {
1379
+ cwd: directory,
1380
+ encoding: "utf-8",
1381
+ timeout: 5000,
1382
+ stdio: ["pipe", "pipe", "pipe"]
1383
+ }).trim();
1384
+ return url || null;
1385
+ } catch {
1386
+ return null;
1387
+ }
1388
+ }
1389
+ }
1390
+ function readProjectConfigFile(directory) {
1391
+ const configPath = join2(directory, ".engrm.json");
1392
+ if (!existsSync2(configPath))
1393
+ return null;
1394
+ try {
1395
+ const raw = readFileSync2(configPath, "utf-8");
1396
+ const parsed = JSON.parse(raw);
1397
+ if (typeof parsed["project_id"] !== "string" || !parsed["project_id"]) {
1398
+ return null;
1399
+ }
1400
+ return {
1401
+ project_id: parsed["project_id"],
1402
+ name: typeof parsed["name"] === "string" ? parsed["name"] : undefined
1403
+ };
1404
+ } catch {
1405
+ return null;
1406
+ }
1407
+ }
1408
+ function detectProject(directory) {
1409
+ const remoteUrl = getGitRemoteUrl(directory);
1410
+ if (remoteUrl) {
1411
+ const canonicalId = normaliseGitRemoteUrl(remoteUrl);
1412
+ return {
1413
+ canonical_id: canonicalId,
1414
+ name: projectNameFromCanonicalId(canonicalId),
1415
+ remote_url: remoteUrl,
1416
+ local_path: directory
1417
+ };
1418
+ }
1419
+ const configFile = readProjectConfigFile(directory);
1420
+ if (configFile) {
1421
+ return {
1422
+ canonical_id: configFile.project_id,
1423
+ name: configFile.name ?? projectNameFromCanonicalId(configFile.project_id),
1424
+ remote_url: null,
1425
+ local_path: directory
1426
+ };
1427
+ }
1428
+ const dirName = basename(directory);
1429
+ return {
1430
+ canonical_id: `local/${dirName}`,
1431
+ name: dirName,
1432
+ remote_url: null,
1433
+ local_path: directory
1434
+ };
1435
+ }
1436
+
1437
+ // src/embeddings/embedder.ts
1438
+ var _available = null;
1439
+ var _pipeline = null;
1440
+ var MODEL_NAME = "Xenova/all-MiniLM-L6-v2";
1441
+ async function embedText(text) {
1442
+ const pipe = await getPipeline();
1443
+ if (!pipe)
1444
+ return null;
1445
+ try {
1446
+ const output = await pipe(text, { pooling: "mean", normalize: true });
1447
+ return new Float32Array(output.data);
1448
+ } catch {
1449
+ return null;
1450
+ }
1451
+ }
1452
+ function composeEmbeddingText(obs) {
1453
+ const parts = [obs.title];
1454
+ if (obs.narrative)
1455
+ parts.push(obs.narrative);
1456
+ if (obs.facts) {
1457
+ try {
1458
+ const facts = JSON.parse(obs.facts);
1459
+ if (Array.isArray(facts) && facts.length > 0) {
1460
+ parts.push(facts.map((f) => `- ${f}`).join(`
1461
+ `));
1462
+ }
1463
+ } catch {
1464
+ parts.push(obs.facts);
1465
+ }
1466
+ }
1467
+ if (obs.concepts) {
1468
+ try {
1469
+ const concepts = JSON.parse(obs.concepts);
1470
+ if (Array.isArray(concepts) && concepts.length > 0) {
1471
+ parts.push(concepts.join(", "));
1472
+ }
1473
+ } catch {}
1474
+ }
1475
+ return parts.join(`
1476
+
1477
+ `);
1478
+ }
1479
+ async function getPipeline() {
1480
+ if (_pipeline)
1481
+ return _pipeline;
1482
+ if (_available === false)
1483
+ return null;
1484
+ try {
1485
+ const { pipeline } = await import("@xenova/transformers");
1486
+ _pipeline = await pipeline("feature-extraction", MODEL_NAME);
1487
+ _available = true;
1488
+ return _pipeline;
1489
+ } catch (err) {
1490
+ _available = false;
1491
+ console.error(`[engrm] Local embedding model unavailable: ${err instanceof Error ? err.message : String(err)}`);
1492
+ return null;
1493
+ }
1494
+ }
1495
+
1496
+ // src/capture/recurrence.ts
1497
+ var DISTANCE_THRESHOLD = 0.15;
1498
+ async function detectRecurrence(db, config, observation) {
1499
+ if (observation.type !== "bugfix") {
1500
+ return { patternCreated: false };
1501
+ }
1502
+ if (!db.vecAvailable) {
1503
+ return { patternCreated: false };
1504
+ }
1505
+ const text = composeEmbeddingText(observation);
1506
+ const embedding = await embedText(text);
1507
+ if (!embedding) {
1508
+ return { patternCreated: false };
1509
+ }
1510
+ const vecResults = db.searchVec(embedding, null, ["active", "aging", "pinned"], 10);
1511
+ for (const match of vecResults) {
1512
+ if (match.observation_id === observation.id)
1513
+ continue;
1514
+ if (match.distance > DISTANCE_THRESHOLD)
1515
+ continue;
1516
+ const matched = db.getObservationById(match.observation_id);
1517
+ if (!matched)
1518
+ continue;
1519
+ if (matched.type !== "bugfix")
1520
+ continue;
1521
+ if (matched.session_id === observation.session_id)
1522
+ continue;
1523
+ if (await patternAlreadyExists(db, observation, matched))
1524
+ continue;
1525
+ let matchedProjectName;
1526
+ if (matched.project_id !== observation.project_id) {
1527
+ const proj = db.getProjectById(matched.project_id);
1528
+ if (proj)
1529
+ matchedProjectName = proj.name;
1530
+ }
1531
+ const similarity = 1 - match.distance;
1532
+ const result = await saveObservation(db, config, {
1533
+ type: "pattern",
1534
+ title: `Recurring bugfix: ${observation.title}`,
1535
+ narrative: `This bug pattern has appeared in multiple sessions. Original: "${matched.title}" (session ${matched.session_id?.slice(0, 8) ?? "unknown"}). Latest: "${observation.title}". Similarity: ${(similarity * 100).toFixed(0)}%. Consider addressing the root cause.`,
1536
+ facts: [
1537
+ `First seen: ${matched.created_at.split("T")[0]}`,
1538
+ `Recurred: ${observation.created_at.split("T")[0]}`,
1539
+ `Similarity: ${(similarity * 100).toFixed(0)}%`
1540
+ ],
1541
+ concepts: mergeConceptsFromBoth(observation, matched),
1542
+ cwd: process.cwd(),
1543
+ session_id: observation.session_id ?? undefined
1544
+ });
1545
+ if (result.success && result.observation_id) {
1546
+ return {
1547
+ patternCreated: true,
1548
+ patternId: result.observation_id,
1549
+ matchedObservationId: matched.id,
1550
+ matchedProjectName,
1551
+ matchedTitle: matched.title,
1552
+ similarity
1553
+ };
1554
+ }
1555
+ }
1556
+ return { patternCreated: false };
1557
+ }
1558
+ async function patternAlreadyExists(db, obs1, obs2) {
1559
+ const recentPatterns = db.db.query(`SELECT * FROM observations
1560
+ WHERE type = 'pattern' AND lifecycle IN ('active', 'aging', 'pinned')
1561
+ AND title LIKE ?
1562
+ ORDER BY created_at_epoch DESC LIMIT 5`).all(`%${obs1.title.slice(0, 30)}%`);
1563
+ for (const p of recentPatterns) {
1564
+ if (p.narrative?.includes(obs2.title.slice(0, 30)))
1565
+ return true;
1566
+ }
1567
+ return false;
1568
+ }
1569
+ function mergeConceptsFromBoth(obs1, obs2) {
1570
+ const concepts = new Set;
1571
+ for (const obs of [obs1, obs2]) {
1572
+ if (obs.concepts) {
1573
+ try {
1574
+ const parsed = JSON.parse(obs.concepts);
1575
+ if (Array.isArray(parsed)) {
1576
+ for (const c of parsed) {
1577
+ if (typeof c === "string")
1578
+ concepts.add(c);
1579
+ }
1580
+ }
1581
+ } catch {}
1582
+ }
1583
+ }
1584
+ return [...concepts];
1585
+ }
1586
+
1587
+ // src/capture/conflict.ts
1588
+ var SIMILARITY_THRESHOLD = 0.25;
1589
+ async function detectDecisionConflict(db, observation) {
1590
+ if (observation.type !== "decision") {
1591
+ return { hasConflict: false };
1592
+ }
1593
+ if (!observation.narrative || observation.narrative.trim().length < 20) {
1594
+ return { hasConflict: false };
1595
+ }
1596
+ if (db.vecAvailable) {
1597
+ return detectViaVec(db, observation);
1598
+ }
1599
+ return detectViaFts(db, observation);
1600
+ }
1601
+ async function detectViaVec(db, observation) {
1602
+ const text = composeEmbeddingText(observation);
1603
+ const embedding = await embedText(text);
1604
+ if (!embedding)
1605
+ return { hasConflict: false };
1606
+ const results = db.searchVec(embedding, observation.project_id, ["active", "aging", "pinned"], 10);
1607
+ for (const match of results) {
1608
+ if (match.observation_id === observation.id)
1609
+ continue;
1610
+ if (match.distance > SIMILARITY_THRESHOLD)
1611
+ continue;
1612
+ const existing = db.getObservationById(match.observation_id);
1613
+ if (!existing)
1614
+ continue;
1615
+ if (existing.type !== "decision")
1616
+ continue;
1617
+ if (!existing.narrative)
1618
+ continue;
1619
+ const conflict = narrativesConflict(observation.narrative, existing.narrative);
1620
+ if (conflict) {
1621
+ return {
1622
+ hasConflict: true,
1623
+ conflictingId: existing.id,
1624
+ conflictingTitle: existing.title,
1625
+ reason: conflict
1626
+ };
1627
+ }
1628
+ }
1629
+ return { hasConflict: false };
1630
+ }
1631
+ async function detectViaFts(db, observation) {
1632
+ const keywords = observation.title.split(/\s+/).filter((w) => w.length > 3).slice(0, 5).join(" ");
1633
+ if (!keywords)
1634
+ return { hasConflict: false };
1635
+ const ftsResults = db.searchFts(keywords, observation.project_id, ["active", "aging", "pinned"], 10);
1636
+ for (const match of ftsResults) {
1637
+ if (match.id === observation.id)
1638
+ continue;
1639
+ const existing = db.getObservationById(match.id);
1640
+ if (!existing)
1641
+ continue;
1642
+ if (existing.type !== "decision")
1643
+ continue;
1644
+ if (!existing.narrative)
1645
+ continue;
1646
+ const conflict = narrativesConflict(observation.narrative, existing.narrative);
1647
+ if (conflict) {
1648
+ return {
1649
+ hasConflict: true,
1650
+ conflictingId: existing.id,
1651
+ conflictingTitle: existing.title,
1652
+ reason: conflict
1653
+ };
1654
+ }
1655
+ }
1656
+ return { hasConflict: false };
1657
+ }
1658
+ function narrativesConflict(narrative1, narrative2) {
1659
+ const n1 = narrative1.toLowerCase();
1660
+ const n2 = narrative2.toLowerCase();
1661
+ const opposingPairs = [
1662
+ [["should use", "decided to use", "chose", "prefer", "went with"], ["should not", "decided against", "avoid", "rejected", "don't use"]],
1663
+ [["enable", "turn on", "activate", "add"], ["disable", "turn off", "deactivate", "remove"]],
1664
+ [["increase", "more", "higher", "scale up"], ["decrease", "less", "lower", "scale down"]],
1665
+ [["keep", "maintain", "preserve"], ["replace", "migrate", "switch from", "deprecate"]]
1666
+ ];
1667
+ for (const [positive, negative] of opposingPairs) {
1668
+ const n1HasPositive = positive.some((w) => n1.includes(w));
1669
+ const n1HasNegative = negative.some((w) => n1.includes(w));
1670
+ const n2HasPositive = positive.some((w) => n2.includes(w));
1671
+ const n2HasNegative = negative.some((w) => n2.includes(w));
1672
+ if (n1HasPositive && n2HasNegative || n1HasNegative && n2HasPositive) {
1673
+ return "Narratives suggest opposing conclusions on a similar topic";
1674
+ }
1675
+ }
1676
+ return null;
1677
+ }
1678
+
1679
+ // src/tools/save.ts
1680
+ var VALID_TYPES = [
1681
+ "bugfix",
1682
+ "discovery",
1683
+ "decision",
1684
+ "pattern",
1685
+ "change",
1686
+ "feature",
1687
+ "refactor",
1688
+ "digest",
1689
+ "standard"
1690
+ ];
1691
+ async function saveObservation(db, config, input) {
1692
+ if (!VALID_TYPES.includes(input.type)) {
1693
+ return {
1694
+ success: false,
1695
+ reason: `Invalid type '${input.type}'. Must be one of: ${VALID_TYPES.join(", ")}`
1696
+ };
1697
+ }
1698
+ if (!input.title || input.title.trim().length === 0) {
1699
+ return { success: false, reason: "Title is required" };
1700
+ }
1701
+ const cwd = input.cwd ?? process.cwd();
1702
+ const detected = detectProject(cwd);
1703
+ const project = db.upsertProject({
1704
+ canonical_id: detected.canonical_id,
1705
+ name: detected.name,
1706
+ local_path: detected.local_path,
1707
+ remote_url: detected.remote_url
1708
+ });
1709
+ const customPatterns = config.scrubbing.enabled ? config.scrubbing.custom_patterns : [];
1710
+ const title = config.scrubbing.enabled ? scrubSecrets(input.title, customPatterns) : input.title;
1711
+ const narrative = input.narrative ? config.scrubbing.enabled ? scrubSecrets(input.narrative, customPatterns) : input.narrative : null;
1712
+ const factsJson = input.facts ? config.scrubbing.enabled ? scrubSecrets(JSON.stringify(input.facts), customPatterns) : JSON.stringify(input.facts) : null;
1713
+ const conceptsJson = input.concepts ? JSON.stringify(input.concepts) : null;
1714
+ const filesRead = input.files_read ? input.files_read.map((f) => toRelativePath(f, cwd)) : null;
1715
+ const filesModified = input.files_modified ? input.files_modified.map((f) => toRelativePath(f, cwd)) : null;
1716
+ const filesReadJson = filesRead ? JSON.stringify(filesRead) : null;
1717
+ const filesModifiedJson = filesModified ? JSON.stringify(filesModified) : null;
1718
+ let sensitivity = input.sensitivity ?? config.scrubbing.default_sensitivity;
1719
+ if (config.scrubbing.enabled && containsSecrets([input.title, input.narrative, JSON.stringify(input.facts)].filter(Boolean).join(" "), customPatterns)) {
1720
+ if (sensitivity === "shared") {
1721
+ sensitivity = "personal";
1722
+ }
1723
+ }
1724
+ const oneDayAgo = Math.floor(Date.now() / 1000) - 86400;
1725
+ const recentObs = db.getRecentObservations(project.id, oneDayAgo);
1726
+ const candidates = recentObs.map((o) => ({
1727
+ id: o.id,
1728
+ title: o.title
1729
+ }));
1730
+ const duplicate = findDuplicate(title, candidates);
1731
+ const qualityInput = {
1732
+ type: input.type,
1733
+ title,
1734
+ narrative,
1735
+ facts: factsJson,
1736
+ concepts: conceptsJson,
1737
+ filesRead,
1738
+ filesModified,
1739
+ isDuplicate: duplicate !== null
1740
+ };
1741
+ const qualityScore = scoreQuality(qualityInput);
1742
+ if (!meetsQualityThreshold(qualityInput)) {
1743
+ return {
1744
+ success: false,
1745
+ quality_score: qualityScore,
1746
+ reason: `Quality score ${qualityScore.toFixed(2)} below threshold`
1747
+ };
1748
+ }
1749
+ if (duplicate) {
1750
+ return {
1751
+ success: true,
1752
+ merged_into: duplicate.id,
1753
+ quality_score: qualityScore,
1754
+ reason: `Merged into existing observation #${duplicate.id}`
1755
+ };
1756
+ }
1757
+ const obs = db.insertObservation({
1758
+ session_id: input.session_id ?? null,
1759
+ project_id: project.id,
1760
+ type: input.type,
1761
+ title,
1762
+ narrative,
1763
+ facts: factsJson,
1764
+ concepts: conceptsJson,
1765
+ files_read: filesReadJson,
1766
+ files_modified: filesModifiedJson,
1767
+ quality: qualityScore,
1768
+ lifecycle: "active",
1769
+ sensitivity,
1770
+ user_id: config.user_id,
1771
+ device_id: config.device_id,
1772
+ agent: input.agent ?? "claude-code"
1773
+ });
1774
+ db.addToOutbox("observation", obs.id);
1775
+ if (db.vecAvailable) {
1776
+ try {
1777
+ const text = composeEmbeddingText(obs);
1778
+ const embedding = await embedText(text);
1779
+ if (embedding) {
1780
+ db.vecInsert(obs.id, embedding);
1781
+ }
1782
+ } catch {}
1783
+ }
1784
+ let recallHint;
1785
+ if (input.type === "bugfix") {
1786
+ try {
1787
+ const recurrence = await detectRecurrence(db, config, obs);
1788
+ if (recurrence.patternCreated && recurrence.matchedTitle) {
1789
+ const projectLabel = recurrence.matchedProjectName ? ` in ${recurrence.matchedProjectName}` : "";
1790
+ recallHint = `You solved a similar issue${projectLabel}: "${recurrence.matchedTitle}"`;
1791
+ }
1792
+ } catch {}
1793
+ }
1794
+ let conflictWarning;
1795
+ if (input.type === "decision") {
1796
+ try {
1797
+ const conflict = await detectDecisionConflict(db, obs);
1798
+ if (conflict.hasConflict && conflict.conflictingTitle) {
1799
+ conflictWarning = `Potential conflict with existing decision: "${conflict.conflictingTitle}" — ${conflict.reason}`;
1800
+ }
1801
+ } catch {}
1802
+ }
1803
+ return {
1804
+ success: true,
1805
+ observation_id: obs.id,
1806
+ quality_score: qualityScore,
1807
+ recall_hint: recallHint,
1808
+ conflict_warning: conflictWarning
1809
+ };
1810
+ }
1811
+ function toRelativePath(filePath, projectRoot) {
1812
+ if (!isAbsolute(filePath))
1813
+ return filePath;
1814
+ const rel = relative(projectRoot, filePath);
1815
+ if (rel.startsWith(".."))
1816
+ return filePath;
1817
+ return rel;
1818
+ }
1819
+
1820
+ // src/capture/scanner.ts
1821
+ function scanForSecrets(text, customPatterns = []) {
1822
+ if (!text)
1823
+ return [];
1824
+ const allPatterns = [
1825
+ ...DEFAULT_PATTERNS,
1826
+ ...compileCustomScanPatterns(customPatterns)
1827
+ ];
1828
+ const findings = [];
1829
+ for (const pattern of allPatterns) {
1830
+ const regex = new RegExp(pattern.source, pattern.flags);
1831
+ let match;
1832
+ while ((match = regex.exec(text)) !== null) {
1833
+ const snippet = buildRedactedSnippet(text, match.index, match[0].length, pattern.replacement);
1834
+ findings.push({
1835
+ finding_type: pattern.category,
1836
+ severity: pattern.severity,
1837
+ pattern_name: pattern.description,
1838
+ snippet
1839
+ });
1840
+ if (match[0].length === 0) {
1841
+ regex.lastIndex++;
1842
+ }
1843
+ }
1844
+ }
1845
+ return findings;
1846
+ }
1847
+ function buildRedactedSnippet(text, matchStart, matchLength, replacement) {
1848
+ const CONTEXT_CHARS = 30;
1849
+ const start = Math.max(0, matchStart - CONTEXT_CHARS);
1850
+ const end = Math.min(text.length, matchStart + matchLength + CONTEXT_CHARS);
1851
+ const before = text.slice(start, matchStart);
1852
+ const after = text.slice(matchStart + matchLength, end);
1853
+ let snippet = before + replacement + after;
1854
+ if (start > 0)
1855
+ snippet = "..." + snippet;
1856
+ if (end < text.length)
1857
+ snippet = snippet + "...";
1858
+ return snippet;
1859
+ }
1860
+ function compileCustomScanPatterns(patterns) {
1861
+ const compiled = [];
1862
+ for (const pattern of patterns) {
1863
+ try {
1864
+ new RegExp(pattern);
1865
+ compiled.push({
1866
+ source: pattern,
1867
+ flags: "g",
1868
+ replacement: "[REDACTED_CUSTOM]",
1869
+ description: `Custom pattern: ${pattern}`,
1870
+ category: "custom",
1871
+ severity: "medium"
1872
+ });
1873
+ } catch {}
1874
+ }
1875
+ return compiled;
1876
+ }
1877
+
1878
+ // src/capture/dependency.ts
1879
+ var INSTALL_PATTERNS = [
1880
+ {
1881
+ regex: /\bnpm\s+(?:install|i|add)\s+([^\s-][\w@/.^~>=<*-]*(?:\s+[^\s-][\w@/.^~>=<*-]*)*)/gm,
1882
+ manager: "npm",
1883
+ packageExtractor: (m) => m[1].split(/\s+/).filter((p) => p && !p.startsWith("-"))
1884
+ },
1885
+ {
1886
+ regex: /\byarn\s+add\s+([^\s-][\w@/.^~>=<*-]*(?:\s+[^\s-][\w@/.^~>=<*-]*)*)/gm,
1887
+ manager: "yarn",
1888
+ packageExtractor: (m) => m[1].split(/\s+/).filter((p) => p && !p.startsWith("-"))
1889
+ },
1890
+ {
1891
+ regex: /\bpnpm\s+(?:add|install)\s+([^\s-][\w@/.^~>=<*-]*(?:\s+[^\s-][\w@/.^~>=<*-]*)*)/gm,
1892
+ manager: "pnpm",
1893
+ packageExtractor: (m) => m[1].split(/\s+/).filter((p) => p && !p.startsWith("-"))
1894
+ },
1895
+ {
1896
+ regex: /\bbun\s+add\s+([^\s-][\w@/.^~>=<*-]*(?:\s+[^\s-][\w@/.^~>=<*-]*)*)/gm,
1897
+ manager: "bun",
1898
+ packageExtractor: (m) => m[1].split(/\s+/).filter((p) => p && !p.startsWith("-"))
1899
+ },
1900
+ {
1901
+ regex: /\bpip3?\s+install\s+([^\s-][\w>=<.~!-]*(?:\s+[^\s-][\w>=<.~!-]*)*)/gm,
1902
+ manager: "pip",
1903
+ packageExtractor: (m) => m[1].split(/\s+/).filter((p) => p && !p.startsWith("-") && !p.startsWith("--"))
1904
+ },
1905
+ {
1906
+ regex: /\bcargo\s+add\s+([^\s-][\w-]*(?:\s+[^\s-][\w-]*)*)/gm,
1907
+ manager: "cargo",
1908
+ packageExtractor: (m) => m[1].split(/\s+/).filter((p) => p && !p.startsWith("-"))
1909
+ },
1910
+ {
1911
+ regex: /\bgo\s+get\s+([\w./\-@]+)/gm,
1912
+ manager: "go",
1913
+ packageExtractor: (m) => [m[1]]
1914
+ },
1915
+ {
1916
+ regex: /\bgem\s+install\s+([\w-]+)/gm,
1917
+ manager: "gem",
1918
+ packageExtractor: (m) => [m[1]]
1919
+ },
1920
+ {
1921
+ regex: /\bcomposer\s+require\s+([\w/.-]+)/gm,
1922
+ manager: "composer",
1923
+ packageExtractor: (m) => [m[1]]
1924
+ }
1925
+ ];
1926
+ function detectDependencyInstalls(command, output) {
1927
+ const results = [];
1928
+ const textToScan = command;
1929
+ for (const pattern of INSTALL_PATTERNS) {
1930
+ pattern.regex.lastIndex = 0;
1931
+ let match;
1932
+ while ((match = pattern.regex.exec(textToScan)) !== null) {
1933
+ const packages = pattern.packageExtractor(match);
1934
+ if (packages.length > 0) {
1935
+ results.push({
1936
+ manager: pattern.manager,
1937
+ packages,
1938
+ command: match[0].trim()
1939
+ });
1940
+ }
1941
+ }
1942
+ }
1943
+ return results;
1944
+ }
1945
+
1946
+ // src/observer/observe.ts
1947
+ import { existsSync as existsSync3, readFileSync as readFileSync3, writeFileSync as writeFileSync2, mkdirSync as mkdirSync2 } from "node:fs";
1948
+ import { join as join3 } from "node:path";
1949
+ import { homedir as homedir2 } from "node:os";
1950
+
1951
+ // src/observer/prompts.ts
1952
+ var OBSERVER_SYSTEM_PROMPT = `You are Engrm Observer, a specialized memory agent that watches a Claude Code session and records what was accomplished.
1953
+
1954
+ CRITICAL RULES:
1955
+ - You are NOT doing the work. You are ONLY observing and recording.
1956
+ - Record what was LEARNED, BUILT, FIXED, DEPLOYED, or DECIDED — not low-level file operations.
1957
+ - If an event is trivial (whitespace change, import reorder, config tweak), respond with <skip/>.
1958
+ - Never use tools. Only respond with XML.
1959
+
1960
+ RESPONSE FORMAT — respond with EXACTLY ONE of:
1961
+
1962
+ 1. A meaningful observation:
1963
+ <observation>
1964
+ <type>TYPE</type>
1965
+ <title>Brief, meaningful title (what was accomplished, not what file changed)</title>
1966
+ <narrative>2-3 sentences: what changed, why it matters, what it enables</narrative>
1967
+ <facts>
1968
+ <fact>Specific technical fact worth remembering</fact>
1969
+ </facts>
1970
+ <concepts>
1971
+ <concept>relevant-tag</concept>
1972
+ </concepts>
1973
+ </observation>
1974
+
1975
+ 2. Skip (trivial/noise):
1976
+ <skip/>
1977
+
1978
+ TYPE must be one of:
1979
+ - bugfix: something was broken, now fixed
1980
+ - discovery: learning about existing system/codebase (reading a file to understand how something works)
1981
+ - decision: architectural or design choice with rationale
1982
+ - change: meaningful modification (new feature, config, docs)
1983
+ - feature: new capability or functionality added
1984
+ - refactor: code restructured without behavior change
1985
+ - pattern: recurring issue or technique observed across multiple events
1986
+
1987
+ TITLE GUIDANCE:
1988
+ - BAD: "Modified auth.ts", "Extended dashboard.html", "Created file.ts"
1989
+ - GOOD: "Added OAuth2 PKCE flow to authentication", "Fixed heatmap color mismatch on dashboard", "Chose SQLite over PostgreSQL for offline-first storage"
1990
+
1991
+ Use verbs like: implemented, fixed, added, configured, migrated, optimized, resolved, refactored, integrated.
1992
+
1993
+ FACTS should capture things worth remembering for future sessions:
1994
+ - Technical choices and their rationale
1995
+ - Gotchas discovered during implementation
1996
+ - API contracts, schema decisions, config values
1997
+ - Performance characteristics or constraints
1998
+
1999
+ CONCEPTS should be domain tags useful for search:
2000
+ - Technology names: "oauth", "sqlite", "react"
2001
+ - Patterns: "error-handling", "caching", "auth"
2002
+ - Domain: "dashboard", "api", "deployment"`;
2003
+ function formatToolEvent(event) {
2004
+ const { tool_name, tool_input, tool_response } = event;
2005
+ const parts = [
2006
+ `<tool_event>`,
2007
+ ` <tool>${tool_name}</tool>`,
2008
+ ` <cwd>${event.cwd}</cwd>`
2009
+ ];
2010
+ switch (tool_name) {
2011
+ case "Edit": {
2012
+ const filePath = tool_input["file_path"] ?? "";
2013
+ const oldStr = truncate2(String(tool_input["old_string"] ?? ""), 500);
2014
+ const newStr = truncate2(String(tool_input["new_string"] ?? ""), 500);
2015
+ parts.push(` <file>${filePath}</file>`);
2016
+ parts.push(` <old_code>${oldStr}</old_code>`);
2017
+ parts.push(` <new_code>${newStr}</new_code>`);
2018
+ break;
2019
+ }
2020
+ case "Write": {
2021
+ const filePath = tool_input["file_path"] ?? "";
2022
+ const content = truncate2(String(tool_input["content"] ?? ""), 800);
2023
+ parts.push(` <file>${filePath}</file>`);
2024
+ parts.push(` <content_preview>${content}</content_preview>`);
2025
+ break;
2026
+ }
2027
+ case "Read": {
2028
+ const filePath = tool_input["file_path"] ?? "";
2029
+ const preview = truncate2(tool_response ?? "", 600);
2030
+ parts.push(` <file>${filePath}</file>`);
2031
+ parts.push(` <content_preview>${preview}</content_preview>`);
2032
+ break;
2033
+ }
2034
+ case "Bash": {
2035
+ const command = truncate2(String(tool_input["command"] ?? ""), 300);
2036
+ const output = truncate2(tool_response ?? "", 500);
2037
+ parts.push(` <command>${command}</command>`);
2038
+ parts.push(` <output>${output}</output>`);
2039
+ break;
2040
+ }
2041
+ default: {
2042
+ parts.push(` <input>${truncate2(JSON.stringify(tool_input), 400)}</input>`);
2043
+ if (tool_response) {
2044
+ parts.push(` <response>${truncate2(tool_response, 400)}</response>`);
2045
+ }
2046
+ break;
2047
+ }
2048
+ }
2049
+ parts.push(`</tool_event>`);
2050
+ return parts.join(`
2051
+ `);
2052
+ }
2053
+ function truncate2(text, maxLen) {
2054
+ if (text.length <= maxLen)
2055
+ return text;
2056
+ return text.slice(0, maxLen - 20) + `
2057
+ ... [truncated]`;
2058
+ }
2059
+
2060
+ // src/observer/parser.ts
2061
+ function parseObservationXml(text) {
2062
+ if (/<skip\s*\/?>/i.test(text)) {
2063
+ return null;
2064
+ }
2065
+ const obsMatch = text.match(/<observation>([\s\S]*?)<\/observation>/i);
2066
+ if (!obsMatch)
2067
+ return null;
2068
+ const inner = obsMatch[1];
2069
+ const type = extractTag(inner, "type");
2070
+ const title = extractTag(inner, "title");
2071
+ const narrative = extractTag(inner, "narrative");
2072
+ if (!type || !title)
2073
+ return null;
2074
+ const facts = extractTags(inner, "fact");
2075
+ const concepts = extractTags(inner, "concept");
2076
+ return {
2077
+ type: type.toLowerCase().trim(),
2078
+ title: title.trim(),
2079
+ narrative: (narrative ?? "").trim(),
2080
+ facts,
2081
+ concepts
2082
+ };
2083
+ }
2084
+ function extractTag(xml, tag) {
2085
+ const match = xml.match(new RegExp(`<${tag}>([\\s\\S]*?)<\\/${tag}>`, "i"));
2086
+ return match ? match[1].trim() : null;
2087
+ }
2088
+ function extractTags(xml, tag) {
2089
+ const results = [];
2090
+ const regex = new RegExp(`<${tag}>([\\s\\S]*?)<\\/${tag}>`, "gi");
2091
+ let match;
2092
+ while ((match = regex.exec(xml)) !== null) {
2093
+ const value = match[1].trim();
2094
+ if (value.length > 0) {
2095
+ results.push(value);
2096
+ }
2097
+ }
2098
+ return results;
2099
+ }
2100
+
2101
+ // src/observer/observe.ts
2102
+ var ENGRM_DIR = join3(homedir2(), ".engrm");
2103
+ var OBSERVER_DIR = join3(ENGRM_DIR, "observer-sessions");
2104
+ function stateFilePath(sessionId) {
2105
+ return join3(OBSERVER_DIR, `${sessionId}.json`);
2106
+ }
2107
+ function readState(sessionId) {
2108
+ const path = stateFilePath(sessionId);
2109
+ if (!existsSync3(path))
2110
+ return null;
2111
+ try {
2112
+ return JSON.parse(readFileSync3(path, "utf-8"));
2113
+ } catch {
2114
+ return null;
2115
+ }
2116
+ }
2117
+ function writeState(sessionId, state) {
2118
+ if (!existsSync3(OBSERVER_DIR)) {
2119
+ mkdirSync2(OBSERVER_DIR, { recursive: true });
2120
+ }
2121
+ writeFileSync2(stateFilePath(sessionId), JSON.stringify(state), "utf-8");
2122
+ }
2123
+ async function observeToolEvent(event, options) {
2124
+ let query;
2125
+ try {
2126
+ const sdk = await import("@anthropic-ai/claude-agent-sdk");
2127
+ query = sdk.query;
2128
+ } catch {
2129
+ return null;
2130
+ }
2131
+ const eventXml = formatToolEvent(event);
2132
+ const state = readState(event.session_id);
2133
+ const isFirst = !state;
2134
+ const prompt = isFirst ? `${OBSERVER_SYSTEM_PROMPT}
2135
+
2136
+ Observe this tool event from the coding session and respond with an <observation> or <skip/>:
2137
+
2138
+ ${eventXml}` : `Observe this next tool event and respond with an <observation> or <skip/>:
2139
+
2140
+ ${eventXml}`;
2141
+ try {
2142
+ let observerSessionId = state?.observerSessionId;
2143
+ let responseText = "";
2144
+ const queryOptions = {
2145
+ model: options?.model ?? "haiku",
2146
+ maxTurns: 1,
2147
+ disallowedTools: [
2148
+ "Bash",
2149
+ "Read",
2150
+ "Write",
2151
+ "Edit",
2152
+ "Grep",
2153
+ "Glob",
2154
+ "WebFetch",
2155
+ "WebSearch",
2156
+ "Agent"
2157
+ ]
2158
+ };
2159
+ if (observerSessionId) {
2160
+ queryOptions.resume = observerSessionId;
2161
+ }
2162
+ const result = query({
2163
+ prompt,
2164
+ options: queryOptions
2165
+ });
2166
+ for await (const message of result) {
2167
+ if ("session_id" in message && message.session_id) {
2168
+ observerSessionId = message.session_id;
2169
+ }
2170
+ if (message.type === "assistant" && "message" in message) {
2171
+ const msg = message.message;
2172
+ if (msg.content) {
2173
+ for (const block of msg.content) {
2174
+ if (block.type === "text" && block.text) {
2175
+ responseText += block.text;
2176
+ }
2177
+ }
2178
+ }
2179
+ }
2180
+ if (message.type === "result" && "result" in message) {
2181
+ const resultMsg = message;
2182
+ if (resultMsg.result && !responseText) {
2183
+ responseText = resultMsg.result;
2184
+ }
2185
+ }
2186
+ }
2187
+ if (observerSessionId) {
2188
+ writeState(event.session_id, {
2189
+ observerSessionId,
2190
+ eventCount: (state?.eventCount ?? 0) + 1
2191
+ });
2192
+ }
2193
+ if (!responseText.trim())
2194
+ return null;
2195
+ const parsed = parseObservationXml(responseText);
2196
+ if (!parsed)
2197
+ return null;
2198
+ const { files_read, files_modified } = extractFilesFromEvent(event);
2199
+ return {
2200
+ type: parsed.type,
2201
+ title: parsed.title,
2202
+ narrative: parsed.narrative || undefined,
2203
+ facts: parsed.facts.length > 0 ? parsed.facts : undefined,
2204
+ concepts: parsed.concepts.length > 0 ? parsed.concepts : undefined,
2205
+ files_read,
2206
+ files_modified,
2207
+ session_id: event.session_id,
2208
+ cwd: event.cwd
2209
+ };
2210
+ } catch {
2211
+ return null;
2212
+ }
2213
+ }
2214
+ function extractFilesFromEvent(event) {
2215
+ const filePath = event.tool_input["file_path"];
2216
+ if (!filePath)
2217
+ return {};
2218
+ if (event.tool_name === "Read") {
2219
+ return { files_read: [filePath] };
2220
+ }
2221
+ return { files_modified: [filePath] };
2222
+ }
2223
+
2224
+ // hooks/post-tool-use.ts
2225
+ async function main() {
2226
+ const chunks = [];
2227
+ for await (const chunk of process.stdin) {
2228
+ chunks.push(typeof chunk === "string" ? chunk : Buffer.from(chunk).toString());
2229
+ }
2230
+ const raw = chunks.join("");
2231
+ if (!raw.trim())
2232
+ process.exit(0);
2233
+ let event;
2234
+ try {
2235
+ event = JSON.parse(raw);
2236
+ } catch {
2237
+ process.exit(0);
2238
+ }
2239
+ if (!configExists())
2240
+ process.exit(0);
2241
+ let config;
2242
+ let db;
2243
+ try {
2244
+ config = loadConfig();
2245
+ db = new MemDatabase(getDbPath());
2246
+ } catch {
2247
+ process.exit(0);
2248
+ }
2249
+ try {
2250
+ if (event.session_id) {
2251
+ const metricsIncrement = {
2252
+ toolCalls: 1
2253
+ };
2254
+ if ((event.tool_name === "Edit" || event.tool_name === "Write") && event.tool_input["file_path"]) {
2255
+ metricsIncrement.files = 1;
2256
+ }
2257
+ db.incrementSessionMetrics(event.session_id, metricsIncrement);
2258
+ }
2259
+ const textToScan = extractScanText(event);
2260
+ if (textToScan) {
2261
+ const findings = scanForSecrets(textToScan, config.scrubbing.custom_patterns);
2262
+ if (findings.length > 0) {
2263
+ const detected = detectProject(event.cwd);
2264
+ const project = db.getProjectByCanonicalId(detected.canonical_id);
2265
+ if (project) {
2266
+ for (const finding of findings) {
2267
+ db.insertSecurityFinding({
2268
+ session_id: event.session_id,
2269
+ project_id: project.id,
2270
+ finding_type: finding.finding_type,
2271
+ severity: finding.severity,
2272
+ pattern_name: finding.pattern_name,
2273
+ snippet: finding.snippet,
2274
+ tool_name: event.tool_name,
2275
+ user_id: config.user_id,
2276
+ device_id: config.device_id
2277
+ });
2278
+ }
2279
+ }
2280
+ }
2281
+ }
2282
+ if (event.tool_name === "Bash" && event.tool_input["command"]) {
2283
+ const command = String(event.tool_input["command"]);
2284
+ const installs = detectDependencyInstalls(command, event.tool_response ?? undefined);
2285
+ for (const install of installs) {
2286
+ await saveObservation(db, config, {
2287
+ type: "change",
2288
+ title: `Added ${install.packages.length === 1 ? install.packages[0] : install.packages.length + " packages"} via ${install.manager}`,
2289
+ narrative: `Dependency installed: ${install.command}`,
2290
+ concepts: [install.manager, "dependency", ...install.packages],
2291
+ session_id: event.session_id,
2292
+ cwd: event.cwd
2293
+ });
2294
+ }
2295
+ }
2296
+ let saved = false;
2297
+ if (config.observer?.enabled !== false) {
2298
+ try {
2299
+ const observed = await observeToolEvent(event, {
2300
+ model: config.observer.model
2301
+ });
2302
+ if (observed) {
2303
+ await saveObservation(db, config, observed);
2304
+ saved = true;
2305
+ }
2306
+ } catch {}
2307
+ }
2308
+ if (!saved) {
2309
+ const extracted = extractObservation(event);
2310
+ if (extracted) {
2311
+ await saveObservation(db, config, {
2312
+ type: extracted.type,
2313
+ title: extracted.title,
2314
+ narrative: extracted.narrative,
2315
+ files_read: extracted.files_read,
2316
+ files_modified: extracted.files_modified,
2317
+ session_id: event.session_id,
2318
+ cwd: event.cwd
2319
+ });
2320
+ }
2321
+ }
2322
+ } finally {
2323
+ db.close();
2324
+ }
2325
+ }
2326
+ function extractScanText(event) {
2327
+ const { tool_name, tool_input, tool_response } = event;
2328
+ switch (tool_name) {
2329
+ case "Edit": {
2330
+ const parts = [];
2331
+ if (tool_input["old_string"])
2332
+ parts.push(String(tool_input["old_string"]));
2333
+ if (tool_input["new_string"])
2334
+ parts.push(String(tool_input["new_string"]));
2335
+ return parts.length > 0 ? parts.join(`
2336
+ `) : null;
2337
+ }
2338
+ case "Write": {
2339
+ const content = tool_input["content"];
2340
+ return content ? String(content) : null;
2341
+ }
2342
+ case "Bash": {
2343
+ const parts = [];
2344
+ if (tool_input["command"])
2345
+ parts.push(String(tool_input["command"]));
2346
+ if (tool_response)
2347
+ parts.push(tool_response);
2348
+ return parts.length > 0 ? parts.join(`
2349
+ `) : null;
2350
+ }
2351
+ default:
2352
+ return null;
2353
+ }
2354
+ }
2355
+ main().catch(() => {
2356
+ process.exit(0);
2357
+ });