engrm 0.4.43 → 0.4.45

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.
@@ -91,10 +91,11 @@ function readProjectConfigFile(directory) {
91
91
  }
92
92
  }
93
93
  function detectProject(directory) {
94
- const remoteUrl = getGitRemoteUrl(directory);
94
+ const resolvedDirectory = resolve(directory);
95
+ const remoteUrl = getGitRemoteUrl(resolvedDirectory);
95
96
  if (remoteUrl) {
96
97
  const canonicalId = normaliseGitRemoteUrl(remoteUrl);
97
- const repoRoot = getGitTopLevel(directory) ?? directory;
98
+ const repoRoot = getGitTopLevel(resolvedDirectory) ?? resolvedDirectory;
98
99
  return {
99
100
  canonical_id: canonicalId,
100
101
  name: projectNameFromCanonicalId(canonicalId),
@@ -102,21 +103,22 @@ function detectProject(directory) {
102
103
  local_path: repoRoot
103
104
  };
104
105
  }
105
- const configFile = readProjectConfigFile(directory);
106
+ const configFile = readProjectConfigFile(resolvedDirectory);
106
107
  if (configFile) {
107
108
  return {
108
109
  canonical_id: configFile.project_id,
109
110
  name: configFile.name ?? projectNameFromCanonicalId(configFile.project_id),
110
111
  remote_url: null,
111
- local_path: directory
112
+ local_path: resolvedDirectory
112
113
  };
113
114
  }
114
- const dirName = basename(directory);
115
+ const dirName = basename(resolvedDirectory);
116
+ const safeDirName = !dirName || dirName === "/" || dirName === "." ? "root" : dirName;
115
117
  return {
116
- canonical_id: `local/${dirName}`,
117
- name: dirName,
118
+ canonical_id: `local/${safeDirName}`,
119
+ name: safeDirName,
118
120
  remote_url: null,
119
- local_path: directory
121
+ local_path: resolvedDirectory
120
122
  };
121
123
  }
122
124
  function detectProjectForPath(filePath, fallbackCwd) {
@@ -996,6 +998,7 @@ function ensureObservationTypes(db) {
996
998
  DROP TABLE observations;
997
999
  ALTER TABLE observations_repair RENAME TO observations;
998
1000
  CREATE INDEX IF NOT EXISTS idx_observations_project ON observations(project_id);
1001
+ CREATE INDEX IF NOT EXISTS idx_observations_project_lifecycle ON observations(project_id, lifecycle);
999
1002
  CREATE INDEX IF NOT EXISTS idx_observations_type ON observations(type);
1000
1003
  CREATE INDEX IF NOT EXISTS idx_observations_created ON observations(created_at_epoch);
1001
1004
  CREATE INDEX IF NOT EXISTS idx_observations_session ON observations(session_id);
@@ -1276,6 +1279,7 @@ class MemDatabase {
1276
1279
  this.db = openDatabase(dbPath);
1277
1280
  this.db.exec("PRAGMA journal_mode = WAL");
1278
1281
  this.db.exec("PRAGMA foreign_keys = ON");
1282
+ this.db.exec("PRAGMA busy_timeout = 5000");
1279
1283
  this.vecAvailable = this.loadVecExtension();
1280
1284
  runMigrations(this.db);
1281
1285
  ensureObservationTypes(this.db);
@@ -1297,8 +1301,16 @@ class MemDatabase {
1297
1301
  this.db.close();
1298
1302
  }
1299
1303
  upsertProject(project) {
1304
+ const canonicalId = project.canonical_id?.trim();
1305
+ const name = project.name?.trim();
1306
+ if (!canonicalId) {
1307
+ throw new Error("Project canonical_id is required");
1308
+ }
1309
+ if (!name) {
1310
+ throw new Error("Project name is required");
1311
+ }
1300
1312
  const now = Math.floor(Date.now() / 1000);
1301
- const existing = this.db.query("SELECT * FROM projects WHERE canonical_id = ?").get(project.canonical_id);
1313
+ const existing = this.db.query("SELECT * FROM projects WHERE canonical_id = ?").get(canonicalId);
1302
1314
  if (existing) {
1303
1315
  this.db.query(`UPDATE projects SET
1304
1316
  local_path = COALESCE(?, local_path),
@@ -1313,7 +1325,7 @@ class MemDatabase {
1313
1325
  };
1314
1326
  }
1315
1327
  const result = this.db.query(`INSERT INTO projects (canonical_id, name, local_path, remote_url, first_seen_epoch, last_active_epoch)
1316
- VALUES (?, ?, ?, ?, ?, ?)`).run(project.canonical_id, project.name, project.local_path ?? null, project.remote_url ?? null, now, now);
1328
+ VALUES (?, ?, ?, ?, ?, ?)`).run(canonicalId, name, project.local_path ?? null, project.remote_url ?? null, now, now);
1317
1329
  return this.db.query("SELECT * FROM projects WHERE id = ?").get(Number(result.lastInsertRowid));
1318
1330
  }
1319
1331
  getProjectByCanonicalId(canonicalId) {
@@ -2101,7 +2113,19 @@ function buildCurrentThread(latestRequest, recentOutcomes, hotFiles, recentToolN
2101
2113
  return null;
2102
2114
  }
2103
2115
  function compactLine(value) {
2104
- const trimmed = value?.replace(/\s+/g, " ").trim();
2116
+ if (value === null || value === undefined)
2117
+ return null;
2118
+ let text;
2119
+ if (typeof value === "string") {
2120
+ text = value;
2121
+ } else {
2122
+ try {
2123
+ text = JSON.stringify(value);
2124
+ } catch {
2125
+ text = String(value);
2126
+ }
2127
+ }
2128
+ const trimmed = text.replace(/\s+/g, " ").trim();
2105
2129
  if (!trimmed)
2106
2130
  return null;
2107
2131
  return trimmed.length > 120 ? `${trimmed.slice(0, 117)}...` : trimmed;
@@ -2123,1489 +2147,6 @@ function parseJsonArray(value) {
2123
2147
  }
2124
2148
  }
2125
2149
 
2126
- // src/capture/transcript.ts
2127
- import { createHash as createHash3 } from "node:crypto";
2128
- import { readFileSync as readFileSync3, existsSync as existsSync3 } from "node:fs";
2129
- import { join as join3 } from "node:path";
2130
- import { homedir as homedir2 } from "node:os";
2131
-
2132
- // src/embeddings/embedder.ts
2133
- var _available = null;
2134
- var _pipeline = null;
2135
- var MODEL_NAME = "Xenova/all-MiniLM-L6-v2";
2136
- async function embedText(text) {
2137
- const pipe = await getPipeline();
2138
- if (!pipe)
2139
- return null;
2140
- try {
2141
- const output = await pipe(text, { pooling: "mean", normalize: true });
2142
- return new Float32Array(output.data);
2143
- } catch {
2144
- return null;
2145
- }
2146
- }
2147
- function composeEmbeddingText(obs) {
2148
- const parts = [obs.title];
2149
- if (obs.narrative)
2150
- parts.push(obs.narrative);
2151
- if (obs.facts) {
2152
- try {
2153
- const facts = JSON.parse(obs.facts);
2154
- if (Array.isArray(facts) && facts.length > 0) {
2155
- parts.push(facts.map((f) => `- ${f}`).join(`
2156
- `));
2157
- }
2158
- } catch {
2159
- parts.push(obs.facts);
2160
- }
2161
- }
2162
- if (obs.concepts) {
2163
- try {
2164
- const concepts = JSON.parse(obs.concepts);
2165
- if (Array.isArray(concepts) && concepts.length > 0) {
2166
- parts.push(concepts.join(", "));
2167
- }
2168
- } catch {}
2169
- }
2170
- return parts.join(`
2171
-
2172
- `);
2173
- }
2174
- function composeChatEmbeddingText(text) {
2175
- return text.replace(/\s+/g, " ").trim().slice(0, 2000);
2176
- }
2177
- async function getPipeline() {
2178
- if (_pipeline)
2179
- return _pipeline;
2180
- if (_available === false)
2181
- return null;
2182
- try {
2183
- const { pipeline } = await import("@xenova/transformers");
2184
- _pipeline = await pipeline("feature-extraction", MODEL_NAME);
2185
- _available = true;
2186
- return _pipeline;
2187
- } catch (err) {
2188
- _available = false;
2189
- console.error(`[engrm] Local embedding model unavailable: ${err instanceof Error ? err.message : String(err)}`);
2190
- return null;
2191
- }
2192
- }
2193
-
2194
- // src/tools/save.ts
2195
- import { relative, isAbsolute } from "node:path";
2196
-
2197
- // src/capture/scrubber.ts
2198
- var DEFAULT_PATTERNS = [
2199
- {
2200
- source: "sk-[a-zA-Z0-9]{20,}",
2201
- flags: "g",
2202
- replacement: "[REDACTED_API_KEY]",
2203
- description: "OpenAI API keys",
2204
- category: "api_key",
2205
- severity: "critical"
2206
- },
2207
- {
2208
- source: "Bearer [a-zA-Z0-9\\-._~+/]+=*",
2209
- flags: "g",
2210
- replacement: "[REDACTED_BEARER]",
2211
- description: "Bearer auth tokens",
2212
- category: "token",
2213
- severity: "medium"
2214
- },
2215
- {
2216
- source: "password[=:]\\s*\\S+",
2217
- flags: "gi",
2218
- replacement: "password=[REDACTED]",
2219
- description: "Passwords in config",
2220
- category: "password",
2221
- severity: "high"
2222
- },
2223
- {
2224
- source: "postgresql://[^\\s]+",
2225
- flags: "g",
2226
- replacement: "[REDACTED_DB_URL]",
2227
- description: "PostgreSQL connection strings",
2228
- category: "db_url",
2229
- severity: "high"
2230
- },
2231
- {
2232
- source: "mongodb://[^\\s]+",
2233
- flags: "g",
2234
- replacement: "[REDACTED_DB_URL]",
2235
- description: "MongoDB connection strings",
2236
- category: "db_url",
2237
- severity: "high"
2238
- },
2239
- {
2240
- source: "mysql://[^\\s]+",
2241
- flags: "g",
2242
- replacement: "[REDACTED_DB_URL]",
2243
- description: "MySQL connection strings",
2244
- category: "db_url",
2245
- severity: "high"
2246
- },
2247
- {
2248
- source: "AKIA[A-Z0-9]{16}",
2249
- flags: "g",
2250
- replacement: "[REDACTED_AWS_KEY]",
2251
- description: "AWS access keys",
2252
- category: "api_key",
2253
- severity: "critical"
2254
- },
2255
- {
2256
- source: "ghp_[a-zA-Z0-9]{36}",
2257
- flags: "g",
2258
- replacement: "[REDACTED_GH_TOKEN]",
2259
- description: "GitHub personal access tokens",
2260
- category: "token",
2261
- severity: "high"
2262
- },
2263
- {
2264
- source: "gho_[a-zA-Z0-9]{36}",
2265
- flags: "g",
2266
- replacement: "[REDACTED_GH_TOKEN]",
2267
- description: "GitHub OAuth tokens",
2268
- category: "token",
2269
- severity: "high"
2270
- },
2271
- {
2272
- source: "github_pat_[a-zA-Z0-9_]{22,}",
2273
- flags: "g",
2274
- replacement: "[REDACTED_GH_TOKEN]",
2275
- description: "GitHub fine-grained PATs",
2276
- category: "token",
2277
- severity: "high"
2278
- },
2279
- {
2280
- source: "cvk_[a-f0-9]{64}",
2281
- flags: "g",
2282
- replacement: "[REDACTED_CANDENGO_KEY]",
2283
- description: "Candengo API keys",
2284
- category: "api_key",
2285
- severity: "critical"
2286
- },
2287
- {
2288
- source: "xox[bpras]-[a-zA-Z0-9\\-]+",
2289
- flags: "g",
2290
- replacement: "[REDACTED_SLACK_TOKEN]",
2291
- description: "Slack tokens",
2292
- category: "token",
2293
- severity: "high"
2294
- }
2295
- ];
2296
- function compileCustomPatterns(patterns) {
2297
- const compiled = [];
2298
- for (const pattern of patterns) {
2299
- try {
2300
- new RegExp(pattern);
2301
- compiled.push({
2302
- source: pattern,
2303
- flags: "g",
2304
- replacement: "[REDACTED_CUSTOM]",
2305
- description: `Custom pattern: ${pattern}`,
2306
- category: "custom",
2307
- severity: "medium"
2308
- });
2309
- } catch {}
2310
- }
2311
- return compiled;
2312
- }
2313
- function scrubSecrets(text, customPatterns = []) {
2314
- let result = text;
2315
- const allPatterns = [...DEFAULT_PATTERNS, ...compileCustomPatterns(customPatterns)];
2316
- for (const pattern of allPatterns) {
2317
- result = result.replace(new RegExp(pattern.source, pattern.flags), pattern.replacement);
2318
- }
2319
- return result;
2320
- }
2321
- function containsSecrets(text, customPatterns = []) {
2322
- const allPatterns = [...DEFAULT_PATTERNS, ...compileCustomPatterns(customPatterns)];
2323
- for (const pattern of allPatterns) {
2324
- if (new RegExp(pattern.source, pattern.flags).test(text))
2325
- return true;
2326
- }
2327
- return false;
2328
- }
2329
- var FLEET_HOSTNAME_PATTERN = /\b(?=.{1,253}\b)(?!-)(?:[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?\.)+[a-z]{2,63}\b/gi;
2330
- var FLEET_IP_PATTERN = /\b(?:\d{1,3}\.){3}\d{1,3}\b/g;
2331
- var FLEET_MAC_PATTERN = /\b(?:[0-9a-f]{2}[:-]){5}[0-9a-f]{2}\b/gi;
2332
- function scrubFleetIdentifiers(text) {
2333
- return text.replace(FLEET_MAC_PATTERN, "[REDACTED_MAC]").replace(FLEET_IP_PATTERN, "[REDACTED_IP]").replace(FLEET_HOSTNAME_PATTERN, "[REDACTED_HOSTNAME]");
2334
- }
2335
-
2336
- // src/capture/quality.ts
2337
- var QUALITY_THRESHOLD = 0.1;
2338
- function scoreQuality(input) {
2339
- let score = 0;
2340
- switch (input.type) {
2341
- case "bugfix":
2342
- score += 0.3;
2343
- break;
2344
- case "decision":
2345
- score += 0.3;
2346
- break;
2347
- case "discovery":
2348
- score += 0.2;
2349
- break;
2350
- case "pattern":
2351
- score += 0.2;
2352
- break;
2353
- case "feature":
2354
- score += 0.15;
2355
- break;
2356
- case "refactor":
2357
- score += 0.15;
2358
- break;
2359
- case "change":
2360
- score += 0.05;
2361
- break;
2362
- case "digest":
2363
- score += 0.3;
2364
- break;
2365
- case "standard":
2366
- score += 0.25;
2367
- break;
2368
- case "message":
2369
- score += 0.1;
2370
- break;
2371
- }
2372
- if (input.narrative && input.narrative.length > 50) {
2373
- score += 0.15;
2374
- }
2375
- if (input.facts) {
2376
- try {
2377
- const factsArray = JSON.parse(input.facts);
2378
- if (factsArray.length >= 2)
2379
- score += 0.15;
2380
- else if (factsArray.length === 1)
2381
- score += 0.05;
2382
- } catch {
2383
- if (input.facts.length > 20)
2384
- score += 0.05;
2385
- }
2386
- }
2387
- if (input.concepts) {
2388
- try {
2389
- const conceptsArray = JSON.parse(input.concepts);
2390
- if (conceptsArray.length >= 1)
2391
- score += 0.1;
2392
- } catch {
2393
- if (input.concepts.length > 10)
2394
- score += 0.05;
2395
- }
2396
- }
2397
- const modifiedCount = input.filesModified?.length ?? 0;
2398
- if (modifiedCount >= 3)
2399
- score += 0.2;
2400
- else if (modifiedCount >= 1)
2401
- score += 0.1;
2402
- if (input.isDuplicate) {
2403
- score -= 0.3;
2404
- }
2405
- return Math.max(0, Math.min(1, score));
2406
- }
2407
- function meetsQualityThreshold(input) {
2408
- return scoreQuality(input) >= QUALITY_THRESHOLD;
2409
- }
2410
-
2411
- // src/capture/dedup.ts
2412
- function tokenise(text) {
2413
- const cleaned = text.toLowerCase().replace(/[^a-z0-9\s]/g, " ").trim();
2414
- const tokens = cleaned.split(/\s+/).filter((t) => t.length > 0);
2415
- return new Set(tokens);
2416
- }
2417
- function jaccardSimilarity(a, b) {
2418
- const tokensA = tokenise(a);
2419
- const tokensB = tokenise(b);
2420
- if (tokensA.size === 0 && tokensB.size === 0)
2421
- return 1;
2422
- if (tokensA.size === 0 || tokensB.size === 0)
2423
- return 0;
2424
- let intersectionSize = 0;
2425
- for (const token of tokensA) {
2426
- if (tokensB.has(token))
2427
- intersectionSize++;
2428
- }
2429
- const unionSize = tokensA.size + tokensB.size - intersectionSize;
2430
- if (unionSize === 0)
2431
- return 0;
2432
- return intersectionSize / unionSize;
2433
- }
2434
- var DEDUP_THRESHOLD = 0.8;
2435
- function findDuplicate(newTitle, candidates) {
2436
- let bestMatch = null;
2437
- let bestScore = 0;
2438
- for (const candidate of candidates) {
2439
- const similarity = jaccardSimilarity(newTitle, candidate.title);
2440
- if (similarity > DEDUP_THRESHOLD && similarity > bestScore) {
2441
- bestScore = similarity;
2442
- bestMatch = candidate;
2443
- }
2444
- }
2445
- return bestMatch;
2446
- }
2447
-
2448
- // src/capture/facts.ts
2449
- var FACT_ELIGIBLE_TYPES = new Set([
2450
- "bugfix",
2451
- "decision",
2452
- "discovery",
2453
- "pattern",
2454
- "feature",
2455
- "refactor",
2456
- "change"
2457
- ]);
2458
- function buildStructuredFacts(input) {
2459
- const seedFacts = dedupeFacts(input.facts ?? []);
2460
- if (!FACT_ELIGIBLE_TYPES.has(input.type)) {
2461
- return seedFacts;
2462
- }
2463
- const derived = [...seedFacts];
2464
- if (seedFacts.length === 0 && looksMeaningful(input.title)) {
2465
- derived.push(input.title.trim());
2466
- }
2467
- for (const sentence of extractNarrativeFacts(input.narrative)) {
2468
- derived.push(sentence);
2469
- }
2470
- const fileFact = buildFilesFact(input.filesModified);
2471
- if (fileFact) {
2472
- derived.push(fileFact);
2473
- }
2474
- return dedupeFacts(derived).slice(0, 4);
2475
- }
2476
- function extractNarrativeFacts(narrative) {
2477
- if (!narrative)
2478
- return [];
2479
- const cleaned = narrative.replace(/\s+/g, " ").trim();
2480
- if (cleaned.length < 24)
2481
- return [];
2482
- const parts = cleaned.split(/(?<=[.!?;])\s+/).map((part) => part.trim().replace(/[.!?;]+$/, "")).filter(Boolean).filter(looksMeaningful);
2483
- return parts.slice(0, 2);
2484
- }
2485
- function buildFilesFact(filesModified) {
2486
- if (!filesModified || filesModified.length === 0)
2487
- return null;
2488
- const cleaned = filesModified.map((file) => file.trim()).filter(Boolean).slice(0, 3);
2489
- if (cleaned.length === 0)
2490
- return null;
2491
- if (cleaned.length === 1) {
2492
- return `Touched ${cleaned[0]}`;
2493
- }
2494
- return `Touched ${cleaned.join(", ")}`;
2495
- }
2496
- function dedupeFacts(facts) {
2497
- const seen = new Set;
2498
- const result = [];
2499
- for (const fact of facts) {
2500
- const cleaned = fact.trim().replace(/\s+/g, " ");
2501
- if (!looksMeaningful(cleaned))
2502
- continue;
2503
- const key = cleaned.toLowerCase().replace(/\([^)]*\)/g, "").replace(/\s+/g, " ").trim();
2504
- if (!key || seen.has(key))
2505
- continue;
2506
- seen.add(key);
2507
- result.push(cleaned);
2508
- }
2509
- return result;
2510
- }
2511
- function looksMeaningful(value) {
2512
- const cleaned = value.trim();
2513
- if (cleaned.length < 12)
2514
- return false;
2515
- if (/^[A-Za-z0-9_.\-\/]+\.[A-Za-z0-9]+$/.test(cleaned))
2516
- return false;
2517
- if (/^(updated|modified|edited|changed|touched)\s+[A-Za-z0-9_.\-\/]+$/i.test(cleaned))
2518
- return false;
2519
- return true;
2520
- }
2521
-
2522
- // src/capture/recurrence.ts
2523
- var DISTANCE_THRESHOLD = 0.15;
2524
- async function detectRecurrence(db, config, observation) {
2525
- if (observation.type !== "bugfix") {
2526
- return { patternCreated: false };
2527
- }
2528
- if (!db.vecAvailable) {
2529
- return { patternCreated: false };
2530
- }
2531
- const text = composeEmbeddingText(observation);
2532
- const embedding = await embedText(text);
2533
- if (!embedding) {
2534
- return { patternCreated: false };
2535
- }
2536
- const vecResults = db.searchVec(embedding, null, ["active", "aging", "pinned"], 10);
2537
- for (const match of vecResults) {
2538
- if (match.observation_id === observation.id)
2539
- continue;
2540
- if (match.distance > DISTANCE_THRESHOLD)
2541
- continue;
2542
- const matched = db.getObservationById(match.observation_id);
2543
- if (!matched)
2544
- continue;
2545
- if (matched.type !== "bugfix")
2546
- continue;
2547
- if (matched.session_id === observation.session_id)
2548
- continue;
2549
- if (await patternAlreadyExists(db, observation, matched))
2550
- continue;
2551
- let matchedProjectName;
2552
- if (matched.project_id !== observation.project_id) {
2553
- const proj = db.getProjectById(matched.project_id);
2554
- if (proj)
2555
- matchedProjectName = proj.name;
2556
- }
2557
- const similarity = 1 - match.distance;
2558
- const result = await saveObservation(db, config, {
2559
- type: "pattern",
2560
- title: `Recurring bugfix: ${observation.title}`,
2561
- 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.`,
2562
- facts: [
2563
- `First seen: ${matched.created_at.split("T")[0]}`,
2564
- `Recurred: ${observation.created_at.split("T")[0]}`,
2565
- `Similarity: ${(similarity * 100).toFixed(0)}%`
2566
- ],
2567
- concepts: mergeConceptsFromBoth(observation, matched),
2568
- cwd: process.cwd(),
2569
- session_id: observation.session_id ?? undefined
2570
- });
2571
- if (result.success && result.observation_id) {
2572
- return {
2573
- patternCreated: true,
2574
- patternId: result.observation_id,
2575
- matchedObservationId: matched.id,
2576
- matchedProjectName,
2577
- matchedTitle: matched.title,
2578
- similarity
2579
- };
2580
- }
2581
- }
2582
- return { patternCreated: false };
2583
- }
2584
- async function patternAlreadyExists(db, obs1, obs2) {
2585
- const recentPatterns = db.db.query(`SELECT * FROM observations
2586
- WHERE type = 'pattern' AND lifecycle IN ('active', 'aging', 'pinned')
2587
- AND title LIKE ?
2588
- ORDER BY created_at_epoch DESC LIMIT 5`).all(`%${obs1.title.slice(0, 30)}%`);
2589
- for (const p of recentPatterns) {
2590
- if (p.narrative?.includes(obs2.title.slice(0, 30)))
2591
- return true;
2592
- }
2593
- return false;
2594
- }
2595
- function mergeConceptsFromBoth(obs1, obs2) {
2596
- const concepts = new Set;
2597
- for (const obs of [obs1, obs2]) {
2598
- if (obs.concepts) {
2599
- try {
2600
- const parsed = JSON.parse(obs.concepts);
2601
- if (Array.isArray(parsed)) {
2602
- for (const c2 of parsed) {
2603
- if (typeof c2 === "string")
2604
- concepts.add(c2);
2605
- }
2606
- }
2607
- } catch {}
2608
- }
2609
- }
2610
- return [...concepts];
2611
- }
2612
-
2613
- // src/capture/conflict.ts
2614
- var SIMILARITY_THRESHOLD = 0.25;
2615
- async function detectDecisionConflict(db, observation) {
2616
- if (observation.type !== "decision") {
2617
- return { hasConflict: false };
2618
- }
2619
- if (!observation.narrative || observation.narrative.trim().length < 20) {
2620
- return { hasConflict: false };
2621
- }
2622
- if (db.vecAvailable) {
2623
- return detectViaVec(db, observation);
2624
- }
2625
- return detectViaFts(db, observation);
2626
- }
2627
- async function detectViaVec(db, observation) {
2628
- const text = composeEmbeddingText(observation);
2629
- const embedding = await embedText(text);
2630
- if (!embedding)
2631
- return { hasConflict: false };
2632
- const results = db.searchVec(embedding, observation.project_id, ["active", "aging", "pinned"], 10);
2633
- for (const match of results) {
2634
- if (match.observation_id === observation.id)
2635
- continue;
2636
- if (match.distance > SIMILARITY_THRESHOLD)
2637
- continue;
2638
- const existing = db.getObservationById(match.observation_id);
2639
- if (!existing)
2640
- continue;
2641
- if (existing.type !== "decision")
2642
- continue;
2643
- if (!existing.narrative)
2644
- continue;
2645
- const conflict = narrativesConflict(observation.narrative, existing.narrative);
2646
- if (conflict) {
2647
- return {
2648
- hasConflict: true,
2649
- conflictingId: existing.id,
2650
- conflictingTitle: existing.title,
2651
- reason: conflict
2652
- };
2653
- }
2654
- }
2655
- return { hasConflict: false };
2656
- }
2657
- async function detectViaFts(db, observation) {
2658
- const keywords = observation.title.split(/\s+/).filter((w) => w.length > 3).slice(0, 5).join(" ");
2659
- if (!keywords)
2660
- return { hasConflict: false };
2661
- const ftsResults = db.searchFts(keywords, observation.project_id, ["active", "aging", "pinned"], 10);
2662
- for (const match of ftsResults) {
2663
- if (match.id === observation.id)
2664
- continue;
2665
- const existing = db.getObservationById(match.id);
2666
- if (!existing)
2667
- continue;
2668
- if (existing.type !== "decision")
2669
- continue;
2670
- if (!existing.narrative)
2671
- continue;
2672
- const conflict = narrativesConflict(observation.narrative, existing.narrative);
2673
- if (conflict) {
2674
- return {
2675
- hasConflict: true,
2676
- conflictingId: existing.id,
2677
- conflictingTitle: existing.title,
2678
- reason: conflict
2679
- };
2680
- }
2681
- }
2682
- return { hasConflict: false };
2683
- }
2684
- function narrativesConflict(narrative1, narrative2) {
2685
- const n1 = narrative1.toLowerCase();
2686
- const n2 = narrative2.toLowerCase();
2687
- const opposingPairs = [
2688
- [["should use", "decided to use", "chose", "prefer", "went with"], ["should not", "decided against", "avoid", "rejected", "don't use"]],
2689
- [["enable", "turn on", "activate", "add"], ["disable", "turn off", "deactivate", "remove"]],
2690
- [["increase", "more", "higher", "scale up"], ["decrease", "less", "lower", "scale down"]],
2691
- [["keep", "maintain", "preserve"], ["replace", "migrate", "switch from", "deprecate"]]
2692
- ];
2693
- for (const [positive, negative] of opposingPairs) {
2694
- const n1HasPositive = positive.some((w) => n1.includes(w));
2695
- const n1HasNegative = negative.some((w) => n1.includes(w));
2696
- const n2HasPositive = positive.some((w) => n2.includes(w));
2697
- const n2HasNegative = negative.some((w) => n2.includes(w));
2698
- if (n1HasPositive && n2HasNegative || n1HasNegative && n2HasPositive) {
2699
- return "Narratives suggest opposing conclusions on a similar topic";
2700
- }
2701
- }
2702
- return null;
2703
- }
2704
-
2705
- // src/sync/targets.ts
2706
- function isFleetProjectName(projectName, config) {
2707
- const fleetProjectName = config.fleet?.project_name ?? "shared-experience";
2708
- if (!projectName || !fleetProjectName)
2709
- return false;
2710
- return projectName.trim().toLowerCase() === fleetProjectName.trim().toLowerCase();
2711
- }
2712
- function hasFleetTarget(config) {
2713
- return Boolean(config.fleet?.namespace?.trim() && config.fleet?.api_key?.trim() && (config.fleet?.project_name ?? "shared-experience").trim());
2714
- }
2715
- function resolveSyncTarget(config, projectName) {
2716
- if (isFleetProjectName(projectName, config) && hasFleetTarget(config)) {
2717
- return {
2718
- key: `fleet:${config.fleet.namespace}`,
2719
- apiKey: config.fleet.api_key,
2720
- namespace: config.fleet.namespace,
2721
- siteId: config.site_id,
2722
- isFleet: true
2723
- };
2724
- }
2725
- return {
2726
- key: `default:${config.namespace}`,
2727
- apiKey: config.candengo_api_key,
2728
- namespace: config.namespace,
2729
- siteId: config.site_id,
2730
- isFleet: false
2731
- };
2732
- }
2733
-
2734
- // src/tools/save.ts
2735
- var VALID_TYPES = [
2736
- "bugfix",
2737
- "discovery",
2738
- "decision",
2739
- "pattern",
2740
- "change",
2741
- "feature",
2742
- "refactor",
2743
- "digest",
2744
- "standard",
2745
- "message"
2746
- ];
2747
- async function saveObservation(db, config, input) {
2748
- if (!VALID_TYPES.includes(input.type)) {
2749
- return {
2750
- success: false,
2751
- reason: `Invalid type '${input.type}'. Must be one of: ${VALID_TYPES.join(", ")}`
2752
- };
2753
- }
2754
- if (!input.title || input.title.trim().length === 0) {
2755
- return { success: false, reason: "Title is required" };
2756
- }
2757
- const cwd = input.cwd ?? process.cwd();
2758
- const touchedPaths = [...input.files_read ?? [], ...input.files_modified ?? []];
2759
- const detected = touchedPaths.length > 0 ? detectProjectFromTouchedPaths(touchedPaths, cwd) : detectProject(cwd);
2760
- const project = db.upsertProject({
2761
- canonical_id: detected.canonical_id,
2762
- name: detected.name,
2763
- local_path: detected.local_path,
2764
- remote_url: detected.remote_url
2765
- });
2766
- const customPatterns = config.scrubbing.enabled ? config.scrubbing.custom_patterns : [];
2767
- const title = config.scrubbing.enabled ? scrubSecrets(input.title, customPatterns) : input.title;
2768
- const narrative = input.narrative ? config.scrubbing.enabled ? scrubSecrets(input.narrative, customPatterns) : input.narrative : null;
2769
- const conceptsJson = input.concepts ? JSON.stringify(input.concepts) : null;
2770
- const filesRead = input.files_read ? input.files_read.map((f) => toRelativePath(f, cwd)) : null;
2771
- const filesModified = input.files_modified ? input.files_modified.map((f) => toRelativePath(f, cwd)) : null;
2772
- const structuredFacts = buildStructuredFacts({
2773
- type: input.type,
2774
- title: input.title,
2775
- narrative: input.narrative,
2776
- facts: input.facts,
2777
- filesModified
2778
- });
2779
- const factsJson = structuredFacts.length > 0 ? config.scrubbing.enabled ? scrubSecrets(JSON.stringify(structuredFacts), customPatterns) : JSON.stringify(structuredFacts) : null;
2780
- const filesReadJson = filesRead ? JSON.stringify(filesRead) : null;
2781
- const filesModifiedJson = filesModified ? JSON.stringify(filesModified) : null;
2782
- const fleetProject = isFleetProjectName(project.name, config);
2783
- let sensitivity = input.sensitivity ?? (fleetProject ? "shared" : config.scrubbing.default_sensitivity);
2784
- if (config.scrubbing.enabled && containsSecrets([input.title, input.narrative, JSON.stringify(input.facts)].filter(Boolean).join(" "), customPatterns)) {
2785
- if (sensitivity === "shared") {
2786
- sensitivity = "personal";
2787
- }
2788
- }
2789
- const oneDayAgo = Math.floor(Date.now() / 1000) - 86400;
2790
- const recentObs = db.getRecentObservations(project.id, oneDayAgo);
2791
- const candidates = recentObs.map((o) => ({
2792
- id: o.id,
2793
- title: o.title
2794
- }));
2795
- const duplicate = findDuplicate(title, candidates);
2796
- const qualityInput = {
2797
- type: input.type,
2798
- title,
2799
- narrative,
2800
- facts: factsJson,
2801
- concepts: conceptsJson,
2802
- filesRead,
2803
- filesModified,
2804
- isDuplicate: duplicate !== null
2805
- };
2806
- const qualityScore = scoreQuality(qualityInput);
2807
- if (!meetsQualityThreshold(qualityInput)) {
2808
- return {
2809
- success: false,
2810
- quality_score: qualityScore,
2811
- reason: `Quality score ${qualityScore.toFixed(2)} below threshold`
2812
- };
2813
- }
2814
- if (duplicate) {
2815
- return {
2816
- success: true,
2817
- merged_into: duplicate.id,
2818
- quality_score: qualityScore,
2819
- reason: `Merged into existing observation #${duplicate.id}`
2820
- };
2821
- }
2822
- const sourcePromptNumber = input.source_prompt_number ?? (input.session_id ? db.getLatestSessionPromptNumber(input.session_id) : null);
2823
- const obs = db.insertObservation({
2824
- session_id: input.session_id ?? null,
2825
- project_id: project.id,
2826
- type: input.type,
2827
- title,
2828
- narrative,
2829
- facts: factsJson,
2830
- concepts: conceptsJson,
2831
- files_read: filesReadJson,
2832
- files_modified: filesModifiedJson,
2833
- quality: qualityScore,
2834
- lifecycle: "active",
2835
- sensitivity,
2836
- user_id: config.user_id,
2837
- device_id: config.device_id,
2838
- agent: input.agent ?? "claude-code",
2839
- source_tool: input.source_tool ?? null,
2840
- source_prompt_number: sourcePromptNumber
2841
- });
2842
- db.addToOutbox("observation", obs.id);
2843
- if (db.vecAvailable) {
2844
- try {
2845
- const text = composeEmbeddingText(obs);
2846
- const embedding = await embedText(text);
2847
- if (embedding) {
2848
- db.vecInsert(obs.id, embedding);
2849
- }
2850
- } catch {}
2851
- }
2852
- let recallHint;
2853
- if (input.type === "bugfix") {
2854
- try {
2855
- const recurrence = await detectRecurrence(db, config, obs);
2856
- if (recurrence.patternCreated && recurrence.matchedTitle) {
2857
- const projectLabel = recurrence.matchedProjectName ? ` in ${recurrence.matchedProjectName}` : "";
2858
- recallHint = `You solved a similar issue${projectLabel}: "${recurrence.matchedTitle}"`;
2859
- }
2860
- } catch {}
2861
- }
2862
- let conflictWarning;
2863
- if (input.type === "decision") {
2864
- try {
2865
- const conflict = await detectDecisionConflict(db, obs);
2866
- if (conflict.hasConflict && conflict.conflictingTitle) {
2867
- conflictWarning = `Potential conflict with existing decision: "${conflict.conflictingTitle}" — ${conflict.reason}`;
2868
- }
2869
- } catch {}
2870
- }
2871
- return {
2872
- success: true,
2873
- observation_id: obs.id,
2874
- quality_score: qualityScore,
2875
- recall_hint: recallHint,
2876
- conflict_warning: conflictWarning
2877
- };
2878
- }
2879
- function toRelativePath(filePath, projectRoot) {
2880
- if (!isAbsolute(filePath))
2881
- return filePath;
2882
- const rel = relative(projectRoot, filePath);
2883
- if (rel.startsWith(".."))
2884
- return filePath;
2885
- return rel;
2886
- }
2887
-
2888
- // src/capture/transcript.ts
2889
- function resolveTranscriptPath(sessionId, cwd, transcriptPath) {
2890
- if (transcriptPath)
2891
- return transcriptPath;
2892
- const encodedCwd = cwd.replace(/\//g, "-");
2893
- return join3(homedir2(), ".claude", "projects", encodedCwd, `${sessionId}.jsonl`);
2894
- }
2895
- function readTranscript(sessionId, cwd, transcriptPath) {
2896
- const path = resolveTranscriptPath(sessionId, cwd, transcriptPath);
2897
- if (!existsSync3(path))
2898
- return [];
2899
- let raw;
2900
- try {
2901
- raw = readFileSync3(path, "utf-8");
2902
- } catch {
2903
- return [];
2904
- }
2905
- const messages = [];
2906
- for (const line of raw.split(`
2907
- `)) {
2908
- if (!line.trim())
2909
- continue;
2910
- let entry;
2911
- try {
2912
- entry = JSON.parse(line);
2913
- } catch {
2914
- continue;
2915
- }
2916
- const role = entry.role;
2917
- if (role !== "user" && role !== "assistant")
2918
- continue;
2919
- const content = entry.content;
2920
- if (typeof content === "string") {
2921
- messages.push({ role, text: content });
2922
- continue;
2923
- }
2924
- if (Array.isArray(content)) {
2925
- const textParts = [];
2926
- for (const block of content) {
2927
- if (block.type === "text" && typeof block.text === "string") {
2928
- textParts.push(block.text);
2929
- }
2930
- }
2931
- if (textParts.length > 0) {
2932
- messages.push({ role, text: textParts.join(`
2933
- `) });
2934
- }
2935
- }
2936
- }
2937
- return messages;
2938
- }
2939
- function resolveHistoryPath(historyPath) {
2940
- if (historyPath)
2941
- return historyPath;
2942
- const override = process.env["ENGRM_CLAUDE_HISTORY_PATH"];
2943
- if (override)
2944
- return override;
2945
- return join3(homedir2(), ".claude", "history.jsonl");
2946
- }
2947
- function readHistoryFallback(sessionId, cwd, opts) {
2948
- const path = resolveHistoryPath(opts?.historyPath);
2949
- if (!existsSync3(path))
2950
- return [];
2951
- let raw;
2952
- try {
2953
- raw = readFileSync3(path, "utf-8");
2954
- } catch {
2955
- return [];
2956
- }
2957
- const targetCanonical = detectProject(cwd).canonical_id;
2958
- const windowStart = Math.max(0, (opts?.startedAtEpoch ?? Math.floor(Date.now() / 1000) - 6 * 3600) - 600);
2959
- const windowEnd = (opts?.completedAtEpoch ?? Math.floor(Date.now() / 1000)) + 600;
2960
- const entries = [];
2961
- for (const line of raw.split(`
2962
- `)) {
2963
- if (!line.trim())
2964
- continue;
2965
- let entry;
2966
- try {
2967
- entry = JSON.parse(line);
2968
- } catch {
2969
- continue;
2970
- }
2971
- if (typeof entry?.display !== "string" || typeof entry?.timestamp !== "number")
2972
- continue;
2973
- const createdAtEpoch = Math.floor(entry.timestamp / 1000);
2974
- entries.push({
2975
- display: entry.display.trim(),
2976
- project: typeof entry.project === "string" ? entry.project : "",
2977
- sessionId: typeof entry.sessionId === "string" ? entry.sessionId : "",
2978
- timestamp: createdAtEpoch
2979
- });
2980
- }
2981
- const bySession = entries.filter((entry) => entry.display.length > 0 && entry.sessionId === sessionId).sort((a, b) => a.timestamp - b.timestamp);
2982
- if (bySession.length > 0) {
2983
- return dedupeHistoryMessages(bySession.map((entry) => ({
2984
- role: "user",
2985
- text: entry.display,
2986
- createdAtEpoch: entry.timestamp
2987
- })));
2988
- }
2989
- const byProjectAndWindow = entries.filter((entry) => {
2990
- if (entry.display.length === 0)
2991
- return false;
2992
- if (entry.timestamp < windowStart || entry.timestamp > windowEnd)
2993
- return false;
2994
- if (!entry.project)
2995
- return false;
2996
- return detectProject(entry.project).canonical_id === targetCanonical;
2997
- }).sort((a, b) => a.timestamp - b.timestamp);
2998
- return dedupeHistoryMessages(byProjectAndWindow.map((entry) => ({
2999
- role: "user",
3000
- text: entry.display,
3001
- createdAtEpoch: entry.timestamp
3002
- })));
3003
- }
3004
- async function syncTranscriptChat(db, config, sessionId, cwd, transcriptPath) {
3005
- const session = db.getSessionById(sessionId);
3006
- const transcriptMessages = readTranscript(sessionId, cwd, transcriptPath).map((message) => ({
3007
- ...message,
3008
- text: message.text.trim()
3009
- })).filter((message) => message.text.length > 0);
3010
- const messages = transcriptMessages.length > 0 ? transcriptMessages.map((message, index) => ({
3011
- ...message,
3012
- sourceKind: "transcript",
3013
- transcriptIndex: index + 1,
3014
- createdAtEpoch: null,
3015
- remoteSourceId: null
3016
- })) : readHistoryFallback(sessionId, cwd, {
3017
- startedAtEpoch: session?.started_at_epoch ?? null,
3018
- completedAtEpoch: session?.completed_at_epoch ?? null
3019
- }).map((message) => ({
3020
- role: message.role,
3021
- text: message.text,
3022
- sourceKind: "hook",
3023
- transcriptIndex: null,
3024
- createdAtEpoch: message.createdAtEpoch,
3025
- remoteSourceId: buildHistorySourceId(sessionId, message.createdAtEpoch, message.text)
3026
- }));
3027
- if (messages.length === 0)
3028
- return { imported: 0, total: 0 };
3029
- const projectId = session?.project_id ?? null;
3030
- const now = Math.floor(Date.now() / 1000);
3031
- let imported = 0;
3032
- for (let index = 0;index < messages.length; index++) {
3033
- const message = messages[index];
3034
- const transcriptIndex = message.transcriptIndex ?? index + 1;
3035
- if (message.sourceKind === "transcript" && db.getTranscriptChatMessage(sessionId, transcriptIndex)) {
3036
- continue;
3037
- }
3038
- if (message.remoteSourceId && db.getChatMessageByRemoteSourceId(message.remoteSourceId)) {
3039
- continue;
3040
- }
3041
- const createdAtEpoch = message.createdAtEpoch ?? Math.max(0, now - (messages.length - transcriptIndex));
3042
- const row = db.insertChatMessage({
3043
- session_id: sessionId,
3044
- project_id: projectId,
3045
- role: message.role,
3046
- content: message.text,
3047
- user_id: config.user_id,
3048
- device_id: config.device_id,
3049
- agent: "claude-code",
3050
- created_at_epoch: createdAtEpoch,
3051
- remote_source_id: message.remoteSourceId,
3052
- source_kind: message.sourceKind,
3053
- transcript_index: message.transcriptIndex
3054
- });
3055
- db.addToOutbox("chat_message", row.id);
3056
- if (message.role === "user") {
3057
- db.insertUserPrompt({
3058
- session_id: sessionId,
3059
- project_id: projectId,
3060
- prompt: message.text,
3061
- cwd,
3062
- user_id: config.user_id,
3063
- device_id: config.device_id,
3064
- agent: "claude-code",
3065
- created_at_epoch: createdAtEpoch
3066
- });
3067
- }
3068
- if (db.vecAvailable) {
3069
- const embedding = await embedText(composeChatEmbeddingText(message.text));
3070
- if (embedding) {
3071
- db.vecChatInsert(row.id, embedding);
3072
- }
3073
- }
3074
- imported++;
3075
- }
3076
- return { imported, total: messages.length };
3077
- }
3078
- function dedupeHistoryMessages(messages) {
3079
- const deduped = [];
3080
- for (const message of messages) {
3081
- const compact = message.text.replace(/\s+/g, " ").trim();
3082
- if (!compact)
3083
- continue;
3084
- const previous = deduped[deduped.length - 1];
3085
- if (previous && previous.text.replace(/\s+/g, " ").trim() === compact)
3086
- continue;
3087
- deduped.push({ ...message, text: compact });
3088
- }
3089
- return deduped;
3090
- }
3091
- function buildHistorySourceId(sessionId, createdAtEpoch, text) {
3092
- const digest = createHash3("sha1").update(text).digest("hex").slice(0, 12);
3093
- return `history:${sessionId}:${createdAtEpoch}:${digest}`;
3094
- }
3095
- function truncateTranscript(messages, maxBytes = 50000) {
3096
- const lines = [];
3097
- for (const msg of messages) {
3098
- lines.push(`[${msg.role}]: ${msg.text}`);
3099
- }
3100
- const full = lines.join(`
3101
- `);
3102
- if (Buffer.byteLength(full, "utf-8") <= maxBytes)
3103
- return full;
3104
- let result = "";
3105
- for (let i = lines.length - 1;i >= 0; i--) {
3106
- const candidate = lines[i] + `
3107
- ` + result;
3108
- if (Buffer.byteLength(candidate, "utf-8") > maxBytes)
3109
- break;
3110
- result = candidate;
3111
- }
3112
- return result.trim();
3113
- }
3114
- async function analyzeTranscript(config, transcript, sessionId) {
3115
- if (!config.candengo_url || !config.candengo_api_key)
3116
- return null;
3117
- const url = `${config.candengo_url}/v1/mem/transcript-analysis`;
3118
- const controller = new AbortController;
3119
- const timeout = setTimeout(() => controller.abort(), 30000);
3120
- try {
3121
- const response = await fetch(url, {
3122
- method: "POST",
3123
- headers: {
3124
- "Content-Type": "application/json",
3125
- Authorization: `Bearer ${config.candengo_api_key}`
3126
- },
3127
- body: JSON.stringify({
3128
- transcript,
3129
- session_id: sessionId
3130
- }),
3131
- signal: controller.signal
3132
- });
3133
- if (!response.ok)
3134
- return null;
3135
- const data = await response.json();
3136
- if (!Array.isArray(data.plans) || !Array.isArray(data.decisions) || !Array.isArray(data.insights)) {
3137
- return null;
3138
- }
3139
- return data;
3140
- } catch {
3141
- return null;
3142
- } finally {
3143
- clearTimeout(timeout);
3144
- }
3145
- }
3146
- async function saveTranscriptResults(db, config, results, sessionId, cwd) {
3147
- let saved = 0;
3148
- const items = [
3149
- ...results.plans.map((item) => ({ item, type: "decision" })),
3150
- ...results.decisions.map((item) => ({ item, type: "decision" })),
3151
- ...results.insights.map((item) => ({ item, type: "discovery" }))
3152
- ];
3153
- for (const { item, type } of items) {
3154
- if (!item.title || item.title.trim().length === 0)
3155
- continue;
3156
- const result = await saveObservation(db, config, {
3157
- type,
3158
- title: item.title.slice(0, 80),
3159
- narrative: item.narrative,
3160
- concepts: item.concepts,
3161
- session_id: sessionId,
3162
- cwd
3163
- });
3164
- if (result.success)
3165
- saved++;
3166
- }
3167
- return saved;
3168
- }
3169
-
3170
- // src/tools/recent-chat.ts
3171
- function summarizeChatSources(messages) {
3172
- return messages.reduce((summary, message) => {
3173
- summary[getChatCaptureOrigin(message)] += 1;
3174
- return summary;
3175
- }, { transcript: 0, history: 0, hook: 0 });
3176
- }
3177
- function getChatCoverageState(messagesOrSummary) {
3178
- const summary = Array.isArray(messagesOrSummary) ? summarizeChatSources(messagesOrSummary) : messagesOrSummary;
3179
- if (summary.transcript > 0)
3180
- return "transcript-backed";
3181
- if (summary.history > 0)
3182
- return "history-backed";
3183
- if (summary.hook > 0)
3184
- return "hook-only";
3185
- return "none";
3186
- }
3187
- function getChatCaptureOrigin(message) {
3188
- if (message.source_kind === "transcript")
3189
- return "transcript";
3190
- if (typeof message.remote_source_id === "string" && message.remote_source_id.startsWith("history:")) {
3191
- return "history";
3192
- }
3193
- return "hook";
3194
- }
3195
-
3196
- // src/tools/session-story.ts
3197
- function getSessionStory(db, input) {
3198
- const session = db.getSessionById(input.session_id);
3199
- const summary = db.getSessionSummary(input.session_id);
3200
- const prompts = db.getSessionUserPrompts(input.session_id, 50);
3201
- const chatMessages = db.getSessionChatMessages(input.session_id, 50);
3202
- const toolEvents = db.getSessionToolEvents(input.session_id, 100);
3203
- const allObservations = db.getObservationsBySession(input.session_id);
3204
- const handoffs = allObservations.filter((obs) => looksLikeHandoff(obs));
3205
- const rollingHandoffDrafts = handoffs.filter((obs) => isDraftHandoff(obs));
3206
- const savedHandoffs = handoffs.filter((obs) => !isDraftHandoff(obs));
3207
- const observations = allObservations.filter((obs) => !looksLikeHandoff(obs));
3208
- const metrics = db.getSessionMetrics(input.session_id);
3209
- const projectName = session?.project_id !== null && session?.project_id !== undefined ? db.getProjectById(session.project_id)?.name ?? null : null;
3210
- const latestRequest = prompts[prompts.length - 1]?.prompt?.trim() || summary?.request?.trim() || null;
3211
- return {
3212
- session,
3213
- project_name: projectName,
3214
- summary,
3215
- prompts,
3216
- chat_messages: chatMessages,
3217
- chat_source_summary: summarizeChatSources(chatMessages),
3218
- chat_coverage_state: getChatCoverageState(chatMessages),
3219
- tool_events: toolEvents,
3220
- observations,
3221
- handoffs,
3222
- saved_handoffs: savedHandoffs,
3223
- rolling_handoff_drafts: rollingHandoffDrafts,
3224
- metrics,
3225
- capture_state: classifyCaptureState({
3226
- hasSummary: Boolean(summary?.request || summary?.completed),
3227
- promptCount: prompts.length,
3228
- toolEventCount: toolEvents.length
3229
- }),
3230
- capture_gaps: buildCaptureGaps({
3231
- promptCount: prompts.length,
3232
- toolEventCount: toolEvents.length,
3233
- toolCallsCount: metrics?.tool_calls_count ?? 0,
3234
- observationCount: observations.length,
3235
- hasSummary: Boolean(summary?.request || summary?.completed)
3236
- }),
3237
- latest_request: latestRequest,
3238
- recent_outcomes: collectRecentOutcomes(observations),
3239
- hot_files: collectHotFiles(observations),
3240
- provenance_summary: collectProvenanceSummary(observations)
3241
- };
3242
- }
3243
- function classifyCaptureState(input) {
3244
- if (input.promptCount > 0 && input.toolEventCount > 0)
3245
- return "rich";
3246
- if (input.promptCount > 0 || input.toolEventCount > 0)
3247
- return "partial";
3248
- if (input.hasSummary)
3249
- return "summary-only";
3250
- return "legacy";
3251
- }
3252
- function buildCaptureGaps(input) {
3253
- const gaps = [];
3254
- if (input.promptCount === 0)
3255
- gaps.push("missing prompts");
3256
- if (input.toolCallsCount > 0 && input.toolEventCount === 0) {
3257
- gaps.push("missing raw tool chronology");
3258
- } else if (input.toolEventCount === 0) {
3259
- gaps.push("no tool events");
3260
- }
3261
- if (input.observationCount === 0 && input.hasSummary) {
3262
- gaps.push("summary without reusable observations");
3263
- }
3264
- return gaps;
3265
- }
3266
- function collectRecentOutcomes(observations) {
3267
- const seen = new Set;
3268
- const outcomes = [];
3269
- for (const obs of observations) {
3270
- if (!["bugfix", "feature", "refactor", "change", "decision"].includes(obs.type))
3271
- continue;
3272
- const title = obs.title.trim();
3273
- if (!title || looksLikeFileOperationTitle(title))
3274
- continue;
3275
- const normalized = title.toLowerCase().replace(/\s+/g, " ").trim();
3276
- if (seen.has(normalized))
3277
- continue;
3278
- seen.add(normalized);
3279
- outcomes.push(title);
3280
- if (outcomes.length >= 6)
3281
- break;
3282
- }
3283
- return outcomes;
3284
- }
3285
- function collectHotFiles(observations) {
3286
- const counts = new Map;
3287
- for (const obs of observations) {
3288
- for (const path of [...parseJsonArray2(obs.files_modified), ...parseJsonArray2(obs.files_read)]) {
3289
- counts.set(path, (counts.get(path) ?? 0) + 1);
3290
- }
3291
- }
3292
- return Array.from(counts.entries()).map(([path, count]) => ({ path, count })).sort((a, b) => b.count - a.count || a.path.localeCompare(b.path)).slice(0, 8);
3293
- }
3294
- function parseJsonArray2(value) {
3295
- if (!value)
3296
- return [];
3297
- try {
3298
- const parsed = JSON.parse(value);
3299
- return Array.isArray(parsed) ? parsed.filter((item) => typeof item === "string" && item.trim().length > 0) : [];
3300
- } catch {
3301
- return [];
3302
- }
3303
- }
3304
- function looksLikeFileOperationTitle(value) {
3305
- return /^(modified|updated|edited|touched|changed|extended|refactored|redesigned)\s+[A-Za-z0-9_.\-\/]+(?:\s*\([^)]*\))?$/i.test(value.trim());
3306
- }
3307
- function collectProvenanceSummary(observations) {
3308
- const counts = new Map;
3309
- for (const obs of observations) {
3310
- if (!obs.source_tool)
3311
- continue;
3312
- counts.set(obs.source_tool, (counts.get(obs.source_tool) ?? 0) + 1);
3313
- }
3314
- return Array.from(counts.entries()).map(([tool, count]) => ({ tool, count })).sort((a, b) => b.count - a.count || a.tool.localeCompare(b.tool)).slice(0, 6);
3315
- }
3316
-
3317
- // src/tools/handoffs.ts
3318
- async function upsertRollingHandoff(db, config, input) {
3319
- const resolved = resolveTargetSession(db, input.cwd, config.user_id, input.session_id);
3320
- if (!resolved.session) {
3321
- return {
3322
- success: false,
3323
- reason: "No recent session found to draft a handoff yet"
3324
- };
3325
- }
3326
- const story = getSessionStory(db, { session_id: resolved.session.session_id });
3327
- if (!story.session) {
3328
- return {
3329
- success: false,
3330
- reason: `Session ${resolved.session.session_id} not found`
3331
- };
3332
- }
3333
- const includeChat = input.include_chat === true || input.include_chat !== false && shouldAutoIncludeChat(story);
3334
- const chatLimit = Math.max(1, Math.min(input.chat_limit ?? 3, 6));
3335
- const title = `Handoff Draft: ${buildHandoffTitle(story.summary, story.latest_request)}`;
3336
- const narrative = buildHandoffNarrative(story.summary, story, {
3337
- includeChat,
3338
- chatLimit
3339
- });
3340
- const facts = buildHandoffFacts(story.summary, story);
3341
- const concepts = buildDraftHandoffConcepts(story.project_name, story.capture_state);
3342
- const existing = getSessionRollingHandoff(db, story.session.session_id);
3343
- const now = Math.floor(Date.now() / 1000);
3344
- if (existing) {
3345
- const nextFacts = JSON.stringify(facts);
3346
- const nextConcepts = JSON.stringify(concepts);
3347
- const shouldRefresh = existing.title !== title || (existing.narrative ?? null) !== narrative || (existing.facts ?? null) !== nextFacts || (existing.concepts ?? null) !== nextConcepts || now - existing.created_at_epoch >= 120;
3348
- if (!shouldRefresh) {
3349
- return {
3350
- success: true,
3351
- observation_id: existing.id,
3352
- session_id: story.session.session_id,
3353
- title: existing.title
3354
- };
3355
- }
3356
- const updated = db.updateObservationContent(existing.id, {
3357
- title,
3358
- narrative,
3359
- facts: nextFacts,
3360
- concepts: nextConcepts,
3361
- created_at_epoch: now
3362
- });
3363
- if (!updated) {
3364
- return {
3365
- success: false,
3366
- reason: "Failed to update rolling handoff draft"
3367
- };
3368
- }
3369
- db.addToOutbox("observation", updated.id);
3370
- return {
3371
- success: true,
3372
- observation_id: updated.id,
3373
- session_id: story.session.session_id,
3374
- title: updated.title
3375
- };
3376
- }
3377
- const result = await saveObservation(db, config, {
3378
- type: "message",
3379
- title,
3380
- narrative,
3381
- facts,
3382
- concepts,
3383
- session_id: story.session.session_id,
3384
- cwd: input.cwd,
3385
- agent: "engrm-handoff",
3386
- source_tool: "rolling_handoff"
3387
- });
3388
- return {
3389
- success: result.success,
3390
- observation_id: result.observation_id,
3391
- session_id: story.session.session_id,
3392
- title,
3393
- reason: result.reason
3394
- };
3395
- }
3396
- function getRecentHandoffs(db, input) {
3397
- const limit = Math.max(1, Math.min(input.limit ?? 10, 25));
3398
- const queryLimit = input.current_device_id ? Math.max(limit, Math.min(limit * 5, 50)) : limit;
3399
- const projectScoped = input.project_scoped !== false;
3400
- let projectId = null;
3401
- let projectName;
3402
- if (projectScoped) {
3403
- const cwd = input.cwd ?? process.cwd();
3404
- const detected = detectProject(cwd);
3405
- const project = db.getProjectByCanonicalId(detected.canonical_id);
3406
- if (project) {
3407
- projectId = project.id;
3408
- projectName = project.name;
3409
- }
3410
- }
3411
- const conditions = [
3412
- "o.type = 'message'",
3413
- "o.lifecycle IN ('active', 'aging', 'pinned')",
3414
- "o.superseded_by IS NULL",
3415
- `(o.title LIKE 'Handoff:%' OR o.concepts LIKE '%"handoff"%')`
3416
- ];
3417
- const params = [];
3418
- if (input.user_id) {
3419
- conditions.push("(o.sensitivity != 'personal' OR o.user_id = ?)");
3420
- params.push(input.user_id);
3421
- }
3422
- if (projectId !== null) {
3423
- conditions.push("o.project_id = ?");
3424
- params.push(projectId);
3425
- }
3426
- params.push(queryLimit);
3427
- const handoffs = db.db.query(`SELECT o.*, p.name AS project_name
3428
- FROM observations o
3429
- LEFT JOIN projects p ON p.id = o.project_id
3430
- WHERE ${conditions.join(" AND ")}
3431
- ORDER BY o.created_at_epoch DESC, o.id DESC
3432
- LIMIT ?`).all(...params);
3433
- handoffs.sort((a, b) => compareHandoffs(a, b, input.current_device_id));
3434
- return {
3435
- handoffs: handoffs.slice(0, limit),
3436
- project: projectName
3437
- };
3438
- }
3439
- function formatHandoffSource(handoff) {
3440
- const ageSeconds = Math.max(0, Math.floor(Date.now() / 1000) - handoff.created_at_epoch);
3441
- const ageLabel = ageSeconds < 3600 ? `${Math.max(1, Math.floor(ageSeconds / 60) || 1)}m ago` : ageSeconds < 86400 ? `${Math.floor(ageSeconds / 3600)}h ago` : `${Math.floor(ageSeconds / 86400)}d ago`;
3442
- return `from ${handoff.device_id} · ${ageLabel}`;
3443
- }
3444
- function isDraftHandoff(obs) {
3445
- if (obs.title.startsWith("Handoff Draft:"))
3446
- return true;
3447
- const concepts = parseJsonArray3(obs.concepts);
3448
- return concepts.includes("draft-handoff") || concepts.includes("auto-handoff");
3449
- }
3450
- function getSessionRollingHandoff(db, sessionId) {
3451
- return db.db.query(`SELECT o.*, p.name AS project_name
3452
- FROM observations o
3453
- LEFT JOIN projects p ON p.id = o.project_id
3454
- WHERE o.session_id = ?
3455
- AND o.type = 'message'
3456
- AND o.lifecycle IN ('active', 'aging', 'pinned')
3457
- AND o.superseded_by IS NULL
3458
- AND (o.title LIKE 'Handoff Draft:%' OR o.concepts LIKE '%"draft-handoff"%')
3459
- ORDER BY o.created_at_epoch DESC, o.id DESC
3460
- LIMIT 1`).get(sessionId) ?? null;
3461
- }
3462
- function compareHandoffs(a, b, currentDeviceId) {
3463
- const aDraft = isDraftHandoff(a) ? 1 : 0;
3464
- const bDraft = isDraftHandoff(b) ? 1 : 0;
3465
- if (aDraft !== bDraft)
3466
- return aDraft - bDraft;
3467
- if (currentDeviceId) {
3468
- const aOther = a.device_id !== currentDeviceId ? 1 : 0;
3469
- const bOther = b.device_id !== currentDeviceId ? 1 : 0;
3470
- if (aOther !== bOther)
3471
- return bOther - aOther;
3472
- }
3473
- if (b.created_at_epoch !== a.created_at_epoch) {
3474
- return b.created_at_epoch - a.created_at_epoch;
3475
- }
3476
- return b.id - a.id;
3477
- }
3478
- function resolveTargetSession(db, cwd, userId, sessionId) {
3479
- if (sessionId) {
3480
- const session = db.getSessionById(sessionId);
3481
- if (!session)
3482
- return { session: null };
3483
- const projectName = session.project_id ? db.getProjectById(session.project_id)?.name : undefined;
3484
- return {
3485
- session: {
3486
- ...session,
3487
- project_name: projectName ?? null,
3488
- request: db.getSessionSummary(sessionId)?.request ?? null,
3489
- completed: db.getSessionSummary(sessionId)?.completed ?? null,
3490
- current_thread: db.getSessionSummary(sessionId)?.current_thread ?? null,
3491
- capture_state: db.getSessionSummary(sessionId)?.capture_state ?? null,
3492
- recent_tool_names: db.getSessionSummary(sessionId)?.recent_tool_names ?? null,
3493
- hot_files: db.getSessionSummary(sessionId)?.hot_files ?? null,
3494
- recent_outcomes: db.getSessionSummary(sessionId)?.recent_outcomes ?? null,
3495
- prompt_count: db.getSessionUserPrompts(sessionId, 200).length,
3496
- tool_event_count: db.getSessionToolEvents(sessionId, 200).length
3497
- },
3498
- projectName: projectName ?? undefined
3499
- };
3500
- }
3501
- const detected = detectProject(cwd ?? process.cwd());
3502
- const project = db.getProjectByCanonicalId(detected.canonical_id);
3503
- const sessions = db.getRecentSessions(project?.id ?? null, 10, userId);
3504
- return {
3505
- session: sessions[0] ?? null,
3506
- projectName: project?.name
3507
- };
3508
- }
3509
- function buildHandoffTitle(summary, latestRequest, explicit) {
3510
- const chosen = explicit?.trim() || summary?.current_thread?.trim() || summary?.completed?.trim() || latestRequest?.trim() || "Current work";
3511
- return compactLine2(chosen) ?? "Current work";
3512
- }
3513
- function buildHandoffNarrative(summary, story, options) {
3514
- const sections = [];
3515
- if (summary?.request || story.latest_request) {
3516
- sections.push(`Request: ${summary?.request ?? story.latest_request}`);
3517
- }
3518
- if (summary?.current_thread) {
3519
- sections.push(`Current thread: ${summary.current_thread}`);
3520
- }
3521
- if (summary?.investigated) {
3522
- sections.push(`Investigated: ${summary.investigated}`);
3523
- }
3524
- if (summary?.learned) {
3525
- sections.push(`Learned: ${summary.learned}`);
3526
- }
3527
- if (summary?.completed) {
3528
- sections.push(`Completed: ${summary.completed}`);
3529
- }
3530
- if (summary?.next_steps) {
3531
- sections.push(`Next Steps: ${summary.next_steps}`);
3532
- }
3533
- if (story.recent_outcomes.length > 0) {
3534
- sections.push(`Recent outcomes:
3535
- ${story.recent_outcomes.slice(0, 5).map((item) => `- ${item}`).join(`
3536
- `)}`);
3537
- }
3538
- if (story.hot_files.length > 0) {
3539
- sections.push(`Hot files:
3540
- ${story.hot_files.slice(0, 5).map((file) => `- ${file.path}`).join(`
3541
- `)}`);
3542
- }
3543
- if (story.provenance_summary.length > 0) {
3544
- sections.push(`Tool trail:
3545
- ${story.provenance_summary.slice(0, 5).map((item) => `- ${item.tool}: ${item.count}`).join(`
3546
- `)}`);
3547
- }
3548
- if (options.includeChat && story.chat_messages.length > 0) {
3549
- const chatLines = story.chat_messages.slice(-options.chatLimit).map((msg) => `- [${msg.role}] ${compactLine2(msg.content) ?? msg.content.slice(0, 120)}`);
3550
- sections.push(`Chat snippets:
3551
- ${chatLines.join(`
3552
- `)}`);
3553
- }
3554
- return sections.filter(Boolean).join(`
3555
-
3556
- `);
3557
- }
3558
- function shouldAutoIncludeChat(story) {
3559
- if (story.chat_messages.length === 0)
3560
- return false;
3561
- const summary = story.summary;
3562
- const thinSummary = !summary?.completed && !summary?.current_thread && story.recent_outcomes.length < 2;
3563
- const thinChronology = story.capture_state !== "rich" || story.tool_events.length === 0;
3564
- return thinSummary || thinChronology;
3565
- }
3566
- function buildHandoffFacts(summary, story) {
3567
- const facts = [
3568
- `session_id=${story.session?.session_id ?? "unknown"}`,
3569
- `capture_state=${story.capture_state}`,
3570
- story.project_name ? `project=${story.project_name}` : null,
3571
- summary?.current_thread ? `current_thread=${summary.current_thread}` : null,
3572
- story.hot_files[0] ? `hot_file=${story.hot_files[0].path}` : null,
3573
- story.provenance_summary[0] ? `primary_tool=${story.provenance_summary[0].tool}` : null
3574
- ];
3575
- return facts.filter((item) => Boolean(item));
3576
- }
3577
- function buildDraftHandoffConcepts(projectName, captureState) {
3578
- return [
3579
- "handoff",
3580
- "draft-handoff",
3581
- "auto-handoff",
3582
- `capture:${captureState}`,
3583
- ...projectName ? [projectName] : []
3584
- ];
3585
- }
3586
- function looksLikeHandoff(obs) {
3587
- if (obs.title.startsWith("Handoff:") || obs.title.startsWith("Handoff Draft:"))
3588
- return true;
3589
- const concepts = parseJsonArray3(obs.concepts);
3590
- return concepts.includes("handoff") || concepts.includes("session-handoff") || concepts.includes("draft-handoff");
3591
- }
3592
- function parseJsonArray3(value) {
3593
- if (!value)
3594
- return [];
3595
- try {
3596
- const parsed = JSON.parse(value);
3597
- return Array.isArray(parsed) ? parsed.filter((item) => typeof item === "string" && item.trim().length > 0) : [];
3598
- } catch {
3599
- return [];
3600
- }
3601
- }
3602
- function compactLine2(value) {
3603
- const trimmed = value?.replace(/\s+/g, " ").trim();
3604
- if (!trimmed)
3605
- return null;
3606
- return trimmed.length > 120 ? `${trimmed.slice(0, 117)}...` : trimmed;
3607
- }
3608
-
3609
2150
  // hooks/user-prompt-submit.ts
3610
2151
  async function main() {
3611
2152
  const event = await parseStdinJson();
@@ -3633,7 +2174,6 @@ async function main() {
3633
2174
  device_id: config.device_id,
3634
2175
  agent: "claude-code"
3635
2176
  });
3636
- await syncTranscriptChat(db, config, event.session_id, event.cwd);
3637
2177
  const chatMessage = db.insertChatMessage({
3638
2178
  session_id: event.session_id,
3639
2179
  project_id: project.id,
@@ -3645,12 +2185,6 @@ async function main() {
3645
2185
  source_kind: "hook"
3646
2186
  });
3647
2187
  db.addToOutbox("chat_message", chatMessage.id);
3648
- if (db.vecAvailable) {
3649
- const chatEmbedding = await embedText(composeChatEmbeddingText(event.prompt));
3650
- if (chatEmbedding) {
3651
- db.vecChatInsert(chatMessage.id, chatEmbedding);
3652
- }
3653
- }
3654
2188
  const compactPrompt = event.prompt.replace(/\s+/g, " ").trim();
3655
2189
  if (compactPrompt.length >= 8) {
3656
2190
  const sessionPrompts = db.getSessionUserPrompts(event.session_id, 20);
@@ -3673,10 +2207,6 @@ async function main() {
3673
2207
  recent_outcomes: JSON.stringify(handoff.recent_outcomes)
3674
2208
  });
3675
2209
  db.addToOutbox("summary", summary.id);
3676
- await upsertRollingHandoff(db, config, {
3677
- session_id: event.session_id,
3678
- cwd: event.cwd
3679
- });
3680
2210
  }
3681
2211
  } finally {
3682
2212
  db.close();