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-optimize.mjs CHANGED
@@ -9,9 +9,17 @@
9
9
  import { readFileSync, writeFileSync } from 'fs';
10
10
  import { join } from 'path';
11
11
  import {
12
- truncate, debugLog, debugCatch, COMPRESSED_AUTO,
13
- computeMinHash, estimateJaccardFromMinHash, jaccardSimilarity, clampImportance, cjkBigrams,
14
- notLowSignalTitleClause, scrubSecrets,
12
+ truncate,
13
+ debugLog,
14
+ debugCatch,
15
+ COMPRESSED_AUTO,
16
+ computeMinHash,
17
+ estimateJaccardFromMinHash,
18
+ jaccardSimilarity,
19
+ clampImportance,
20
+ cjkBigrams,
21
+ notLowSignalTitleClause,
22
+ scrubSecrets,
15
23
  } from './utils.mjs';
16
24
  import { callModelJSONAsync, BG_LLM_TIMEOUT_MS } from './haiku-client.mjs';
17
25
  import { acquireLLMSlot, releaseLLMSlot } from './hook-semaphore.mjs';
@@ -201,12 +209,16 @@ export async function executeReenrich(db, limit = 10, { scope = 'narrow', projec
201
209
  const candidates = findReenrichCandidates(db, limit, { scope, project });
202
210
  if (candidates.length === 0) return { processed: 0, skipped: 0 };
203
211
 
204
- let processed = 0, skipped = 0;
212
+ let processed = 0,
213
+ skipped = 0;
205
214
  const validTypes = OBS_TYPE_SET;
206
215
 
207
216
  for (const cand of candidates) {
208
217
  const gotSlot = await acquireLLMSlot();
209
- if (!gotSlot) { skipped++; continue; }
218
+ if (!gotSlot) {
219
+ skipped++;
220
+ continue;
221
+ }
210
222
 
211
223
  try {
212
224
  if (scope === 'scopes') {
@@ -223,15 +235,25 @@ Lesson: ${truncate(cand.lesson_learned || '(none)', 300)}
223
235
 
224
236
  JSON: {"scope":"file|module|project|environment"}
225
237
  scope: ${SCOPE_PROMPT_LEGEND}`;
226
- const parsed = await callModelJSONAsync(scopePrompt, 'haiku', { timeout: BG_LLM_TIMEOUT_MS, maxTokens: 60 });
238
+ const parsed = await callModelJSONAsync(scopePrompt, 'haiku', {
239
+ timeout: BG_LLM_TIMEOUT_MS,
240
+ maxTokens: 60,
241
+ });
227
242
  const scopeValue = normalizeScope(parsed && parsed.scope);
228
- if (!scopeValue) { skipped++; continue; }
243
+ if (!scopeValue) {
244
+ skipped++;
245
+ continue;
246
+ }
229
247
  // `AND scope IS NULL` is the fill-only-empty guard: a save-enrich worker or
230
248
  // an episode upgrade can land between candidate selection and this write,
231
249
  // and a classifier round-trip is long enough for that to be real.
232
- const res = db.prepare('UPDATE observations SET scope = ? WHERE id = ? AND scope IS NULL')
250
+ const res = db
251
+ .prepare('UPDATE observations SET scope = ? WHERE id = ? AND scope IS NULL')
233
252
  .run(scopeValue, cand.id);
234
- if (res.changes === 0) { skipped++; continue; }
253
+ if (res.changes === 0) {
254
+ skipped++;
255
+ continue;
256
+ }
235
257
  // No rebuildVector: scope is a filter column, absent from the FTS text
236
258
  // field and from vecTextForRow — a rebuild here would be a no-op write.
237
259
  processed++;
@@ -251,11 +273,18 @@ Narrative: ${truncate(cand.narrative || '(no narrative)', 500)}
251
273
  JSON: {"search_aliases":["alt phrasing","synonym","spelled-out jargon","CJK term if the domain word has one"],"scope":"file|module|project|environment"}
252
274
  Give 3-6 aliases: words a user might search for the SAME concept but that are NOT already in the title (synonyms, the spelled-out form of an acronym, the jargon term for a described symptom, a CJK translation of a key domain term).
253
275
  scope: ${SCOPE_PROMPT_LEGEND}`;
254
- const parsed = await callModelJSONAsync(aliasPrompt, 'haiku', { timeout: BG_LLM_TIMEOUT_MS, maxTokens: 300 });
255
- const aliasArr = parsed && Array.isArray(parsed.search_aliases)
256
- ? parsed.search_aliases.filter((a) => typeof a === 'string' && a.trim().length > 0)
257
- : [];
258
- if (!aliasArr.length) { skipped++; continue; }
276
+ const parsed = await callModelJSONAsync(aliasPrompt, 'haiku', {
277
+ timeout: BG_LLM_TIMEOUT_MS,
278
+ maxTokens: 300,
279
+ });
280
+ const aliasArr =
281
+ parsed && Array.isArray(parsed.search_aliases)
282
+ ? parsed.search_aliases.filter((a) => typeof a === 'string' && a.trim().length > 0)
283
+ : [];
284
+ if (!aliasArr.length) {
285
+ skipped++;
286
+ continue;
287
+ }
259
288
  const searchAliases = aliasArr.slice(0, 6).join(' ');
260
289
  const aliasBigrams = cjkBigrams(searchAliases);
261
290
  const appendedText = [cand.text || '', searchAliases, aliasBigrams].filter(Boolean).join(' ');
@@ -263,8 +292,9 @@ scope: ${SCOPE_PROMPT_LEGEND}`;
263
292
  // scope rides this call for free (D#135 P3). COALESCE, not a plain set:
264
293
  // an omitted or off-enum value normalizes to null and must not erase a
265
294
  // classification an earlier face already wrote.
266
- db.prepare(`UPDATE observations SET search_aliases = ?, text = ?, scope = COALESCE(?, scope) WHERE id = ?`)
267
- .run(safe.search_aliases, safe.text, normalizeScope(parsed.scope), cand.id);
295
+ db.prepare(
296
+ `UPDATE observations SET search_aliases = ?, text = ?, scope = COALESCE(?, scope) WHERE id = ?`,
297
+ ).run(safe.search_aliases, safe.text, normalizeScope(parsed.scope), cand.id);
268
298
  // Refresh the TF-IDF vector from the just-updated FTS text so the new
269
299
  // aliases reach the vector arm too — the narrow/wide branch rebuilds, this
270
300
  // one must as well. No-ops when the vector arm is off / vocab unbuilt.
@@ -284,8 +314,14 @@ lesson_learned: State what was learned. If routine, write "none".
284
314
  search_aliases: 2-6 alternative search terms (include CJK if applicable).
285
315
  scope: ${SCOPE_PROMPT_LEGEND}`;
286
316
 
287
- const parsed = await callModelJSONAsync(prompt, 'haiku', { timeout: BG_LLM_TIMEOUT_MS, maxTokens: 500 });
288
- if (!parsed || !parsed.title) { skipped++; continue; }
317
+ const parsed = await callModelJSONAsync(prompt, 'haiku', {
318
+ timeout: BG_LLM_TIMEOUT_MS,
319
+ maxTokens: 500,
320
+ });
321
+ if (!parsed || !parsed.title) {
322
+ skipped++;
323
+ continue;
324
+ }
289
325
 
290
326
  // Auto-hide on importance:0 targets fully-degraded NARROW rows (this branch predates
291
327
  // the wide-scope widening). A wide candidate has a substantive narrative (>100 chars)
@@ -294,15 +330,16 @@ scope: ${SCOPE_PROMPT_LEGEND}`;
294
330
  // hide a real observation until manual surgery. In wide scope, fall through and let
295
331
  // clampImportance floor it to 1 (kept visible, low-ranked) instead of hiding.
296
332
  if ((parsed.importance === 0 || parsed.importance === '0') && scope !== 'wide') {
297
- db.prepare(`UPDATE observations SET compressed_into = ${COMPRESSED_AUTO}, optimized_at = ? WHERE id = ?`)
298
- .run(Date.now(), cand.id);
333
+ db.prepare(
334
+ `UPDATE observations SET compressed_into = ${COMPRESSED_AUTO}, optimized_at = ? WHERE id = ?`,
335
+ ).run(Date.now(), cand.id);
299
336
  processed++;
300
337
  continue;
301
338
  }
302
339
 
303
340
  // Enrichment ("add a lesson") must not reclassify a specific type down to the generic
304
341
  // 'change' (lower TYPE_QUALITY + faster decay); keep the stored type on that downgrade.
305
- let type = validTypes.has(parsed.type) ? parsed.type : (cand.type || 'change');
342
+ let type = validTypes.has(parsed.type) ? parsed.type : cand.type || 'change';
306
343
  if (type === 'change' && cand.type && cand.type !== 'change') type = cand.type;
307
344
  const concepts = Array.isArray(parsed.concepts) ? parsed.concepts.slice(0, 10) : [];
308
345
  const facts = Array.isArray(parsed.facts) ? parsed.facts.slice(0, 10) : [];
@@ -312,17 +349,21 @@ scope: ${SCOPE_PROMPT_LEGEND}`;
312
349
  // sets optimized_at, locking the row out of any future re-enrich (:88), so the loss is
313
350
  // permanent. Keep the candidate's existing value when the LLM returned nothing. (Narrow
314
351
  // candidates are all-null on these by their WHERE, so cand.* is falsy → no-op there.)
315
- const conceptsText = concepts.length ? concepts.join(' ') : (cand.concepts || '');
316
- const factsText = facts.length ? facts.join(' ') : (cand.facts || '');
352
+ const conceptsText = concepts.length ? concepts.join(' ') : cand.concepts || '';
353
+ const factsText = facts.length ? facts.join(' ') : cand.facts || '';
317
354
  // Scrub BEFORE truncate so a secret straddling the cut can't leave a sub-6-char
318
355
  // head that scrubSecrets's value-length floor no longer matches (the scrubRecord
319
356
  // below would then miss it too). Mirrors the hook-llm save-path fix.
320
- const lessonLearned = typeof parsed.lesson_learned === 'string'
321
- && parsed.lesson_learned.toLowerCase() !== 'none'
322
- && parsed.lesson_learned.trim().length > 0
323
- ? scrubSecrets(parsed.lesson_learned).slice(0, 500) : null;
324
- const searchAliases = Array.isArray(parsed.search_aliases) && parsed.search_aliases.length
325
- ? parsed.search_aliases.slice(0, 6).join(' ') : (cand.search_aliases || null);
357
+ const lessonLearned =
358
+ typeof parsed.lesson_learned === 'string' &&
359
+ parsed.lesson_learned.toLowerCase() !== 'none' &&
360
+ parsed.lesson_learned.trim().length > 0
361
+ ? scrubSecrets(parsed.lesson_learned).slice(0, 500)
362
+ : null;
363
+ const searchAliases =
364
+ Array.isArray(parsed.search_aliases) && parsed.search_aliases.length
365
+ ? parsed.search_aliases.slice(0, 6).join(' ')
366
+ : cand.search_aliases || null;
326
367
  const title = truncate(scrubSecrets(parsed.title || ''), 120);
327
368
  const narrative = truncate(scrubSecrets(parsed.narrative || cand.narrative || ''), 500);
328
369
  // Floor at the stored importance: re-enrich adds a lesson, it must never silently downgrade
@@ -337,26 +378,47 @@ scope: ${SCOPE_PROMPT_LEGEND}`;
337
378
  // Scrub LLM-output text fields at the UPDATE boundary. type is an
338
379
  // enum, importance is numeric, minhash_sig is hash bytes.
339
380
  const safe = scrubRecord('observations', {
340
- title, narrative,
381
+ title,
382
+ narrative,
341
383
  concepts: conceptsText,
342
384
  facts: factsText,
343
385
  text: textField,
344
386
  lesson_learned: lessonLearned,
345
387
  search_aliases: searchAliases,
346
388
  });
347
- db.prepare(`
389
+ db.prepare(
390
+ `
348
391
  UPDATE observations SET type=?, title=?, narrative=?, concepts=?, facts=?,
349
392
  text=?, importance=?, lesson_learned=?, search_aliases=?, minhash_sig=?, optimized_at=?,
350
393
  scope=COALESCE(?, scope)
351
394
  WHERE id = ?
352
- `).run(type, safe.title, safe.narrative, safe.concepts, safe.facts, safe.text,
353
- importance, safe.lesson_learned, safe.search_aliases, minhashSig, Date.now(),
395
+ `,
396
+ ).run(
397
+ type,
398
+ safe.title,
399
+ safe.narrative,
400
+ safe.concepts,
401
+ safe.facts,
402
+ safe.text,
403
+ importance,
404
+ safe.lesson_learned,
405
+ safe.search_aliases,
406
+ minhashSig,
407
+ Date.now(),
354
408
  // COALESCE (mirrors the hook-llm upgrade path): a re-enrich that omits
355
409
  // scope, or emits an off-enum value, must never blank an existing label —
356
410
  // and THIS update stamps optimized_at, so the loss would be permanent.
357
- normalizeScope(parsed.scope), cand.id);
411
+ normalizeScope(parsed.scope),
412
+ cand.id,
413
+ );
358
414
 
359
- rebuildVector(db, cand.id, { title, narrative, concepts: conceptsText, lesson_learned: safe.lesson_learned, search_aliases: safe.search_aliases });
415
+ rebuildVector(db, cand.id, {
416
+ title,
417
+ narrative,
418
+ concepts: conceptsText,
419
+ lesson_learned: safe.lesson_learned,
420
+ search_aliases: safe.search_aliases,
421
+ });
360
422
 
361
423
  processed++;
362
424
  } catch (e) {
@@ -441,9 +503,12 @@ Rules:
441
503
  - Include CJK ↔ English equivalents if present
442
504
  - Skip terms that have no synonyms in the list`;
443
505
 
444
- const parsed = await callModelJSONAsync(prompt, 'sonnet', { timeout: BG_LLM_TIMEOUT_MS, maxTokens: 1000 });
506
+ const parsed = await callModelJSONAsync(prompt, 'sonnet', {
507
+ timeout: BG_LLM_TIMEOUT_MS,
508
+ maxTokens: 1000,
509
+ });
445
510
  if (!parsed?.groups || !Array.isArray(parsed.groups)) return [];
446
- return parsed.groups.filter(g => g.canonical && Array.isArray(g.aliases) && g.aliases.length > 0);
511
+ return parsed.groups.filter((g) => g.canonical && Array.isArray(g.aliases) && g.aliases.length > 0);
447
512
  } catch (e) {
448
513
  debugCatch(e, 'normalize-identify');
449
514
  return [];
@@ -467,12 +532,16 @@ export function applyNormalization(db, groups, { project = null } = {}) {
467
532
  // concepts/search_aliases of EVERY project's observations — the exact cross-project
468
533
  // contamination the --project flag was added to prevent. NULL → all projects (legacy
469
534
  // unscoped run), matching the search-engine `(? IS NULL OR project = ?)` idiom.
470
- const rows = db.prepare(`
535
+ const rows = db
536
+ .prepare(
537
+ `
471
538
  SELECT id, title, narrative, concepts, search_aliases, lesson_learned FROM observations
472
539
  WHERE COALESCE(compressed_into, 0) = 0
473
540
  AND concepts IS NOT NULL AND concepts != ''
474
541
  AND (? IS NULL OR project = ?)
475
- `).all(project, project);
542
+ `,
543
+ )
544
+ .all(project, project);
476
545
 
477
546
  let updated = 0;
478
547
  const updateStmt = db.prepare(`
@@ -482,16 +551,21 @@ export function applyNormalization(db, groups, { project = null } = {}) {
482
551
  for (const row of rows) {
483
552
  const terms = row.concepts.split(/\s+/);
484
553
  let changed = false;
485
- const newTerms = terms.map(t => {
554
+ const newTerms = terms.map((t) => {
486
555
  const canonical = aliasMap.get(t.toLowerCase());
487
- if (canonical && canonical !== t) { changed = true; return canonical; }
556
+ if (canonical && canonical !== t) {
557
+ changed = true;
558
+ return canonical;
559
+ }
488
560
  return t;
489
561
  });
490
562
 
491
563
  if (changed) {
492
564
  const uniqueConcepts = [...new Set(newTerms)].join(' ');
493
565
  const existingAliases = row.search_aliases || '';
494
- const originalTerms = terms.filter(t => aliasMap.has(t.toLowerCase()) && aliasMap.get(t.toLowerCase()) !== t);
566
+ const originalTerms = terms.filter(
567
+ (t) => aliasMap.has(t.toLowerCase()) && aliasMap.get(t.toLowerCase()) !== t,
568
+ );
495
569
  const newAliases = [existingAliases, ...originalTerms].filter(Boolean).join(' ');
496
570
  // Defense-in-depth scrub. Canonical concept names come from LLM output
497
571
  // (identifySynonymGroups via Sonnet); existing values are already
@@ -503,7 +577,13 @@ export function applyNormalization(db, groups, { project = null } = {}) {
503
577
  updateStmt.run(safe.concepts, safe.search_aliases, Date.now(), row.id);
504
578
  // V-F3: normalize mutated concepts + search_aliases (both vector fields) — rebuild the
505
579
  // vector so it reflects the canonicalized terms (no-op when the vector arm is disabled).
506
- rebuildVector(db, row.id, { title: row.title, narrative: row.narrative, concepts: safe.concepts, search_aliases: safe.search_aliases, lesson_learned: row.lesson_learned });
580
+ rebuildVector(db, row.id, {
581
+ title: row.title,
582
+ narrative: row.narrative,
583
+ concepts: safe.concepts,
584
+ search_aliases: safe.search_aliases,
585
+ lesson_learned: row.lesson_learned,
586
+ });
507
587
  updated++;
508
588
  }
509
589
  }
@@ -526,7 +606,13 @@ export async function executeNormalize(db, force = false, { project } = {}) {
526
606
  // Only the UNSCOPED (whole-store) run advances the shared 7-day gate. A project-scoped run
527
607
  // must not reset the global timer (it never consulted it — shouldRunNormalize(project) is
528
608
  // always open), or one `--project X` run would silently block the next global normalize.
529
- if (!project) { try { writeFileSync(NORMALIZE_GATE_FILE, JSON.stringify({ epoch: Date.now() })); } catch { /* best-effort */ } }
609
+ if (!project) {
610
+ try {
611
+ writeFileSync(NORMALIZE_GATE_FILE, JSON.stringify({ epoch: Date.now() }));
612
+ } catch {
613
+ /* best-effort */
614
+ }
615
+ }
530
616
 
531
617
  return { processed: result.updated, groups: groups.length };
532
618
  }
@@ -596,9 +682,12 @@ export async function executeMergeCluster(db, cluster) {
596
682
  if (!gotSlot) return { merged: false };
597
683
 
598
684
  try {
599
- const obsDescriptions = cluster.map((o, i) =>
600
- `${i + 1}. [${o.type || 'change'}] "${truncate(o.title, 200)}" — ${truncate(o.narrative || '(no narrative)', 500)}`
601
- ).join('\n');
685
+ const obsDescriptions = cluster
686
+ .map(
687
+ (o, i) =>
688
+ `${i + 1}. [${o.type || 'change'}] "${truncate(o.title, 200)}" — ${truncate(o.narrative || '(no narrative)', 500)}`,
689
+ )
690
+ .join('\n');
602
691
 
603
692
  const prompt = `These observations from a code memory database may be about the same topic. Should they be merged into a single observation?
604
693
 
@@ -609,18 +698,22 @@ Return ONLY valid JSON:
609
698
  - If they should NOT be merged: {"should_merge":false}
610
699
  - If they SHOULD be merged: {"should_merge":true,"merged_title":"≤120 char comprehensive title","merged_narrative":"comprehensive ≤800 char summary preserving all key details","merged_concepts":["kw1","kw2"],"merged_facts":["specific fact 1"],"merged_lesson":"synthesized non-obvious lesson or null","importance":2}`;
611
700
 
612
- const parsed = await callModelJSONAsync(prompt, 'sonnet', { timeout: BG_LLM_TIMEOUT_MS, maxTokens: 1000 });
701
+ const parsed = await callModelJSONAsync(prompt, 'sonnet', {
702
+ timeout: BG_LLM_TIMEOUT_MS,
703
+ maxTokens: 1000,
704
+ });
613
705
  if (!parsed || !parsed.should_merge) return { merged: false };
614
706
 
615
707
  // Keeper = highest importance, then highest access_count. Previously access_count
616
708
  // alone, so a critical (importance=3) but never-accessed observation lost the keeper
617
709
  // role to a trivial (importance=1) accessed one and was compressed away.
618
710
  const keeper = cluster.reduce((best, o) => {
619
- const oi = o.importance || 1, bi = best.importance || 1;
711
+ const oi = o.importance || 1,
712
+ bi = best.importance || 1;
620
713
  if (oi !== bi) return oi > bi ? o : best;
621
714
  return (o.access_count || 0) > (best.access_count || 0) ? o : best;
622
715
  }, cluster[0]);
623
- const others = cluster.filter(o => o.id !== keeper.id);
716
+ const others = cluster.filter((o) => o.id !== keeper.id);
624
717
  // Floor the merged importance at the cluster max — merging must never silently
625
718
  // downgrade the ranking of the most-important member (the LLM default is 2). The keeper
626
719
  // is selected by importance-first, so keeper.importance IS the cluster max by construction.
@@ -631,8 +724,8 @@ Return ONLY valid JSON:
631
724
  // Preserve-on-empty (mirror the merged_lesson guard below + the re-enrich path): the merge
632
725
  // overwrites the keeper in place, so a partial LLM response that omits these must fall back to
633
726
  // the keeper's own values, not blank its live concepts/facts (findMergeCandidates now selects them).
634
- const conceptsText = concepts.length ? concepts.join(' ') : (keeper.concepts || '');
635
- const factsText = facts.length ? facts.join(' ') : (keeper.facts || '');
727
+ const conceptsText = concepts.length ? concepts.join(' ') : keeper.concepts || '';
728
+ const factsText = facts.length ? facts.join(' ') : keeper.facts || '';
636
729
  // Scrub BEFORE truncate (see re-enrich note): keep the boundary cut on
637
730
  // already-scrubbed text so a straddling secret can't leak a sub-floor head.
638
731
  const title = truncate(scrubSecrets(parsed.merged_title || ''), 120);
@@ -647,13 +740,16 @@ Return ONLY valid JSON:
647
740
  // (a tombstoned/retired lesson can't resurrect onto the keeper). The union is scrubbed then
648
741
  // capped at 500 chars like a single lesson, so an unusually long union may truncate trailing
649
742
  // members — still strictly better than the prior unconditional null (partial > total loss).
650
- let lessonLearned = typeof parsed.merged_lesson === 'string'
651
- && parsed.merged_lesson.trim().length > 0
652
- ? scrubSecrets(parsed.merged_lesson).slice(0, 500) : null;
743
+ let lessonLearned =
744
+ typeof parsed.merged_lesson === 'string' && parsed.merged_lesson.trim().length > 0
745
+ ? scrubSecrets(parsed.merged_lesson).slice(0, 500)
746
+ : null;
653
747
  if (!lessonLearned) {
654
- const memberLessons = [...new Set(cluster
655
- .map(o => (o.lesson_learned || '').trim())
656
- .filter(l => l && l.toLowerCase() !== 'none'))];
748
+ const memberLessons = [
749
+ ...new Set(
750
+ cluster.map((o) => (o.lesson_learned || '').trim()).filter((l) => l && l.toLowerCase() !== 'none'),
751
+ ),
752
+ ];
657
753
  if (memberLessons.length) lessonLearned = scrubSecrets(memberLessons.join(' — ')).slice(0, 500);
658
754
  }
659
755
 
@@ -665,7 +761,8 @@ Return ONLY valid JSON:
665
761
  // Scrub LLM-output cluster-merge text fields at the UPDATE boundary.
666
762
  // importance is numeric; minhash_sig is hash bytes.
667
763
  const safe = scrubRecord('observations', {
668
- title, narrative,
764
+ title,
765
+ narrative,
669
766
  concepts: conceptsText,
670
767
  facts: factsText,
671
768
  text: textField,
@@ -679,9 +776,9 @@ Return ONLY valid JSON:
679
776
  // the other members at a tombstoned keeper is precisely the "buried behind a hidden
680
777
  // parent" loss that mergeDuplicates' docblock enumerates (lib/maintain-core.mjs), and
681
778
  // it is what that function's own isLive gate exists to prevent. Same predicate here.
682
- const keeperLive = db.prepare(
683
- `SELECT 1 FROM observations WHERE id = ? AND ${liveObsFilterSql('')}`
684
- ).get(keeper.id);
779
+ const keeperLive = db
780
+ .prepare(`SELECT 1 FROM observations WHERE id = ? AND ${liveObsFilterSql('')}`)
781
+ .get(keeper.id);
685
782
  if (!keeperLive) return false;
686
783
 
687
784
  // Snapshot the keeper's pre-merge row BEFORE overwriting it, so its original
@@ -691,27 +788,43 @@ Return ONLY valid JSON:
691
788
  // would otherwise destroy its original text irreversibly (HIGH-3 data loss).
692
789
  // Column list is derived from the live schema (minus id/compressed_into) so
693
790
  // it stays correct as migrations add columns; names are internal identifiers.
694
- const snapCols = db.prepare(`PRAGMA table_info(observations)`).all()
695
- .map(c => c.name).filter(c => c !== 'id' && c !== 'compressed_into');
791
+ const snapCols = db
792
+ .prepare(`PRAGMA table_info(observations)`)
793
+ .all()
794
+ .map((c) => c.name)
795
+ .filter((c) => c !== 'id' && c !== 'compressed_into');
696
796
  const snapColList = snapCols.join(', ');
697
797
  db.prepare(
698
798
  `INSERT INTO observations (${snapColList}, compressed_into)
699
- SELECT ${snapColList}, ? FROM observations WHERE id = ?`
799
+ SELECT ${snapColList}, ? FROM observations WHERE id = ?`,
700
800
  ).run(keeper.id, keeper.id);
701
801
 
702
- db.prepare(`
802
+ db.prepare(
803
+ `
703
804
  UPDATE observations SET title=?, narrative=?, concepts=?, facts=?, text=?,
704
805
  importance=?, lesson_learned=?, minhash_sig=?, optimized_at=?
705
806
  WHERE id = ?
706
- `).run(safe.title, safe.narrative, safe.concepts, safe.facts, safe.text,
707
- importance, safe.lesson_learned, minhashSig, Date.now(), keeper.id);
708
-
709
- const otherIds = others.map(o => o.id);
807
+ `,
808
+ ).run(
809
+ safe.title,
810
+ safe.narrative,
811
+ safe.concepts,
812
+ safe.facts,
813
+ safe.text,
814
+ importance,
815
+ safe.lesson_learned,
816
+ minhashSig,
817
+ Date.now(),
818
+ keeper.id,
819
+ );
820
+
821
+ const otherIds = others.map((o) => o.id);
710
822
  const ph = otherIds.map(() => '?').join(',');
711
823
  // Live guard on the members too: one already compressed into ANOTHER summary S during
712
824
  // the LLM window would be re-pointed here, silently dropping a row out of S's child set.
713
- db.prepare(`UPDATE observations SET compressed_into = ? WHERE id IN (${ph}) AND ${liveObsFilterSql('')}`)
714
- .run(keeper.id, ...otherIds);
825
+ db.prepare(
826
+ `UPDATE observations SET compressed_into = ? WHERE id IN (${ph}) AND ${liveObsFilterSql('')}`,
827
+ ).run(keeper.id, ...otherIds);
715
828
  return true;
716
829
  })();
717
830
  if (!mergeApplied) {
@@ -719,7 +832,13 @@ Return ONLY valid JSON:
719
832
  return { merged: false };
720
833
  }
721
834
 
722
- rebuildVector(db, keeper.id, { title, narrative, concepts: conceptsText, lesson_learned: lessonLearned, search_aliases: keeper.search_aliases });
835
+ rebuildVector(db, keeper.id, {
836
+ title,
837
+ narrative,
838
+ concepts: conceptsText,
839
+ lesson_learned: lessonLearned,
840
+ search_aliases: keeper.search_aliases,
841
+ });
723
842
 
724
843
  debugLog('DEBUG', 'llm-optimize', `merged ${cluster.length} observations into #${keeper.id}`);
725
844
  return { merged: true, keeperId: keeper.id, mergedCount: others.length };
@@ -792,10 +911,12 @@ export function clusterForCompression(candidates, db) {
792
911
  if (obs.length < 3) continue;
793
912
 
794
913
  let vocab;
795
- try { vocab = getVocabulary(db); } catch {}
914
+ try {
915
+ vocab = getVocabulary(db);
916
+ } catch {}
796
917
 
797
918
  if (vocab) {
798
- const vectors = obs.map(o => {
919
+ const vectors = obs.map((o) => {
799
920
  const text = [o.title || '', o.narrative || ''].join(' ');
800
921
  return computeVector(text, vocab);
801
922
  });
@@ -816,7 +937,7 @@ export function clusterForCompression(candidates, db) {
816
937
  }
817
938
 
818
939
  if (cluster.length >= 3) {
819
- const sorted = cluster.map(c => c.obs).sort((a, b) => a.created_at_epoch - b.created_at_epoch);
940
+ const sorted = cluster.map((c) => c.obs).sort((a, b) => a.created_at_epoch - b.created_at_epoch);
820
941
  let subCluster = [sorted[0]];
821
942
  for (let k = 1; k < sorted.length; k++) {
822
943
  if (sorted[k].created_at_epoch - subCluster[0].created_at_epoch > COMPRESS_TIME_SPLIT_MS) {
@@ -855,9 +976,12 @@ export async function executeSmartCompressCluster(db, observations, project) {
855
976
  if (!gotSlot) return { compressed: false };
856
977
 
857
978
  try {
858
- const obsDescriptions = observations.map((o, i) =>
859
- `${i + 1}. [${o.type || 'change'}] "${truncate(o.title || '(untitled)', 200)}" — ${truncate(o.narrative || '(no narrative)', 500)}${o.lesson_learned ? ` | Lesson: ${truncate(o.lesson_learned, 200)}` : ''}`
860
- ).join('\n');
979
+ const obsDescriptions = observations
980
+ .map(
981
+ (o, i) =>
982
+ `${i + 1}. [${o.type || 'change'}] "${truncate(o.title || '(untitled)', 200)}" — ${truncate(o.narrative || '(no narrative)', 500)}${o.lesson_learned ? ` | Lesson: ${truncate(o.lesson_learned, 200)}` : ''}`,
983
+ )
984
+ .join('\n');
861
985
 
862
986
  const prompt = `Summarize these related code memory observations into ONE comprehensive summary. Preserve all important decisions, lessons, and specific facts. Return ONLY valid JSON.
863
987
 
@@ -866,7 +990,10 @@ ${obsDescriptions}
866
990
 
867
991
  JSON: {"title":"descriptive summary ≤120 chars","narrative":"comprehensive summary ≤800 chars preserving key decisions and lessons","concepts":["kw1","kw2"],"facts":["all specific facts preserved"],"lesson_learned":"most important synthesized lesson or 'none'","search_aliases":["alt search 1","alt search 2"]}`;
868
992
 
869
- const parsed = await callModelJSONAsync(prompt, 'sonnet', { timeout: BG_LLM_TIMEOUT_MS, maxTokens: 1000 });
993
+ const parsed = await callModelJSONAsync(prompt, 'sonnet', {
994
+ timeout: BG_LLM_TIMEOUT_MS,
995
+ maxTokens: 1000,
996
+ });
870
997
  if (!parsed || !parsed.title) return { compressed: false };
871
998
 
872
999
  // Scrub BEFORE truncate (see re-enrich note): boundary cut on scrubbed text.
@@ -876,25 +1003,29 @@ JSON: {"title":"descriptive summary ≤120 chars","narrative":"comprehensive sum
876
1003
  const facts = Array.isArray(parsed.facts) ? parsed.facts.slice(0, 10) : [];
877
1004
  const conceptsText = concepts.join(' ');
878
1005
  const factsText = facts.join(' ');
879
- const lessonLearned = typeof parsed.lesson_learned === 'string'
880
- && parsed.lesson_learned.toLowerCase() !== 'none'
881
- && parsed.lesson_learned.trim().length > 0
882
- ? scrubSecrets(parsed.lesson_learned).slice(0, 500) : null;
1006
+ const lessonLearned =
1007
+ typeof parsed.lesson_learned === 'string' &&
1008
+ parsed.lesson_learned.toLowerCase() !== 'none' &&
1009
+ parsed.lesson_learned.trim().length > 0
1010
+ ? scrubSecrets(parsed.lesson_learned).slice(0, 500)
1011
+ : null;
883
1012
  const searchAliases = Array.isArray(parsed.search_aliases)
884
- ? parsed.search_aliases.slice(0, 6).join(' ') : null;
1013
+ ? parsed.search_aliases.slice(0, 6).join(' ')
1014
+ : null;
885
1015
 
886
1016
  const bigramText = cjkBigrams((title || '') + ' ' + (narrative || ''));
887
1017
  const textField = [conceptsText, factsText, searchAliases || '', bigramText].filter(Boolean).join(' ');
888
1018
 
889
- const epochs = observations.map(o => o.created_at_epoch).sort((a, b) => a - b);
1019
+ const epochs = observations.map((o) => o.created_at_epoch).sort((a, b) => a - b);
890
1020
  const medianEpoch = epochs[Math.floor(epochs.length / 2)];
891
1021
 
892
1022
  const summaryId = db.transaction(() => {
893
1023
  const sessionId = `compress-${project}`;
894
1024
  const now = new Date();
895
- db.prepare(`INSERT OR IGNORE INTO sdk_sessions
1025
+ db.prepare(
1026
+ `INSERT OR IGNORE INTO sdk_sessions
896
1027
  (content_session_id, memory_session_id, project, started_at, started_at_epoch, status)
897
- VALUES (?,?,?,?,?,'active')`
1028
+ VALUES (?,?,?,?,?,'active')`,
898
1029
  ).run(sessionId, sessionId, project, now.toISOString(), now.getTime());
899
1030
 
900
1031
  // Defense-in-depth: title/narrative/etc. are LLM-generated compression
@@ -908,32 +1039,52 @@ JSON: {"title":"descriptive summary ≤120 chars","narrative":"comprehensive sum
908
1039
  lesson_learned: lessonLearned,
909
1040
  search_aliases: searchAliases,
910
1041
  });
911
- const result = db.prepare(`INSERT INTO observations
1042
+ const result = db
1043
+ .prepare(
1044
+ `INSERT INTO observations
912
1045
  (memory_session_id, project, text, type, title, subtitle, narrative, concepts, facts,
913
1046
  files_read, files_modified, importance, lesson_learned, search_aliases, optimized_at,
914
1047
  created_at, created_at_epoch)
915
- VALUES (?,?,?,?,?,'',?,?,?,'[]','[]',2,?,?,?,?,?)`
916
- ).run(sessionId, project, safe.text, 'discovery', safe.title, safe.narrative,
917
- safe.concepts, safe.facts, safe.lesson_learned, safe.search_aliases, Date.now(),
918
- new Date(medianEpoch).toISOString(), medianEpoch);
1048
+ VALUES (?,?,?,?,?,'',?,?,?,'[]','[]',2,?,?,?,?,?)`,
1049
+ )
1050
+ .run(
1051
+ sessionId,
1052
+ project,
1053
+ safe.text,
1054
+ 'discovery',
1055
+ safe.title,
1056
+ safe.narrative,
1057
+ safe.concepts,
1058
+ safe.facts,
1059
+ safe.lesson_learned,
1060
+ safe.search_aliases,
1061
+ Date.now(),
1062
+ new Date(medianEpoch).toISOString(),
1063
+ medianEpoch,
1064
+ );
919
1065
 
920
1066
  const sId = Number(result.lastInsertRowid);
921
1067
 
922
- const obsIds = observations.map(o => o.id);
1068
+ const obsIds = observations.map((o) => o.id);
923
1069
  const ph = obsIds.map(() => '?').join(',');
924
1070
  // Live guard (audit 2026-09-02 P0-3): the candidate SELECT is separated from this write
925
1071
  // by a Sonnet round-trip, so a member may already be compressed into another summary or
926
1072
  // tombstoned. Re-pointing it here would silently remove a row from that summary's child
927
1073
  // set. Members that lost liveness stay where they are; the summary still lands.
928
- db.prepare(`UPDATE observations SET compressed_into = ? WHERE id IN (${ph}) AND ${liveObsFilterSql('')}`)
929
- .run(sId, ...obsIds);
1074
+ db.prepare(
1075
+ `UPDATE observations SET compressed_into = ? WHERE id IN (${ph}) AND ${liveObsFilterSql('')}`,
1076
+ ).run(sId, ...obsIds);
930
1077
 
931
1078
  return sId;
932
1079
  })();
933
1080
 
934
1081
  rebuildVector(db, summaryId, { title, narrative, concepts: conceptsText });
935
1082
 
936
- debugLog('DEBUG', 'llm-optimize', `smart-compressed ${observations.length} observations into #${summaryId}`);
1083
+ debugLog(
1084
+ 'DEBUG',
1085
+ 'llm-optimize',
1086
+ `smart-compressed ${observations.length} observations into #${summaryId}`,
1087
+ );
937
1088
  return { compressed: true, summaryId, count: observations.length };
938
1089
  } catch (e) {
939
1090
  debugCatch(e, 'smart-compress');
@@ -1043,14 +1194,18 @@ export function optimizePreview(db, { project, detail = false } = {}) {
1043
1194
  * @param {string} [opts.project] Filter all tasks to a single project. Opt-in;
1044
1195
  * absence preserves the prior all-projects default.
1045
1196
  */
1046
- export async function optimizeRun(db, { tasks, maxItems = 15, force = false, reenrichScope = 'narrow', project } = {}) {
1197
+ export async function optimizeRun(
1198
+ db,
1199
+ { tasks, maxItems = 15, force = false, reenrichScope = 'narrow', project } = {},
1200
+ ) {
1047
1201
  const allTasks = ['re-enrich', 'normalize', 'cluster-merge', 'smart-compress'];
1048
1202
  const selectedTasks = tasks && tasks.length > 0 ? tasks : allTasks;
1049
1203
  // Single-task mode: give that task the full budget. Distribution only makes sense
1050
1204
  // when multiple tasks compete for the same pool.
1051
- const budget = selectedTasks.length === 1
1052
- ? { reenrich: maxItems, normalize: maxItems, clusterMerge: maxItems, smartCompress: maxItems }
1053
- : distributeBudget(maxItems);
1205
+ const budget =
1206
+ selectedTasks.length === 1
1207
+ ? { reenrich: maxItems, normalize: maxItems, clusterMerge: maxItems, smartCompress: maxItems }
1208
+ : distributeBudget(maxItems);
1054
1209
  const results = {};
1055
1210
 
1056
1211
  for (const task of selectedTasks) {
@@ -1088,18 +1243,26 @@ export async function optimizeRun(db, { tasks, maxItems = 15, force = false, ree
1088
1243
  // Cap is budget.reenrich, so the daily pass adds at most that many cheap
1089
1244
  // classification calls and an empty pool still costs nothing.
1090
1245
  const half = Math.max(1, Math.floor(budget.reenrich / 2));
1091
- const aliasBudget = Math.min(half, findReenrichCandidates(db, half, { scope: 'aliases', project }).length);
1246
+ const aliasBudget = Math.min(
1247
+ half,
1248
+ findReenrichCandidates(db, half, { scope: 'aliases', project }).length,
1249
+ );
1092
1250
  const scopesBudget = Math.min(
1093
1251
  budget.reenrich,
1094
1252
  findReenrichCandidates(db, budget.reenrich, { scope: 'scopes', project }).length,
1095
1253
  );
1096
- const mainRes = await executeReenrich(db, budget.reenrich - aliasBudget, { scope: reenrichScope, project });
1097
- const aliasRes = aliasBudget > 0
1098
- ? await executeReenrich(db, aliasBudget, { scope: 'aliases', project })
1099
- : { processed: 0, skipped: 0 };
1100
- const scopesRes = scopesBudget > 0
1101
- ? await executeReenrich(db, scopesBudget, { scope: 'scopes', project })
1102
- : { processed: 0, skipped: 0 };
1254
+ const mainRes = await executeReenrich(db, budget.reenrich - aliasBudget, {
1255
+ scope: reenrichScope,
1256
+ project,
1257
+ });
1258
+ const aliasRes =
1259
+ aliasBudget > 0
1260
+ ? await executeReenrich(db, aliasBudget, { scope: 'aliases', project })
1261
+ : { processed: 0, skipped: 0 };
1262
+ const scopesRes =
1263
+ scopesBudget > 0
1264
+ ? await executeReenrich(db, scopesBudget, { scope: 'scopes', project })
1265
+ : { processed: 0, skipped: 0 };
1103
1266
  results.reenrich = {
1104
1267
  processed: (mainRes.processed || 0) + (aliasRes.processed || 0) + (scopesRes.processed || 0),
1105
1268
  skipped: (mainRes.skipped || 0) + (aliasRes.skipped || 0) + (scopesRes.skipped || 0),