claude-mem-lite 3.96.0 → 3.97.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 (143) hide show
  1. package/.claude-plugin/marketplace.json +1 -1
  2. package/.claude-plugin/plugin.json +1 -1
  3. package/adopt-cli.mjs +125 -51
  4. package/bash-utils.mjs +105 -29
  5. package/claudemd.mjs +63 -19
  6. package/cli/activity.mjs +36 -18
  7. package/cli/common.mjs +101 -18
  8. package/cli/doctor.mjs +29 -9
  9. package/cli.mjs +69 -10
  10. package/deep-search.mjs +63 -20
  11. package/format-utils.mjs +15 -7
  12. package/haiku-client.mjs +189 -111
  13. package/hash-utils.mjs +15 -6
  14. package/hook-context.mjs +135 -46
  15. package/hook-episode.mjs +76 -23
  16. package/hook-handoff.mjs +216 -84
  17. package/hook-llm.mjs +476 -216
  18. package/hook-memory.mjs +106 -43
  19. package/hook-optimize.mjs +272 -109
  20. package/hook-precompact.mjs +1 -1
  21. package/hook-semaphore.mjs +46 -12
  22. package/hook-shared.mjs +101 -131
  23. package/hook-update.mjs +286 -88
  24. package/hook.mjs +1207 -617
  25. package/install-metadata.mjs +246 -125
  26. package/install.mjs +1327 -917
  27. package/lib/activity.mjs +52 -29
  28. package/lib/atomic-write.mjs +23 -4
  29. package/lib/binding-probe.mjs +40 -38
  30. package/lib/browse-core.mjs +22 -8
  31. package/lib/citation-tracker.mjs +189 -79
  32. package/lib/cite-back-hint.mjs +37 -19
  33. package/lib/cite-recall-path.mjs +3 -1
  34. package/lib/cli-flags.mjs +4 -2
  35. package/lib/cli-project.mjs +8 -5
  36. package/lib/compress-core.mjs +34 -9
  37. package/lib/cooldown-path.mjs +3 -1
  38. package/lib/db-backup.mjs +33 -11
  39. package/lib/deferred-work.mjs +73 -32
  40. package/lib/delete-core.mjs +25 -9
  41. package/lib/doctor-benchmark.mjs +5 -1
  42. package/lib/doctor-drift.mjs +25 -8
  43. package/lib/edge-attribution.mjs +23 -9
  44. package/lib/env-number.mjs +7 -8
  45. package/lib/err-sampler.mjs +22 -11
  46. package/lib/error-recall-core.mjs +15 -17
  47. package/lib/events-injection.mjs +25 -8
  48. package/lib/export-columns.mjs +49 -10
  49. package/lib/fast-summary.mjs +22 -9
  50. package/lib/file-edge-match.mjs +4 -2
  51. package/lib/file-intel.mjs +38 -13
  52. package/lib/frontmatter.mjs +22 -5
  53. package/lib/get-core.mjs +78 -11
  54. package/lib/handoff-constants.mjs +18 -0
  55. package/lib/hook-stdin.mjs +10 -3
  56. package/lib/hook-stdout.mjs +21 -7
  57. package/lib/hook-telemetry.mjs +44 -17
  58. package/lib/id-routing.mjs +11 -6
  59. package/lib/import-jsonl.mjs +92 -41
  60. package/lib/inject-search-core.mjs +5 -2
  61. package/lib/injected-ids.mjs +19 -12
  62. package/lib/install-shape.mjs +47 -23
  63. package/lib/keyctx-marker.mjs +3 -1
  64. package/lib/lesson-bridge.mjs +8 -5
  65. package/lib/llm-call.mjs +76 -0
  66. package/lib/llm-provider-probe.mjs +24 -12
  67. package/lib/low-signal-patterns.mjs +25 -22
  68. package/lib/maintain-core.mjs +221 -92
  69. package/lib/mem-override.mjs +4 -2
  70. package/lib/metrics.mjs +30 -25
  71. package/lib/native-binding-hint.mjs +29 -8
  72. package/lib/observation-write.mjs +74 -19
  73. package/lib/patha-exclude-meter.mjs +10 -6
  74. package/lib/persist-reminder.mjs +22 -10
  75. package/lib/plan-reader.mjs +2 -2
  76. package/lib/private-strip.mjs +3 -3
  77. package/lib/proc-lock.mjs +15 -3
  78. package/lib/proxy-fetch.mjs +49 -13
  79. package/lib/quiet-scope.mjs +42 -0
  80. package/lib/recall-core.mjs +19 -7
  81. package/lib/recent-core.mjs +18 -5
  82. package/lib/registry-core.mjs +12 -6
  83. package/lib/release-digest.mjs +4 -1
  84. package/lib/relevance-floor.mjs +5 -2
  85. package/lib/reread-guard.mjs +16 -8
  86. package/lib/resolve-data-dir.mjs +1 -1
  87. package/lib/rrf.mjs +4 -1
  88. package/lib/save-enrich.mjs +36 -18
  89. package/lib/save-observation.mjs +78 -35
  90. package/lib/scrub-record.mjs +20 -6
  91. package/lib/search-core.mjs +456 -123
  92. package/lib/shard-gc.mjs +50 -0
  93. package/lib/startup-dashboard.mjs +20 -9
  94. package/lib/stats-core.mjs +101 -35
  95. package/lib/stats-quality.mjs +56 -20
  96. package/lib/summary-extractor.mjs +2 -2
  97. package/lib/task-imperative.mjs +12 -4
  98. package/lib/timeline-core.mjs +88 -28
  99. package/lib/tmp-fixture-sweep.mjs +32 -9
  100. package/lib/transcript-scan.mjs +20 -4
  101. package/lib/upgrade-banner.mjs +11 -7
  102. package/mem-cli.mjs +1314 -506
  103. package/memdir.mjs +53 -21
  104. package/nlp.mjs +68 -38
  105. package/npm-shrinkwrap.json +2 -2
  106. package/package.json +9 -2
  107. package/plugin-cache-guard.mjs +31 -15
  108. package/project-utils.mjs +44 -20
  109. package/registry-enricher.mjs +26 -6
  110. package/registry-github.mjs +4 -2
  111. package/registry-importer.mjs +151 -40
  112. package/registry-recommend.mjs +189 -96
  113. package/registry-retriever.mjs +229 -70
  114. package/registry-scanner.mjs +11 -6
  115. package/registry.mjs +163 -66
  116. package/rerank.mjs +18 -4
  117. package/resource-discovery.mjs +14 -4
  118. package/schema.mjs +258 -99
  119. package/scoring-sql.mjs +21 -10
  120. package/scripts/binding-probe-cli.mjs +17 -12
  121. package/scripts/hook-launcher.mjs +148 -31
  122. package/scripts/launch-preflight.mjs +5 -7
  123. package/scripts/launch.mjs +21 -9
  124. package/scripts/post-tool-recall.js +21 -5
  125. package/scripts/pre-agent-inject.js +51 -13
  126. package/scripts/pre-skill-bridge.js +22 -7
  127. package/scripts/pre-tool-recall.js +157 -77
  128. package/scripts/prompt-search-utils.mjs +70 -25
  129. package/scripts/setup.sh +16 -1
  130. package/scripts/user-prompt-search.js +238 -97
  131. package/search-engine.mjs +329 -96
  132. package/search-scoring.mjs +75 -33
  133. package/secret-scrub.mjs +48 -12
  134. package/server/fts-check.mjs +12 -9
  135. package/server.mjs +674 -243
  136. package/skip-tools.mjs +19 -8
  137. package/source-files.mjs +54 -21
  138. package/stop-words.mjs +152 -19
  139. package/synonyms.mjs +579 -164
  140. package/tfidf.mjs +130 -40
  141. package/tier.mjs +1 -1
  142. package/tool-schemas.mjs +381 -131
  143. package/utils.mjs +184 -36
package/hook-llm.mjs CHANGED
@@ -4,20 +4,47 @@
4
4
  import { basename, join } from 'path';
5
5
  import { existsSync, readFileSync, unlinkSync, readdirSync, statSync } from 'fs';
6
6
  import {
7
- jaccardSimilarity, truncate, clampImportance, computeRuleImportance,
8
- inferProject, parseJsonFromLLM, scrubSecrets,
9
- computeMinHash, estimateJaccardFromMinHash, cjkBigrams, EDIT_TOOLS, LOW_SIGNAL_TITLE, debugCatch, debugLog, OBS_BM25,
10
- getCurrentBranch, notLowSignalTitleClause,
7
+ jaccardSimilarity,
8
+ truncate,
9
+ clampImportance,
10
+ computeRuleImportance,
11
+ inferProject,
12
+ parseJsonFromLLM,
13
+ scrubSecrets,
14
+ computeMinHash,
15
+ estimateJaccardFromMinHash,
16
+ cjkBigrams,
17
+ EDIT_TOOLS,
18
+ LOW_SIGNAL_TITLE,
19
+ debugCatch,
20
+ debugLog,
21
+ OBS_BM25,
22
+ getCurrentBranch,
23
+ notLowSignalTitleClause,
11
24
  } from './utils.mjs';
12
25
  import { acquireLLMSlot, releaseLLMSlot } from './hook-semaphore.mjs';
13
26
  import { BG_LLM_TIMEOUT_MS } from './haiku-client.mjs';
14
27
  import { scrubRecord } from './lib/scrub-record.mjs';
15
28
  import { vecTextForRow } from './tfidf.mjs';
16
- import { insertObservationRow, insertObservationFiles, insertObservationVector, upsertObservationVector, normalizeScope, SCOPE_PROMPT_LEGEND } from './lib/observation-write.mjs';
29
+ import {
30
+ insertObservationRow,
31
+ insertObservationFiles,
32
+ insertObservationVector,
33
+ upsertObservationVector,
34
+ normalizeScope,
35
+ SCOPE_PROMPT_LEGEND,
36
+ } from './lib/observation-write.mjs';
17
37
  import { DEDUP_JACCARD_THRESHOLD, AUTO_MERGE_THRESHOLD } from './lib/dedup-constants.mjs';
18
38
  import {
19
- RUNTIME_DIR, DEDUP_WINDOW_MS, RELATED_OBS_WINDOW_MS, ORPHAN_EPISODE_AGE_MS,
20
- sessionFile, getSessionId, openDb, callLLM, sleep,
39
+ RUNTIME_DIR,
40
+ DEDUP_WINDOW_MS,
41
+ RELATED_OBS_WINDOW_MS,
42
+ ORPHAN_EPISODE_AGE_MS,
43
+ sessionFile,
44
+ getSessionId,
45
+ openDb,
46
+ callLLM,
47
+ sleep,
21
48
  } from './hook-shared.mjs';
22
49
  import { EVENT_TYPES, saveEvent } from './lib/activity.mjs';
23
50
  import { isNoiseObservation, capNoiseImportance, isLowYieldChangeObs } from './lib/low-signal-patterns.mjs';
@@ -56,9 +83,7 @@ export function retractPreSavedObs(db, obsId, where) {
56
83
  // either; nesting under persistHaikuSummary's transaction is safe (better-sqlite3 uses
57
84
  // savepoints).
58
85
  return db.transaction(() => {
59
- const live = db.prepare(
60
- `SELECT 1 FROM observations WHERE id = ? AND ${liveObsFilterSql('')}`,
61
- ).get(obsId);
86
+ const live = db.prepare(`SELECT 1 FROM observations WHERE id = ? AND ${liveObsFilterSql('')}`).get(obsId);
62
87
  if (!live) {
63
88
  debugLog('DEBUG', 'llm-episode', `${where}: pre-saved obs #${obsId} no longer live — left in place`);
64
89
  return false;
@@ -119,13 +144,15 @@ export function recordRetryAttempt(db, recovered, bucket = dateBucketUtc()) {
119
144
  // IGNORE and the UPDATE; ON CONFLICT collapses this to one statement
120
145
  // that runs entirely under the writer lock with no observable middle
121
146
  // state. SQLite ≥3.24 supports the syntax (better-sqlite3 ships ≥3.30).
122
- db.prepare(`
147
+ db.prepare(
148
+ `
123
149
  INSERT INTO lesson_retry_stats (date_bucket, attempts, recovered)
124
150
  VALUES (?, 1, ?)
125
151
  ON CONFLICT(date_bucket) DO UPDATE SET
126
152
  attempts = attempts + 1,
127
153
  recovered = recovered + excluded.recovered
128
- `).run(bucket, recovered ? 1 : 0);
154
+ `,
155
+ ).run(bucket, recovered ? 1 : 0);
129
156
  }
130
157
 
131
158
  /**
@@ -135,10 +162,12 @@ export function recordRetryAttempt(db, recovered, bucket = dateBucketUtc()) {
135
162
  */
136
163
  export function readRetryStats(db, days = 30) {
137
164
  const cutoff = new Date(Date.now() - days * DAY_MS);
138
- return db.prepare(
139
- `SELECT date_bucket, attempts, recovered FROM lesson_retry_stats
140
- WHERE date_bucket >= ? ORDER BY date_bucket DESC`
141
- ).all(dateBucketUtc(cutoff));
165
+ return db
166
+ .prepare(
167
+ `SELECT date_bucket, attempts, recovered FROM lesson_retry_stats
168
+ WHERE date_bucket >= ? ORDER BY date_bucket DESC`,
169
+ )
170
+ .all(dateBucketUtc(cutoff));
142
171
  }
143
172
 
144
173
  // ─── Save Observation to DB ─────────────────────────────────────────────────
@@ -156,18 +185,29 @@ function buildFtsTextField(obs) {
156
185
  if (!conceptsText && !factsText && !aliasesText) {
157
186
  const raw = (obs.title || '') + ' ' + (obs.narrative || '');
158
187
  // Extract file basenames (without extension) as searchable terms
159
- const fileNames = [...new Set(
160
- [...raw.matchAll(/\b([\w.-]+\.(?:mjs|js|ts|tsx|jsx|py|rs|go|vue|css|html|json|yaml|yml|md|sh|sql|toml|cfg))\b/g)]
161
- .map(m => m[1].replace(/\.[^.]+$/, ''))
162
- )];
188
+ const fileNames = [
189
+ ...new Set(
190
+ [
191
+ ...raw.matchAll(
192
+ /\b([\w.-]+\.(?:mjs|js|ts|tsx|jsx|py|rs|go|vue|css|html|json|yaml|yml|md|sh|sql|toml|cfg))\b/g,
193
+ ),
194
+ ].map((m) => m[1].replace(/\.[^.]+$/, '')),
195
+ ),
196
+ ];
163
197
  // Extract error keywords from "→ ERROR: ..." patterns
164
- const errorTerms = raw.split(/→ ERROR[: ]+/).slice(1)
165
- .map(s => s.split(/[;{[\]"\\|→\n]/)[0].trim())
166
- .filter(t => t.length >= 4 && t.length <= 50);
198
+ const errorTerms = raw
199
+ .split(/→ ERROR[: ]+/)
200
+ .slice(1)
201
+ .map((s) => s.split(/[;{[\]"\\|→\n]/)[0].trim())
202
+ .filter((t) => t.length >= 4 && t.length <= 50);
167
203
  fallbackText = [...fileNames, ...errorTerms].join(' ');
168
204
  }
169
205
 
170
- return { conceptsText, factsText, textField: [conceptsText, factsText, aliasesText, bigramText, fallbackText].filter(Boolean).join(' ') };
206
+ return {
207
+ conceptsText,
208
+ factsText,
209
+ textField: [conceptsText, factsText, aliasesText, bigramText, fallbackText].filter(Boolean).join(' '),
210
+ };
171
211
  }
172
212
 
173
213
  // TF-IDF vector text. Must mirror the FTS-searchable content so the vector arm and
@@ -178,8 +218,11 @@ export function buildVecText(obs) {
178
218
  // Single source (V-F1): map the camelCase obs onto vecTextForRow's row shape so save and
179
219
  // every rebuild path encode the identical field set (title/narrative/concepts/lesson/aliases).
180
220
  return vecTextForRow({
181
- title: obs.title, narrative: obs.narrative, concepts: obs.concepts,
182
- lesson_learned: obs.lessonLearned, search_aliases: obs.searchAliases,
221
+ title: obs.title,
222
+ narrative: obs.narrative,
223
+ concepts: obs.concepts,
224
+ lesson_learned: obs.lessonLearned,
225
+ search_aliases: obs.searchAliases,
183
226
  });
184
227
  }
185
228
 
@@ -197,10 +240,12 @@ export function saveObservation(obs, projectOverride, sessionIdOverride, externa
197
240
  const project = projectOverride || inferProject();
198
241
  const sessionId = sessionIdOverride || getSessionId();
199
242
 
200
- db.prepare(`
243
+ db.prepare(
244
+ `
201
245
  INSERT OR IGNORE INTO sdk_sessions (content_session_id, memory_session_id, project, started_at, started_at_epoch, status)
202
246
  VALUES (?, ?, ?, ?, ?, 'active')
203
- `).run(sessionId, sessionId, project, now.toISOString(), now.getTime());
247
+ `,
248
+ ).run(sessionId, sessionId, project, now.toISOString(), now.getTime());
204
249
 
205
250
  // P0: write-side noise block — LOW_SIGNAL title with no recoverable signal
206
251
  // (no lesson, importance<2, empty facts, thin narrative) is dropped before
@@ -232,20 +277,28 @@ export function saveObservation(obs, projectOverride, sessionIdOverride, externa
232
277
  // enter the 7-day accelerated auto-compress window in hook.mjs.
233
278
  const capped = capNoiseImportance(obs);
234
279
  if (capped !== (obs.importance ?? 1)) {
235
- debugLog('DEBUG', 'saveObservation', `capped imp ${obs.importance}→${capped}: ${truncate(obs.title || '', 60)}`);
280
+ debugLog(
281
+ 'DEBUG',
282
+ 'saveObservation',
283
+ `capped imp ${obs.importance}→${capped}: ${truncate(obs.title || '', 60)}`,
284
+ );
236
285
  obs.importance = capped;
237
286
  }
238
287
 
239
288
  // Three-tier dedup — returns null (not throw) for dedup hits
240
289
  // Tier 1 (fast): 5-min Jaccard on titles
241
290
  const fiveMinAgo = now.getTime() - DEDUP_WINDOW_MS;
242
- const recent = db.prepare(`
291
+ const recent = db
292
+ .prepare(
293
+ `
243
294
  SELECT title FROM observations
244
295
  WHERE project = ? AND created_at_epoch > ?
245
296
  ORDER BY created_at_epoch DESC LIMIT 10
246
- `).all(project, fiveMinAgo);
297
+ `,
298
+ )
299
+ .all(project, fiveMinAgo);
247
300
 
248
- if (obs.title && recent.some(r => jaccardSimilarity(r.title, obs.title) > DEDUP_JACCARD_THRESHOLD)) {
301
+ if (obs.title && recent.some((r) => jaccardSimilarity(r.title, obs.title) > DEDUP_JACCARD_THRESHOLD)) {
249
302
  return null; // dedup: Jaccard title match
250
303
  }
251
304
 
@@ -258,19 +311,27 @@ export function saveObservation(obs, projectOverride, sessionIdOverride, externa
258
311
  const sevenDaysAgo = now.getTime() - 7 * DAY_MS;
259
312
  const threeDaysAgo = now.getTime() - 3 * DAY_MS;
260
313
  // Phase 1: exact title match within 7 days
261
- const exactDup = db.prepare(`
314
+ const exactDup = db
315
+ .prepare(
316
+ `
262
317
  SELECT 1 FROM observations
263
318
  WHERE project = ? AND title = ? AND created_at_epoch > ? AND created_at_epoch <= ?
264
319
  LIMIT 1
265
- `).get(project, obs.title, sevenDaysAgo, fiveMinAgo);
320
+ `,
321
+ )
322
+ .get(project, obs.title, sevenDaysAgo, fiveMinAgo);
266
323
  if (exactDup) return null; // dedup: exact title match
267
324
  // Phase 2: Jaccard similarity for near-duplicates (3-day window)
268
- const extRecent = db.prepare(`
325
+ const extRecent = db
326
+ .prepare(
327
+ `
269
328
  SELECT title FROM observations
270
329
  WHERE project = ? AND created_at_epoch > ? AND created_at_epoch <= ?
271
330
  ORDER BY created_at_epoch DESC LIMIT 60
272
- `).all(project, threeDaysAgo, fiveMinAgo);
273
- if (extRecent.some(r => jaccardSimilarity(r.title, obs.title) > AUTO_MERGE_THRESHOLD)) {
331
+ `,
332
+ )
333
+ .all(project, threeDaysAgo, fiveMinAgo);
334
+ if (extRecent.some((r) => jaccardSimilarity(r.title, obs.title) > AUTO_MERGE_THRESHOLD)) {
274
335
  return null; // dedup: low-signal Jaccard match (stricter cutoff for degraded titles)
275
336
  }
276
337
  }
@@ -279,13 +340,17 @@ export function saveObservation(obs, projectOverride, sessionIdOverride, externa
279
340
  const minhashSig = computeMinHash((obs.title || '') + ' ' + (obs.narrative || ''));
280
341
  if (minhashSig) {
281
342
  const sevenDaysAgo = now.getTime() - RELATED_OBS_WINDOW_MS;
282
- const recentSigs = db.prepare(`
343
+ const recentSigs = db
344
+ .prepare(
345
+ `
283
346
  SELECT minhash_sig FROM observations
284
347
  WHERE project = ? AND created_at_epoch > ? AND minhash_sig IS NOT NULL
285
348
  ORDER BY created_at_epoch DESC LIMIT 200
286
- `).all(project, sevenDaysAgo);
349
+ `,
350
+ )
351
+ .all(project, sevenDaysAgo);
287
352
 
288
- if (recentSigs.some(r => estimateJaccardFromMinHash(minhashSig, r.minhash_sig) > 0.8)) {
353
+ if (recentSigs.some((r) => estimateJaccardFromMinHash(minhashSig, r.minhash_sig) > 0.8)) {
289
354
  return null; // dedup: MinHash similarity match
290
355
  }
291
356
  }
@@ -309,14 +374,24 @@ export function saveObservation(obs, projectOverride, sessionIdOverride, externa
309
374
  // Column list single-sourced in lib/observation-write (shared with manual mem_save).
310
375
  const savedId = db.transaction(() => {
311
376
  const id = insertObservationRow(db, {
312
- memory_session_id: sessionId, project, text: safe.text, type: obs.type,
313
- title: safe.title, subtitle: safe.subtitle, narrative: safe.narrative,
314
- concepts: safe.concepts, facts: safe.facts,
377
+ memory_session_id: sessionId,
378
+ project,
379
+ text: safe.text,
380
+ type: obs.type,
381
+ title: safe.title,
382
+ subtitle: safe.subtitle,
383
+ narrative: safe.narrative,
384
+ concepts: safe.concepts,
385
+ facts: safe.facts,
315
386
  files_read: JSON.stringify(obs.filesRead || []),
316
387
  files_modified: JSON.stringify(obs.files || []),
317
- importance: obs.importance ?? 1, minhash_sig: minhashSig,
318
- lesson_learned: safe.lesson_learned, search_aliases: safe.search_aliases,
319
- branch: getCurrentBranch(), created_at: now.toISOString(), created_at_epoch: now.getTime(),
388
+ importance: obs.importance ?? 1,
389
+ minhash_sig: minhashSig,
390
+ lesson_learned: safe.lesson_learned,
391
+ search_aliases: safe.search_aliases,
392
+ branch: getCurrentBranch(),
393
+ created_at: now.toISOString(),
394
+ created_at_epoch: now.getTime(),
320
395
  // P3 (D#78): re-validate at the write boundary — saveObservation is also
321
396
  // reached by immediate-save / manual callers whose scope never saw the
322
397
  // handleLLMEpisode whitelist.
@@ -385,17 +460,19 @@ export function persistHaikuSummary(db, summary, ctx) {
385
460
  // rule-inferred type; now that Haiku has classified it as an event type,
386
461
  // we must remove the stale observations row before inserting the event.
387
462
  // Atomic via better-sqlite3 transaction: either both succeed or neither.
388
- const insertEvent = () => saveEvent(db, {
389
- project: ctx.project,
390
- event_type: summary.type,
391
- title: summary.title,
392
- body: summary.lesson_learned || summary.narrative || null,
393
- file_paths: (Array.isArray(summary.files_modified) && summary.files_modified.length > 0)
394
- ? summary.files_modified
395
- : null,
396
- importance: summary.importance ?? 1,
397
- created_at_epoch: Date.now(),
398
- });
463
+ const insertEvent = () =>
464
+ saveEvent(db, {
465
+ project: ctx.project,
466
+ event_type: summary.type,
467
+ title: summary.title,
468
+ body: summary.lesson_learned || summary.narrative || null,
469
+ file_paths:
470
+ Array.isArray(summary.files_modified) && summary.files_modified.length > 0
471
+ ? summary.files_modified
472
+ : null,
473
+ importance: summary.importance ?? 1,
474
+ created_at_epoch: Date.now(),
475
+ });
399
476
 
400
477
  if (ctx.preSavedObsId) {
401
478
  const id = db.transaction(() => {
@@ -413,75 +490,112 @@ export function persistHaikuSummary(db, summary, ctx) {
413
490
 
414
491
  // Fallthrough: memdir-compatible / legacy types use the observations path.
415
492
  // Map the Haiku/plan field names to saveObservation's expected shape.
416
- const id = saveObservation({
417
- type: summary.type,
418
- title: summary.title,
419
- subtitle: summary.subtitle || '',
420
- narrative: summary.narrative || '',
421
- concepts: summary.concepts || [],
422
- facts: summary.facts || [],
423
- files: summary.files_modified || [],
424
- filesRead: summary.files_read || [],
425
- importance: summary.importance ?? 1,
426
- lessonLearned: summary.lesson_learned || null,
427
- searchAliases: summary.search_aliases || null,
428
- scope: summary.scope ?? null,
429
- }, ctx.project, ctx.session_id, db);
493
+ const id = saveObservation(
494
+ {
495
+ type: summary.type,
496
+ title: summary.title,
497
+ subtitle: summary.subtitle || '',
498
+ narrative: summary.narrative || '',
499
+ concepts: summary.concepts || [],
500
+ facts: summary.facts || [],
501
+ files: summary.files_modified || [],
502
+ filesRead: summary.files_read || [],
503
+ importance: summary.importance ?? 1,
504
+ lessonLearned: summary.lesson_learned || null,
505
+ searchAliases: summary.search_aliases || null,
506
+ scope: summary.scope ?? null,
507
+ },
508
+ ctx.project,
509
+ ctx.session_id,
510
+ db,
511
+ );
430
512
  return { table: 'observations', id };
431
513
  }
432
514
 
433
515
  // ─── Related Observation Linking ─────────────────────────────────────────────
434
516
 
435
517
  function linkRelatedObservations(db, savedId, obs, episode) {
436
- const newObs = db.prepare(`
518
+ const newObs = db
519
+ .prepare(
520
+ `
437
521
  SELECT id, title, files_modified, related_ids FROM observations WHERE id = ?
438
- `).get(savedId);
522
+ `,
523
+ )
524
+ .get(savedId);
439
525
  if (!newObs) return;
440
526
 
441
527
  const candidates = new Set();
442
528
 
443
529
  // Strategy 1: FTS5 title similarity (cross-session)
444
530
  if (obs.title) {
445
- const titleTokens = obs.title.replace(/[^a-zA-Z0-9_\s-]/g, ' ').split(/\s+/)
446
- .filter(t => t.length > 2).slice(0, 5);
531
+ const titleTokens = obs.title
532
+ .replace(/[^a-zA-Z0-9_\s-]/g, ' ')
533
+ .split(/\s+/)
534
+ .filter((t) => t.length > 2)
535
+ .slice(0, 5);
447
536
  if (titleTokens.length > 0) {
448
- const ftsQuery = titleTokens.map(t => `"${t.replace(/"/g, '""')}"`).join(' OR ');
537
+ const ftsQuery = titleTokens.map((t) => `"${t.replace(/"/g, '""')}"`).join(' OR ');
449
538
  try {
450
- const ftsMatches = db.prepare(`
539
+ const ftsMatches = db
540
+ .prepare(
541
+ `
451
542
  SELECT o.id FROM observations_fts
452
543
  JOIN observations o ON observations_fts.rowid = o.id
453
544
  WHERE observations_fts MATCH ? AND o.id != ? AND o.project = ?
454
545
  ORDER BY ${OBS_BM25}
455
546
  LIMIT 5
456
- `).all(ftsQuery, newObs.id, episode.project);
547
+ `,
548
+ )
549
+ .all(ftsQuery, newObs.id, episode.project);
457
550
  for (const m of ftsMatches) candidates.add(m.id);
458
- } catch (e) { debugCatch(e, 'linkRelated-fts'); }
551
+ } catch (e) {
552
+ debugCatch(e, 'linkRelated-fts');
553
+ }
459
554
  }
460
555
  }
461
556
 
462
557
  // Strategy 2: file overlap (any session, recent observations)
463
558
  let newFiles;
464
- try { newFiles = JSON.parse(newObs.files_modified || '[]'); } catch (e) { debugCatch(e, 'linkRelated-newFiles'); newFiles = []; }
465
- if (!Array.isArray(newFiles) || !newFiles.every(f => typeof f === 'string')) newFiles = [];
559
+ try {
560
+ newFiles = JSON.parse(newObs.files_modified || '[]');
561
+ } catch (e) {
562
+ debugCatch(e, 'linkRelated-newFiles');
563
+ newFiles = [];
564
+ }
565
+ if (!Array.isArray(newFiles) || !newFiles.every((f) => typeof f === 'string')) newFiles = [];
466
566
  if (newFiles.length > 0) {
467
- const recentObs = db.prepare(`
567
+ const recentObs = db
568
+ .prepare(
569
+ `
468
570
  SELECT id, files_modified FROM observations
469
571
  WHERE id != ? AND created_at_epoch > ? AND project = ?
470
572
  ORDER BY created_at_epoch DESC LIMIT 50
471
- `).all(newObs.id, Date.now() - RELATED_OBS_WINDOW_MS, episode.project);
573
+ `,
574
+ )
575
+ .all(newObs.id, Date.now() - RELATED_OBS_WINDOW_MS, episode.project);
472
576
  for (const r of recentObs) {
473
577
  let rFiles;
474
- try { rFiles = JSON.parse(r.files_modified || '[]'); } catch (e) { debugCatch(e, 'linkRelated-rFiles'); rFiles = []; }
475
- if (!Array.isArray(rFiles) || !rFiles.every(f => typeof f === 'string')) rFiles = [];
476
- if (rFiles.some(f => newFiles.includes(f))) candidates.add(r.id);
578
+ try {
579
+ rFiles = JSON.parse(r.files_modified || '[]');
580
+ } catch (e) {
581
+ debugCatch(e, 'linkRelated-rFiles');
582
+ rFiles = [];
583
+ }
584
+ if (!Array.isArray(rFiles) || !rFiles.every((f) => typeof f === 'string')) rFiles = [];
585
+ if (rFiles.some((f) => newFiles.includes(f))) candidates.add(r.id);
477
586
  }
478
587
  }
479
588
 
480
589
  // Apply bidirectional links (max 5 related)
481
590
  if (candidates.size > 0) {
482
591
  let newRelated;
483
- try { newRelated = JSON.parse(newObs.related_ids || '[]'); } catch (e) { debugCatch(e, 'linkRelated-newRelated'); newRelated = []; }
484
- if (!Array.isArray(newRelated) || !newRelated.every(id => Number.isInteger(id))) newRelated = [];
592
+ try {
593
+ newRelated = JSON.parse(newObs.related_ids || '[]');
594
+ } catch (e) {
595
+ debugCatch(e, 'linkRelated-newRelated');
596
+ newRelated = [];
597
+ }
598
+ if (!Array.isArray(newRelated) || !newRelated.every((id) => Number.isInteger(id))) newRelated = [];
485
599
 
486
600
  for (const relId of [...candidates].slice(0, 5)) {
487
601
  if (newRelated.includes(relId)) continue;
@@ -490,16 +604,27 @@ function linkRelatedObservations(db, savedId, obs, episode) {
490
604
  const rel = db.prepare('SELECT related_ids FROM observations WHERE id = ?').get(relId);
491
605
  if (rel) {
492
606
  let relRelated;
493
- try { relRelated = JSON.parse(rel.related_ids || '[]'); } catch (e) { debugCatch(e, 'linkRelated-relRelated'); relRelated = []; }
494
- if (!Array.isArray(relRelated) || !relRelated.every(id => Number.isInteger(id))) relRelated = [];
607
+ try {
608
+ relRelated = JSON.parse(rel.related_ids || '[]');
609
+ } catch (e) {
610
+ debugCatch(e, 'linkRelated-relRelated');
611
+ relRelated = [];
612
+ }
613
+ if (!Array.isArray(relRelated) || !relRelated.every((id) => Number.isInteger(id))) relRelated = [];
495
614
  if (!relRelated.includes(newObs.id)) {
496
615
  relRelated.push(newObs.id);
497
- db.prepare('UPDATE observations SET related_ids = ? WHERE id = ?').run(JSON.stringify(relRelated.slice(-10)), relId);
616
+ db.prepare('UPDATE observations SET related_ids = ? WHERE id = ?').run(
617
+ JSON.stringify(relRelated.slice(-10)),
618
+ relId,
619
+ );
498
620
  }
499
621
  }
500
622
  }
501
623
 
502
- db.prepare('UPDATE observations SET related_ids = ? WHERE id = ?').run(JSON.stringify(newRelated.slice(-10)), newObs.id);
624
+ db.prepare('UPDATE observations SET related_ids = ? WHERE id = ?').run(
625
+ JSON.stringify(newRelated.slice(-10)),
626
+ newObs.id,
627
+ );
503
628
  }
504
629
  }
505
630
 
@@ -509,13 +634,13 @@ function linkRelatedObservations(db, savedId, obs, episode) {
509
634
 
510
635
  export function buildDegradedTitle(episode) {
511
636
  const files = (episode.files || []).filter(Boolean);
512
- const hasError = episode.entries.some(e => e.isError);
513
- const hasEdit = episode.entries.some(e => EDIT_TOOLS.has(e.tool));
637
+ const hasError = episode.entries.some((e) => e.isError);
638
+ const hasEdit = episode.entries.some((e) => EDIT_TOOLS.has(e.tool));
514
639
 
515
640
  // Extract a short error hint from the first error entry's desc
516
641
  let errorHint = '';
517
642
  if (hasError) {
518
- const errEntry = episode.entries.find(e => e.isError);
643
+ const errEntry = episode.entries.find((e) => e.isError);
519
644
  if (errEntry?.desc) {
520
645
  // Extract meaningful error text from "cmd → ERROR: ..." format
521
646
  const errMatch = errEntry.desc.match(/→ ERROR: (.{3,80})/);
@@ -534,12 +659,12 @@ export function buildDegradedTitle(episode) {
534
659
  }
535
660
 
536
661
  if (files.length > 0) {
537
- const uniqueNames = [...new Set(files.map(f => basename(f)))];
662
+ const uniqueNames = [...new Set(files.map((f) => basename(f)))];
538
663
  const names = uniqueNames.slice(0, 3).join(', ');
539
664
  const suffix = uniqueNames.length > 3 ? ` +${uniqueNames.length - 3} more` : '';
540
665
  if (hasError) {
541
666
  // Include the triggering command for richer context: "Error: dispatch.mjs — npm test failed"
542
- const errEntry = episode.entries.find(e => e.isError);
667
+ const errEntry = episode.entries.find((e) => e.isError);
543
668
  const cmd = errEntry?.desc?.match(/^(.{3,30}?) →/)?.[1]?.trim();
544
669
  const cmdHint = cmd ? ` — ${cmd}` : '';
545
670
  return `Error: ${names}${suffix}${errorHint || cmdHint}`;
@@ -549,7 +674,8 @@ export function buildDegradedTitle(episode) {
549
674
  }
550
675
  // No files: strip raw output (JSON, arrays, long tails) from Bash descriptions
551
676
  const desc = episode.entries[0]?.desc || '(no description)';
552
- return desc.replace(/ → (?:ERROR: )?[[{].*$/, hasError ? ' (error)' : '')
677
+ return desc
678
+ .replace(/ → (?:ERROR: )?[[{].*$/, hasError ? ' (error)' : '')
553
679
  .replace(/ → .*---EXIT:\d+$/, hasError ? ' (error)' : '')
554
680
  .replace(/\t/g, ' ')
555
681
  .replace(/\s{2,}/g, ' ')
@@ -586,20 +712,19 @@ export function saveEpisodeImmediate(episode, externalDb, scope = 'saveEpisodeIm
586
712
  * @returns {object} Observation object ready for saveObservation()
587
713
  */
588
714
  export function buildImmediateObservation(episode) {
589
- const hasError = episode.entries.some(e => e.isError);
590
- const hasEdit = episode.entries.some(e => EDIT_TOOLS.has(e.tool));
591
- const readCount = episode.entries.filter(e => e.tool === 'Read' || e.tool === 'Grep').length;
715
+ const hasError = episode.entries.some((e) => e.isError);
716
+ const hasEdit = episode.entries.some((e) => EDIT_TOOLS.has(e.tool));
717
+ const readCount = episode.entries.filter((e) => e.tool === 'Read' || e.tool === 'Grep').length;
592
718
  const isReviewPattern = !hasEdit && !hasError && readCount >= 5;
593
719
  const inferredType = hasError ? 'bugfix' : hasEdit ? 'change' : 'discovery';
594
- const fileList = (episode.files || []).map(f => basename(f)).join(', ') || '(multiple)';
720
+ const fileList = (episode.files || []).map((f) => basename(f)).join(', ') || '(multiple)';
595
721
 
596
722
  // Review/research episodes: use a descriptive title with file count
597
723
  let title;
598
724
  if (isReviewPattern) {
599
- const allFiles = [...new Set([
600
- ...(episode.files || []),
601
- ...(episode.filesRead || []),
602
- ])].map(f => basename(f));
725
+ const allFiles = [...new Set([...(episode.files || []), ...(episode.filesRead || [])])].map((f) =>
726
+ basename(f),
727
+ );
603
728
  const names = allFiles.slice(0, 4).join(', ');
604
729
  const suffix = allFiles.length > 4 ? ` +${allFiles.length - 4} more` : '';
605
730
  title = truncate(`Reviewed ${allFiles.length} files: ${names}${suffix}`, 120);
@@ -656,7 +781,7 @@ export function buildImmediateObservation(episode) {
656
781
  type: inferredType,
657
782
  title,
658
783
  subtitle: fileList,
659
- narrative: episode.entries.map(e => e.desc).join('; '),
784
+ narrative: episode.entries.map((e) => e.desc).join('; '),
660
785
  concepts: [],
661
786
  facts: [],
662
787
  files: [...modifiedFiles],
@@ -713,7 +838,8 @@ export function hasEnrichmentContent(parsed) {
713
838
  if (!parsed || typeof parsed !== 'object') return false;
714
839
  if (typeof parsed.lesson_learned === 'string' && !isLowSignalLesson(parsed.lesson_learned)) return true;
715
840
  if (typeof parsed.narrative === 'string' && parsed.narrative.trim().length > 0) return true;
716
- if (Array.isArray(parsed.facts) && parsed.facts.some(f => typeof f === 'string' && f.trim().length > 0)) return true;
841
+ if (Array.isArray(parsed.facts) && parsed.facts.some((f) => typeof f === 'string' && f.trim().length > 0))
842
+ return true;
717
843
  return false;
718
844
  }
719
845
 
@@ -732,12 +858,13 @@ export function hasEnrichmentContent(parsed) {
732
858
  // Module-private: the only call site is the retry branch below. Same D#207 reasoning as
733
859
  // MEMORY_INPUT_GUARD — exported by habit, never imported.
734
860
  function buildLessonRetryPrompt(episode, firstPass) {
735
- const actionList = episode.entries.map((e, i) =>
736
- `${i + 1}. [${e.tool}] ${e.desc}${e.isError ? ' (ERROR)' : ''}`
737
- ).join('\n');
738
- const typeHint = firstPass.type === 'bugfix'
739
- ? 'For this bugfix: what was the root cause + how to spot it next time? Example: "FTS5 trigger fires on any UPDATE — wrap access_count writes in try/catch."'
740
- : 'For this decision: what tradeoff was made + why? Example: "Chose single-source module over schema column because 1 drift point, not 4."';
861
+ const actionList = episode.entries
862
+ .map((e, i) => `${i + 1}. [${e.tool}] ${e.desc}${e.isError ? ' (ERROR)' : ''}`)
863
+ .join('\n');
864
+ const typeHint =
865
+ firstPass.type === 'bugfix'
866
+ ? 'For this bugfix: what was the root cause + how to spot it next time? Example: "FTS5 trigger fires on any UPDATE — wrap access_count writes in try/catch."'
867
+ : 'For this decision: what tradeoff was made + why? Example: "Chose single-source module over schema column because 1 drift point, not 4."';
741
868
 
742
869
  const system = `${typeHint}
743
870
 
@@ -762,22 +889,28 @@ export async function handleLLMEpisode() {
762
889
  try {
763
890
  episode = JSON.parse(readFileSync(tmpFile, 'utf8'));
764
891
  } catch {
765
- try { unlinkSync(tmpFile); } catch {}
892
+ try {
893
+ unlinkSync(tmpFile);
894
+ } catch {}
766
895
  return;
767
896
  }
768
897
 
769
898
  if (!episode.entries || episode.entries.length === 0) {
770
- try { unlinkSync(tmpFile); } catch {}
899
+ try {
900
+ unlinkSync(tmpFile);
901
+ } catch {}
771
902
  return;
772
903
  }
773
904
 
774
905
  // Rate-limit background LLM calls to avoid competing with active sessions
775
906
  if (!process.env.CLAUDE_MEM_NO_DELAY) {
776
907
  const sessionActive = existsSync(sessionFile());
777
- const delayMs = sessionActive
778
- ? 2000 + Math.random() * 3000
779
- : 500 + Math.random() * 1000;
780
- debugLog('DEBUG', 'llm-episode', `delay: ${Math.round(delayMs)}ms (session ${sessionActive ? 'active' : 'ended'})`);
908
+ const delayMs = sessionActive ? 2000 + Math.random() * 3000 : 500 + Math.random() * 1000;
909
+ debugLog(
910
+ 'DEBUG',
911
+ 'llm-episode',
912
+ `delay: ${Math.round(delayMs)}ms (session ${sessionActive ? 'active' : 'ended'})`,
913
+ );
781
914
  await sleep(delayMs);
782
915
  }
783
916
 
@@ -786,14 +919,13 @@ export async function handleLLMEpisode() {
786
919
  // before any cleanup, leaking the tmp file (which is then retried and crashes
787
920
  // forever). Guard defensively, mirroring buildImmediateObservation's `|| []`.
788
921
  const episodeFiles = Array.isArray(episode.files) ? episode.files : [];
789
- const fileList = episodeFiles.map(f => basename(f)).join(', ') || '(multiple)';
922
+ const fileList = episodeFiles.map((f) => basename(f)).join(', ') || '(multiple)';
790
923
 
791
924
  // Defense-in-depth (cso F#4): split static instructions (system) from
792
925
  // per-call data (user). Episode descriptions and file paths come from tool
793
926
  // events; treating them as a separate role + boundary marker reduces the
794
927
  // attack surface for memory poisoning via crafted file content.
795
- const SHARED_OBS_SCHEMA_TAIL =
796
- `${MEMORY_INPUT_GUARD}
928
+ const SHARED_OBS_SCHEMA_TAIL = `${MEMORY_INPUT_GUARD}
797
929
  type: pick by strongest signal. decision = explicit tradeoff / "chose X over Y because Z" / rejected an approach (e.g. "Rejected schema migration — single-source module + sync test instead"; "Heterogeneous hook events → heterogeneous context budgets"). bugfix = prior-failing path fixed with a named root cause. feature = new user-visible capability. refactor = behavior unchanged but structure improved. discovery = learned how a system works (read-heavy, no writes). change = routine edit with no new principle (default if unsure and nothing else fits).
798
930
  Facts: each MUST be (1) atomic—one claim, (2) self-contained—no pronouns, include file/function name, (3) specific—"refreshToken() in auth.ts:45 uses 1h TTL" not "handles tokens"
799
931
  importance: Be strict — default to 1. 0=pure browsing with zero learning value. 1=routine file edits, standard changes, normal workflow (MOST episodes). 2=notable ONLY if it reveals something non-obvious: error fix with discovered root cause, architectural decision with explicit tradeoff, config change with unexpected side effects. 3=critical: breaking change affecting users, security vulnerability fix, data migration. Ask yourself: "would a future session benefit from knowing this?" — if not, it's importance=1.
@@ -814,9 +946,9 @@ Action: ${e.desc}
814
946
  Error: ${e.isError ? 'yes' : 'no'}`;
815
947
  prompt = { system, user };
816
948
  } else {
817
- const actionList = episode.entries.map((e, i) =>
818
- `${i + 1}. [${e.tool}] ${e.desc}${e.isError ? ' (ERROR)' : ''}`
819
- ).join('\n');
949
+ const actionList = episode.entries
950
+ .map((e, i) => `${i + 1}. [${e.tool}] ${e.desc}${e.isError ? ' (ERROR)' : ''}`)
951
+ .join('\n');
820
952
 
821
953
  const system = `Summarize this coding episode as ONE coherent observation. Return ONLY valid JSON, no markdown fences.
822
954
 
@@ -861,7 +993,12 @@ ${actionList}`;
861
993
  // with neither a usable title nor content still falls through to
862
994
  // buildImmediateObservation, which infers type/importance from the episode.
863
995
  const titleUsable = parsed && typeof parsed.title === 'string' && !!parsed.title;
864
- if (parsed && typeof parsed === 'object' && !Array.isArray(parsed) && (titleUsable || hasEnrichmentContent(parsed))) {
996
+ if (
997
+ parsed &&
998
+ typeof parsed === 'object' &&
999
+ !Array.isArray(parsed) &&
1000
+ (titleUsable || hasEnrichmentContent(parsed))
1001
+ ) {
865
1002
  // Synthesize a rule-based title when Haiku's is unusable (crash-safe, and the
866
1003
  // lesson survives). Only the title degrades; every other field is kept.
867
1004
  if (!titleUsable) parsed.title = buildDegradedTitle(episode);
@@ -879,11 +1016,16 @@ ${actionList}`;
879
1016
  if (episode.savedId) {
880
1017
  const ddb = openDb();
881
1018
  if (ddb) {
882
- try { retractPreSavedObs(ddb, episode.savedId, 'low-value-discard'); }
883
- finally { ddb.close(); }
1019
+ try {
1020
+ retractPreSavedObs(ddb, episode.savedId, 'low-value-discard');
1021
+ } finally {
1022
+ ddb.close();
1023
+ }
884
1024
  }
885
1025
  }
886
- try { unlinkSync(tmpFile); } catch {}
1026
+ try {
1027
+ unlinkSync(tmpFile);
1028
+ } catch {}
887
1029
  return;
888
1030
  }
889
1031
 
@@ -904,9 +1046,11 @@ ${actionList}`;
904
1046
  // episode. Opt-out: CLAUDE_MEM_NO_LESSON_RETRY=1.
905
1047
  let retryAttempted = false;
906
1048
  let retryRecovered = false;
907
- if (isLessonLowSignal &&
908
- (parsed.type === 'bugfix' || parsed.type === 'decision') &&
909
- !process.env.CLAUDE_MEM_NO_LESSON_RETRY) {
1049
+ if (
1050
+ isLessonLowSignal &&
1051
+ (parsed.type === 'bugfix' || parsed.type === 'decision') &&
1052
+ !process.env.CLAUDE_MEM_NO_LESSON_RETRY
1053
+ ) {
910
1054
  retryAttempted = true;
911
1055
  // The first callLLM released its slot in the finally above; this lesson
912
1056
  // retry is a SECOND LLM call and must re-acquire the semaphore or it
@@ -924,11 +1068,18 @@ ${actionList}`;
924
1068
  if (!retryIsLow) {
925
1069
  lessonLearned = retryLesson.slice(0, 500);
926
1070
  retryRecovered = true;
927
- debugLog('DEBUG', 'llm-episode', `lesson-retry: recovered ${retryLesson.length}-char lesson for ${parsed.type}`);
1071
+ debugLog(
1072
+ 'DEBUG',
1073
+ 'llm-episode',
1074
+ `lesson-retry: recovered ${retryLesson.length}-char lesson for ${parsed.type}`,
1075
+ );
928
1076
  }
929
1077
  }
930
- } catch (e) { debugCatch(e, 'lesson-retry'); }
931
- finally { if (retrySlot) releaseLLMSlot(); }
1078
+ } catch (e) {
1079
+ debugCatch(e, 'lesson-retry');
1080
+ } finally {
1081
+ if (retrySlot) releaseLLMSlot();
1082
+ }
932
1083
  }
933
1084
  // v2.57.x B2: persist retry outcome counters. The retry path costs
934
1085
  // 1 extra Haiku call per bugfix/decision episode; if recovered/attempts
@@ -941,9 +1092,15 @@ ${actionList}`;
941
1092
  try {
942
1093
  const cdb = openDb();
943
1094
  if (cdb) {
944
- try { recordRetryAttempt(cdb, retryRecovered); } finally { cdb.close(); }
1095
+ try {
1096
+ recordRetryAttempt(cdb, retryRecovered);
1097
+ } finally {
1098
+ cdb.close();
1099
+ }
945
1100
  }
946
- } catch (e) { debugCatch(e, 'retry-stats-write'); }
1101
+ } catch (e) {
1102
+ debugCatch(e, 'retry-stats-write');
1103
+ }
947
1104
  }
948
1105
 
949
1106
  const searchAliases = Array.isArray(parsed.search_aliases)
@@ -982,9 +1139,10 @@ ${actionList}`;
982
1139
  // to schema.mjs). Haiku's OWN importance can still reach 3 (genuine judgment); only the
983
1140
  // path heuristic is capped. The isLessonLowSignal branch still floors no-lesson
984
1141
  // non-decision autos at ≤1; manual mem_save uses a different path and is unaffected.
985
- importance: isLessonLowSignal && !retryRecovered && parsed.type !== 'decision'
986
- ? Math.min(ruleImportance, 1)
987
- : Math.max(Math.min(ruleImportance, 2), clampImportance(parsed.importance)),
1142
+ importance:
1143
+ isLessonLowSignal && !retryRecovered && parsed.type !== 'decision'
1144
+ ? Math.min(ruleImportance, 1)
1145
+ : Math.max(Math.min(ruleImportance, 2), clampImportance(parsed.importance)),
988
1146
  lessonLearned,
989
1147
  searchAliases,
990
1148
  // P3 (D#78): lesson applicability scope — whitelist-validated, invalid → null.
@@ -1004,11 +1162,16 @@ ${actionList}`;
1004
1162
  if (episode.savedId) {
1005
1163
  const ddb = openDb();
1006
1164
  if (ddb) {
1007
- try { retractPreSavedObs(ddb, episode.savedId, 'low-yield-change-drop'); }
1008
- finally { ddb.close(); }
1165
+ try {
1166
+ retractPreSavedObs(ddb, episode.savedId, 'low-yield-change-drop');
1167
+ } finally {
1168
+ ddb.close();
1169
+ }
1009
1170
  }
1010
1171
  }
1011
- try { unlinkSync(tmpFile); } catch {}
1172
+ try {
1173
+ unlinkSync(tmpFile);
1174
+ } catch {}
1012
1175
  return;
1013
1176
  }
1014
1177
  }
@@ -1019,14 +1182,21 @@ ${actionList}`;
1019
1182
  // If pre-saved observation exists, LLM degraded mode doesn't need to overwrite — keep pre-saved data
1020
1183
  if (episode.savedId) {
1021
1184
  debugLog('DEBUG', 'llm-episode', `LLM failed but pre-saved obs #${episode.savedId} exists, keeping`);
1022
- try { unlinkSync(tmpFile); } catch {}
1185
+ try {
1186
+ unlinkSync(tmpFile);
1187
+ } catch {}
1023
1188
  return;
1024
1189
  }
1025
1190
  obs = buildImmediateObservation(episode);
1026
1191
  }
1027
1192
 
1028
1193
  const db = openDb();
1029
- if (!db) { try { unlinkSync(tmpFile); } catch {} return; }
1194
+ if (!db) {
1195
+ try {
1196
+ unlinkSync(tmpFile);
1197
+ } catch {}
1198
+ return;
1199
+ }
1030
1200
 
1031
1201
  try {
1032
1202
  let savedId;
@@ -1073,31 +1243,42 @@ ${actionList}`;
1073
1243
  // tombstone: `changes` is 1, nothing looks wrong, and the row it landed on is
1074
1244
  // excluded from every read face by liveObsFilterSql. Same clause as those read
1075
1245
  // faces, so "what the update may touch" and "what a query may return" cannot drift.
1076
- const upgraded = db.prepare(`
1246
+ const upgraded = db
1247
+ .prepare(
1248
+ `
1077
1249
  UPDATE observations SET type=?, title=?, subtitle=?,
1078
1250
  narrative=COALESCE(NULLIF(?, ''), narrative), concepts=?, facts=?,
1079
1251
  text=?, importance=?, files_read=?, minhash_sig=?, lesson_learned=?, search_aliases=?,
1080
1252
  scope=COALESCE(?, scope)
1081
1253
  WHERE id = ? AND ${liveObsFilterSql('')}
1082
- `).run(
1083
- obs.type, safe.title, safe.subtitle,
1084
- safe.narrative,
1085
- safe.concepts, safe.facts, safe.text,
1086
- obs.importance,
1087
- JSON.stringify(obs.filesRead || []),
1088
- minhashSig,
1089
- safe.lesson_learned,
1090
- safe.search_aliases,
1091
- normalizeScope(obs.scope),
1092
- episode.savedId
1093
- ).changes;
1254
+ `,
1255
+ )
1256
+ .run(
1257
+ obs.type,
1258
+ safe.title,
1259
+ safe.subtitle,
1260
+ safe.narrative,
1261
+ safe.concepts,
1262
+ safe.facts,
1263
+ safe.text,
1264
+ obs.importance,
1265
+ JSON.stringify(obs.filesRead || []),
1266
+ minhashSig,
1267
+ safe.lesson_learned,
1268
+ safe.search_aliases,
1269
+ normalizeScope(obs.scope),
1270
+ episode.savedId,
1271
+ ).changes;
1094
1272
 
1095
1273
  if (upgraded === 0) {
1096
1274
  // The pre-saved row is gone or tombstoned. Dropping the enrichment here is the
1097
1275
  // silent-loss shape this repository keeps paying for, so save it as a fresh row
1098
1276
  // and let the normal dedup path decide whether it merges into the keeper.
1099
- debugLog('DEBUG', 'llm-episode',
1100
- `pre-saved obs #${episode.savedId} no longer live — saving enrichment as a fresh row`);
1277
+ debugLog(
1278
+ 'DEBUG',
1279
+ 'llm-episode',
1280
+ `pre-saved obs #${episode.savedId} no longer live — saving enrichment as a fresh row`,
1281
+ );
1101
1282
  const result = persistHaikuSummary(db, obsToSummary(obs), {
1102
1283
  project: episode.project,
1103
1284
  session_id: episode.sessionId,
@@ -1112,10 +1293,18 @@ ${actionList}`;
1112
1293
  // Update TF-IDF vector with enriched content. SQL + text derivation are
1113
1294
  // lib/observation-write.mjs's (audit 2026-09-02 P1-4); `gate: false` keeps this
1114
1295
  // path's prior behaviour, which never consulted vectorsEnabled().
1115
- upsertObservationVector(db, savedId, {
1116
- title: obs.title, narrative: obs.narrative, concepts: conceptsText,
1117
- lesson_learned: safe.lesson_learned, search_aliases: safe.search_aliases,
1118
- }, { gate: false, scope: 'handleLLMEpisode-vector' });
1296
+ upsertObservationVector(
1297
+ db,
1298
+ savedId,
1299
+ {
1300
+ title: obs.title,
1301
+ narrative: obs.narrative,
1302
+ concepts: conceptsText,
1303
+ lesson_learned: safe.lesson_learned,
1304
+ search_aliases: safe.search_aliases,
1305
+ },
1306
+ { gate: false, scope: 'handleLLMEpisode-vector' },
1307
+ );
1119
1308
  }
1120
1309
  }
1121
1310
  } else {
@@ -1133,13 +1322,17 @@ ${actionList}`;
1133
1322
  if (savedId && savedTable === 'observations') {
1134
1323
  try {
1135
1324
  linkRelatedObservations(db, savedId, obs, episode);
1136
- } catch (e) { debugCatch(e, 'relatedObsLinking'); }
1325
+ } catch (e) {
1326
+ debugCatch(e, 'relatedObsLinking');
1327
+ }
1137
1328
  }
1138
1329
  } finally {
1139
1330
  db.close();
1140
1331
  }
1141
1332
 
1142
- try { unlinkSync(tmpFile); } catch {}
1333
+ try {
1334
+ unlinkSync(tmpFile);
1335
+ } catch {}
1143
1336
  }
1144
1337
 
1145
1338
  // ─── Background: LLM Session Summary ────────────────────────────────────────
@@ -1174,17 +1367,31 @@ export async function handleLLMSummary() {
1174
1367
  pending = readdirSync(RUNTIME_DIR)
1175
1368
  .filter((f) => f.startsWith('ep-flush-'))
1176
1369
  .filter((f) => {
1177
- try { return statSync(join(RUNTIME_DIR, f)).mtimeMs >= cutoff; } catch { return false; }
1370
+ try {
1371
+ return statSync(join(RUNTIME_DIR, f)).mtimeMs >= cutoff;
1372
+ } catch {
1373
+ return false;
1374
+ }
1178
1375
  });
1179
- } catch { pending = []; }
1376
+ } catch {
1377
+ pending = [];
1378
+ }
1180
1379
 
1181
1380
  for (let i = 0; i < flushTimeout && pending.length > 0; i++) {
1182
1381
  await sleep(1000);
1183
1382
  pending = pending.filter((f) => existsSync(join(RUNTIME_DIR, f)));
1184
- debugLog('DEBUG', 'llm-summary', `waiting for ${pending.length} flush file(s) (${i + 1}/${flushTimeout})`);
1383
+ debugLog(
1384
+ 'DEBUG',
1385
+ 'llm-summary',
1386
+ `waiting for ${pending.length} flush file(s) (${i + 1}/${flushTimeout})`,
1387
+ );
1185
1388
  }
1186
1389
  if (pending.length > 0) {
1187
- debugLog('DEBUG', 'llm-summary', `gave up waiting on ${pending.length} flush file(s) after ${flushTimeout}s: ${pending.join(', ')}`);
1390
+ debugLog(
1391
+ 'DEBUG',
1392
+ 'llm-summary',
1393
+ `gave up waiting on ${pending.length} flush file(s) after ${flushTimeout}s: ${pending.join(', ')}`,
1394
+ );
1188
1395
  }
1189
1396
 
1190
1397
  const db = openDb();
@@ -1197,29 +1404,38 @@ export async function handleLLMSummary() {
1197
1404
  // Exclude LOW_SIGNAL hook-llm fallback titles ("Error: files +2 more: ...",
1198
1405
  // "Modified X", "Worked on X", etc.) from the Haiku summary input — they
1199
1406
  // pollute the `completed` field and mislead session-resume context.
1200
- const recentObs = db.prepare(`
1407
+ const recentObs = db
1408
+ .prepare(
1409
+ `
1201
1410
  SELECT id, type, title, narrative
1202
1411
  FROM observations
1203
1412
  WHERE memory_session_id = ?
1204
1413
  AND ${notLowSignalTitleClause('')}
1205
1414
  ORDER BY created_at_epoch DESC
1206
1415
  LIMIT 30
1207
- `).all(sessionId);
1416
+ `,
1417
+ )
1418
+ .all(sessionId);
1208
1419
 
1209
1420
  if (recentObs.length < 1) return;
1210
1421
 
1211
- const obsList = recentObs.map((o, i) =>
1212
- `${i + 1}. [${o.type}] ${o.title}${o.narrative ? ': ' + truncate(o.narrative, 200) : ''}`
1213
- ).join('\n');
1422
+ const obsList = recentObs
1423
+ .map(
1424
+ (o, i) => `${i + 1}. [${o.type}] ${o.title}${o.narrative ? ': ' + truncate(o.narrative, 200) : ''}`,
1425
+ )
1426
+ .join('\n');
1214
1427
 
1215
1428
  // Include user prompts for richer context
1216
- const userPrompts = db.prepare(`
1429
+ const userPrompts = db
1430
+ .prepare(
1431
+ `
1217
1432
  SELECT prompt_text FROM user_prompts
1218
1433
  WHERE content_session_id = ? ORDER BY prompt_number ASC LIMIT 10
1219
- `).all(sessionId).map(p => truncate(p.prompt_text, 300));
1220
- const promptCtx = userPrompts.length > 0
1221
- ? `\nUser requests: ${userPrompts.join(' → ')}\n`
1222
- : '';
1434
+ `,
1435
+ )
1436
+ .all(sessionId)
1437
+ .map((p) => truncate(p.prompt_text, 300));
1438
+ const promptCtx = userPrompts.length > 0 ? `\nUser requests: ${userPrompts.join(' → ')}\n` : '';
1223
1439
 
1224
1440
  // cso F#4: split system/user. The userPrompts content (line 921) is the
1225
1441
  // single highest-leakage path for memory poisoning — putting it in the
@@ -1253,9 +1469,12 @@ ${obsList}`;
1253
1469
  // ("Too many parameter values") out of this try/finally and drops the WHOLE summary incl.
1254
1470
  // lessons + key_decisions. Join array items; non-strings → ''. (lessons/key_decisions are
1255
1471
  // JSON.stringify'd separately below.)
1256
- const asText = v => Array.isArray(v)
1257
- ? v.filter(x => typeof x === 'string' && x.trim()).join('; ')
1258
- : (typeof v === 'string' ? v : '');
1472
+ const asText = (v) =>
1473
+ Array.isArray(v)
1474
+ ? v.filter((x) => typeof x === 'string' && x.trim()).join('; ')
1475
+ : typeof v === 'string'
1476
+ ? v
1477
+ : '';
1259
1478
 
1260
1479
  // Persist when ANY meaningful field is present — not just `request`. Gating on `request`
1261
1480
  // alone dropped the whole INSERT/UPDATE (losing the session's highest-value fields:
@@ -1264,24 +1483,35 @@ ${obsList}`;
1264
1483
  // empty request: INSERT writes '' and the UPDATE COALESCE(NULLIF(?, ''), request) preserves
1265
1484
  // the prior value. Use asText in the gate so a non-string / empty-array field can't falsely
1266
1485
  // trigger it.
1267
- const hasSummaryContent = llmParsed && (
1268
- asText(llmParsed.request) || asText(llmParsed.completed) || asText(llmParsed.remaining_items) || asText(llmParsed.next_steps) ||
1269
- (Array.isArray(llmParsed.lessons) && llmParsed.lessons.length > 0) ||
1270
- (Array.isArray(llmParsed.key_decisions) && llmParsed.key_decisions.length > 0)
1271
- );
1486
+ const hasSummaryContent =
1487
+ llmParsed &&
1488
+ (asText(llmParsed.request) ||
1489
+ asText(llmParsed.completed) ||
1490
+ asText(llmParsed.remaining_items) ||
1491
+ asText(llmParsed.next_steps) ||
1492
+ (Array.isArray(llmParsed.lessons) && llmParsed.lessons.length > 0) ||
1493
+ (Array.isArray(llmParsed.key_decisions) && llmParsed.key_decisions.length > 0));
1272
1494
  if (hasSummaryContent) {
1273
1495
  const now = new Date();
1274
- const lessonsJson = Array.isArray(llmParsed.lessons) && llmParsed.lessons.length > 0
1275
- ? JSON.stringify(llmParsed.lessons) : null;
1276
- const decisionsJson = Array.isArray(llmParsed.key_decisions) && llmParsed.key_decisions.length > 0
1277
- ? JSON.stringify(llmParsed.key_decisions) : null;
1496
+ const lessonsJson =
1497
+ Array.isArray(llmParsed.lessons) && llmParsed.lessons.length > 0
1498
+ ? JSON.stringify(llmParsed.lessons)
1499
+ : null;
1500
+ const decisionsJson =
1501
+ Array.isArray(llmParsed.key_decisions) && llmParsed.key_decisions.length > 0
1502
+ ? JSON.stringify(llmParsed.key_decisions)
1503
+ : null;
1278
1504
 
1279
1505
  // Upgrade existing fast summary instead of creating a duplicate
1280
- const existingFast = db.prepare(`
1506
+ const existingFast = db
1507
+ .prepare(
1508
+ `
1281
1509
  SELECT id FROM session_summaries
1282
1510
  WHERE memory_session_id = ? AND notes = 'fast'
1283
1511
  LIMIT 1
1284
- `).get(sessionId);
1512
+ `,
1513
+ )
1514
+ .get(sessionId);
1285
1515
 
1286
1516
  if (existingFast) {
1287
1517
  // Preserve structural-extractor content (completed / remaining_items written
@@ -1306,7 +1536,8 @@ ${obsList}`;
1306
1536
  lessons: lessonsJson,
1307
1537
  key_decisions: decisionsJson,
1308
1538
  });
1309
- db.prepare(`
1539
+ db.prepare(
1540
+ `
1310
1541
  UPDATE session_summaries
1311
1542
  SET request = COALESCE(NULLIF(?, ''), request),
1312
1543
  investigated = COALESCE(NULLIF(?, ''), investigated),
@@ -1320,13 +1551,19 @@ ${obsList}`;
1320
1551
  created_at = ?,
1321
1552
  created_at_epoch = ?
1322
1553
  WHERE id = ?
1323
- `).run(
1324
- safe.request, safe.investigated, safe.learned,
1325
- safe.completed, safe.next_steps,
1554
+ `,
1555
+ ).run(
1556
+ safe.request,
1557
+ safe.investigated,
1558
+ safe.learned,
1559
+ safe.completed,
1560
+ safe.next_steps,
1326
1561
  safe.remaining_items,
1327
- safe.lessons, safe.key_decisions,
1328
- now.toISOString(), now.getTime(),
1329
- existingFast.id
1562
+ safe.lessons,
1563
+ safe.key_decisions,
1564
+ now.toISOString(),
1565
+ now.getTime(),
1566
+ existingFast.id,
1330
1567
  );
1331
1568
  } else {
1332
1569
  const safe = scrubRecord('session_summaries', {
@@ -1339,16 +1576,24 @@ ${obsList}`;
1339
1576
  lessons: lessonsJson,
1340
1577
  key_decisions: decisionsJson,
1341
1578
  });
1342
- db.prepare(`
1579
+ db.prepare(
1580
+ `
1343
1581
  INSERT INTO session_summaries (memory_session_id, project, request, investigated, learned, completed, next_steps, remaining_items, files_read, files_edited, notes, lessons, key_decisions, created_at, created_at_epoch)
1344
1582
  VALUES (?, ?, ?, ?, ?, ?, ?, ?, '[]', '[]', '', ?, ?, ?, ?)
1345
- `).run(
1346
- sessionId, project,
1347
- safe.request, safe.investigated, safe.learned,
1348
- safe.completed, safe.next_steps,
1583
+ `,
1584
+ ).run(
1585
+ sessionId,
1586
+ project,
1587
+ safe.request,
1588
+ safe.investigated,
1589
+ safe.learned,
1590
+ safe.completed,
1591
+ safe.next_steps,
1349
1592
  safe.remaining_items,
1350
- safe.lessons, safe.key_decisions,
1351
- now.toISOString(), now.getTime()
1593
+ safe.lessons,
1594
+ safe.key_decisions,
1595
+ now.toISOString(),
1596
+ now.getTime(),
1352
1597
  );
1353
1598
  }
1354
1599
  }
@@ -1369,12 +1614,27 @@ ${obsList}`;
1369
1614
  // the observations INSERT path scrubs all configured text fields.
1370
1615
  export const __insertObservationForTest = (db, obs) => {
1371
1616
  const safe = scrubRecord('observations', obs);
1372
- db.prepare(`INSERT INTO observations (memory_session_id, project, text, type, title, subtitle, narrative, concepts, facts, files_read, files_modified, importance, minhash_sig, lesson_learned, search_aliases, branch, created_at, created_at_epoch)
1373
- VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`).run(
1374
- obs.session_id, obs.project, safe.text, 'change',
1375
- safe.title, safe.subtitle, safe.narrative,
1376
- safe.concepts, safe.facts, obs.files_read, obs.files_modified,
1377
- obs.importance, obs.minhash_sig, safe.lesson_learned, safe.search_aliases,
1378
- obs.branch, new Date().toISOString(), Date.now(),
1617
+ db.prepare(
1618
+ `INSERT INTO observations (memory_session_id, project, text, type, title, subtitle, narrative, concepts, facts, files_read, files_modified, importance, minhash_sig, lesson_learned, search_aliases, branch, created_at, created_at_epoch)
1619
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
1620
+ ).run(
1621
+ obs.session_id,
1622
+ obs.project,
1623
+ safe.text,
1624
+ 'change',
1625
+ safe.title,
1626
+ safe.subtitle,
1627
+ safe.narrative,
1628
+ safe.concepts,
1629
+ safe.facts,
1630
+ obs.files_read,
1631
+ obs.files_modified,
1632
+ obs.importance,
1633
+ obs.minhash_sig,
1634
+ safe.lesson_learned,
1635
+ safe.search_aliases,
1636
+ obs.branch,
1637
+ new Date().toISOString(),
1638
+ Date.now(),
1379
1639
  );
1380
1640
  };