claude-mem-lite 3.35.1 → 3.36.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.
@@ -8,7 +8,7 @@
8
8
  // This writes to a pid-unique temp then renames (atomic on POSIX), and can drop
9
9
  // a one-time ".bak" so a logic bug in the caller's merge is recoverable.
10
10
 
11
- import { writeFileSync, renameSync, existsSync, copyFileSync, mkdirSync } from 'node:fs';
11
+ import { writeFileSync, renameSync, existsSync, copyFileSync, mkdirSync, lstatSync, realpathSync } from 'node:fs';
12
12
  import { dirname } from 'node:path';
13
13
 
14
14
  /**
@@ -22,17 +22,28 @@ import { dirname } from 'node:path';
22
22
  * preserves the last-known-good rather than being overwritten each run.
23
23
  */
24
24
  export function atomicWriteFileSync(filePath, data, { backup = false } = {}) {
25
- const dir = dirname(filePath);
25
+ // Write THROUGH a symlink to its real target. renameSync onto a symlink NAME replaces the
26
+ // link with a regular file, silently orphaning a dotfiles-managed (chezmoi/stow/yadm)
27
+ // config: ~/.claude/settings.json (and ~/.claude.json) get severed from the dotfiles
28
+ // repo, so future dotfiles edits stop applying and .bak captures the wrong content.
29
+ // Resolve first, then put the temp in the TARGET's dir so the rename stays same-device
30
+ // (atomic, no EXDEV). A broken/absent symlink falls through to a direct write.
31
+ let target = filePath;
32
+ try {
33
+ if (lstatSync(filePath).isSymbolicLink()) target = realpathSync(filePath);
34
+ } catch { /* not a symlink, or missing — write filePath directly */ }
35
+
36
+ const dir = dirname(target);
26
37
  if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
27
38
 
28
- if (backup && existsSync(filePath) && !existsSync(filePath + '.bak')) {
29
- try { copyFileSync(filePath, filePath + '.bak'); } catch { /* best-effort backup */ }
39
+ if (backup && existsSync(target) && !existsSync(target + '.bak')) {
40
+ try { copyFileSync(target, target + '.bak'); } catch { /* best-effort backup */ }
30
41
  }
31
42
 
32
43
  // pid-unique temp: a fixed ".tmp" name lets two concurrent installs clobber
33
- // each other's temp mid-write. Same-dir temp keeps the rename atomic (no
34
- // cross-device move).
35
- const tmp = `${filePath}.tmp-${process.pid}`;
44
+ // each other's temp mid-write. Same-dir-as-target temp keeps the rename atomic (no
45
+ // cross-device move) even when the target lives in a dotfiles repo on another mount.
46
+ const tmp = `${target}.tmp-${process.pid}`;
36
47
  writeFileSync(tmp, data);
37
- renameSync(tmp, filePath);
48
+ renameSync(tmp, target);
38
49
  }
@@ -119,7 +119,9 @@ export function resolveDeferredIds(db, project, tokens) {
119
119
  throw new Error(`D#${id} belongs to project "${row.project}", not "${project}"`);
120
120
  }
121
121
  if (row.status !== 'open') {
122
- throw new Error(`D#${id} status is "${row.status}", cannot close (only 'open' items)`);
122
+ // Verb-neutral: resolveDeferredIds is shared by close (save --closes-deferred)
123
+ // AND drop (mem_defer_drop), so "cannot close" mis-described the drop path.
124
+ throw new Error(`D#${id} status is "${row.status}" — only 'open' items can be closed or dropped`);
123
125
  }
124
126
  } else {
125
127
  throw new Error(`invalid token type ${typeof t} — expected D#N or integer ordinal`);
@@ -110,7 +110,12 @@ export function runBenchmark(db, { prompts = [], project = 'mem', skipHookLatenc
110
110
  * Returns true iff the simulator would produce a non-empty injection.
111
111
  */
112
112
  const runInjection = (promptText) => {
113
- if (!promptText || promptText.trim().length < 15) return false;
113
+ // Mirror the real hook's min-length gate (hook-memory.mjs searchRelevantMemories):
114
+ // 5-char floor for non-CJK, 2 for CJK. The old flat 15 was 3x stricter and dropped
115
+ // every 5-14-char prompt (and all 2-14-char CJK) the live hook processes, so the
116
+ // simulated injection_rate systematically under-counted "how often memory fires".
117
+ const cjk = /[一-鿿㐀-䶿]/.test(promptText || '');
118
+ if (!promptText || promptText.length < (cjk ? 2 : 5)) return false;
114
119
  const q = sanitizeFtsQuery(promptText);
115
120
  if (!q) return false;
116
121
  try {
@@ -145,6 +145,12 @@ export function cleanupBroken(db, { projectFilter, baseParams, opCap = OP_CAP })
145
145
  SELECT id FROM observations
146
146
  WHERE COALESCE(compressed_into, 0) = 0
147
147
  AND (title IS NULL OR title = '') AND (narrative IS NULL OR narrative = '')
148
+ -- A lesson-bearing row is NOT "broken" — it still carries the distilled value,
149
+ -- so empty title+narrative isn't grounds to hard-delete it (a degenerate
150
+ -- cluster-merge can write merged_title='' onto a row that kept a synthesized
151
+ -- lesson). Parity with the "lessons never auto-GC" guards in
152
+ -- decayAndMarkIdle / selectCompressionCandidates / findSmartCompressCandidates.
153
+ AND (lesson_learned IS NULL OR lesson_learned = '' OR lesson_learned = 'none')
148
154
  ${projectFilter} LIMIT ${opCap}
149
155
  `).all(...baseParams).map(r => r.id);
150
156
  if (!doomed.length) return 0;
@@ -329,6 +335,12 @@ export function purgeStalePreview(db, { projectFilter, baseParams }, retainCutof
329
335
 
330
336
  /** Delete pending-purge observations older than the retain cutoff. Returns rows deleted. */
331
337
  export function purgeStale(db, { projectFilter, baseParams, opCap = OP_CAP }, retainCutoff) {
338
+ // No lesson guard HERE by design: this hard-DELETE only touches rows already
339
+ // marked COMPRESSED_PENDING_PURGE, and every writer of that sentinel is itself
340
+ // lesson-guarded (decayAndMarkIdle above + search-scoring.runIdleCleanup), so a
341
+ // lesson row can never reach here. INVARIANT: any NEW code that sets
342
+ // compressed_into = COMPRESSED_PENDING_PURGE MUST carry the "lessons never auto-GC"
343
+ // guard, or it re-opens the path that hard-deletes lessons through this DELETE.
332
344
  const doomed = db.prepare(`
333
345
  SELECT id FROM observations
334
346
  WHERE compressed_into = ${COMPRESSED_PENDING_PURGE} AND created_at_epoch < ?
@@ -3,6 +3,8 @@
3
3
  // measurement that gates Phase 2): ONE tested source of truth so the measured
4
4
  // framing and the shipped framing cannot drift. Hot-path-shared → regex/string
5
5
  // only, NO heavy imports (lesson #8447), mirroring lib/lesson-idents.mjs.
6
+ // format-utils.mjs is a zero-import pure-string module, so it respects that constraint.
7
+ import { neutralizeContextDelimiters } from '../format-utils.mjs';
6
8
  //
7
9
  // Delivers a high-value lesson at the task-prompt position as an imperative,
8
10
  // task-bound constraint: attribution kept (honest + #NN cite-traceable), the
@@ -11,7 +13,7 @@
11
13
  // Spec: docs/superpowers/specs/2026-06-29-task-imperative-memory-injection-design.md
12
14
 
13
15
  export function formatTaskImperative(lesson, id) {
14
- const body = String(lesson || '').trim().replace(/\.$/, '');
16
+ const body = neutralizeContextDelimiters(String(lesson || '').trim().replace(/\.$/, ''));
15
17
  if (!body) return '';
16
18
  const tag = (id === undefined || id === null || id === '') ? '' : ` (#${id})`;
17
19
  return `Memory — a past lesson applies to THIS task. You must: ${body}.${tag}`;
@@ -27,7 +29,7 @@ export function formatTaskImperative(lesson, id) {
27
29
  // / "reference, not an instruction" / appended-below-the-task / no adversarial
28
30
  // tokens) are the measured difference between adopt and refuse — do not drift them.
29
31
  export function formatSubagentContext(lesson, id) {
30
- const body = String(lesson || '').trim().replace(/\.$/, '');
32
+ const body = neutralizeContextDelimiters(String(lesson || '').trim().replace(/\.$/, ''));
31
33
  if (!body) return '';
32
34
  const tag = (id === undefined || id === null || id === '') ? '' : `#${id} — `;
33
35
  return [
package/mem-cli.mjs CHANGED
@@ -1621,7 +1621,17 @@ function cmdExport(db, args) {
1621
1621
  process.stderr.write(`[mem] Note: --from "${flags.from}" is after --to "${flags.to}"; this range is empty\n`);
1622
1622
  }
1623
1623
 
1624
- const limit = parseIntFlag(flags.limit, { name: '--limit', defaultValue: 200, max: 1000 });
1624
+ // Backup default: with no --limit, export the COMPLETE matching set. `export` is
1625
+ // the documented backup half of backup/restore (README; cmdRestore header), yet its
1626
+ // old default capped at 200 (hard max 1000) and the "capped" warning went only to
1627
+ // stderr — so a bare `export > backup.json` on a >200-row store silently wrote a
1628
+ // truncated backup that lost rows on restore, and `--limit 5000` was REJECTED back
1629
+ // to 200 (can't back up >1000 at all). Now: omit --limit → LIMIT -1 (SQLite = no
1630
+ // limit); pass --limit N → honor any positive N (a backup may exceed 1000).
1631
+ const limitGiven = flags.limit !== undefined && flags.limit !== null && flags.limit !== '';
1632
+ const limit = limitGiven
1633
+ ? parseIntFlag(flags.limit, { name: '--limit', defaultValue: 200 })
1634
+ : -1;
1625
1635
  const format = flags.format || 'json';
1626
1636
  if (!['json', 'jsonl'].includes(format)) {
1627
1637
  fail(`[mem] Invalid format "${format}". Use: json or jsonl`);
@@ -1662,8 +1672,8 @@ function cmdExport(db, args) {
1662
1672
  out(JSON.stringify(rows, null, 2));
1663
1673
  }
1664
1674
 
1665
- if (rows.length >= limit) {
1666
- process.stderr.write(`[mem] Note: Results capped at ${limit}. Use --from/--to or --limit to export more.\n`);
1675
+ if (limitGiven && rows.length >= limit) {
1676
+ process.stderr.write(`[mem] Note: Results capped at ${limit}. Raise --limit or narrow --from/--to to export more.\n`);
1667
1677
  }
1668
1678
  }
1669
1679
 
@@ -2575,15 +2585,17 @@ Commands:
2575
2585
  --narrative T New narrative
2576
2586
  --concepts T Space-separated concept tags
2577
2587
 
2578
- export Export observations as JSON/JSONL
2579
- restore <file> Restore observations from an export file (JSON/JSONL); --dry-run to preview
2588
+ export Export observations as JSON/JSONL (complete backup by default)
2580
2589
  --project P Filter by project
2581
2590
  --type T Filter by type
2582
2591
  --format F json (default) or jsonl
2583
2592
  --from DATE Start date
2584
2593
  --to DATE End date
2585
2594
  --include-compressed Include compressed observations
2586
- --limit N Max results (default 200, max 1000)
2595
+ --limit N Cap output at N rows (default: export ALL matching rows)
2596
+ restore <file> Restore observations from an export file (JSON/JSONL)
2597
+ --project P Override the restored project for every row
2598
+ --dry-run Preview what would be restored without writing
2587
2599
 
2588
2600
  compress Compress old low-value observations
2589
2601
  --execute Execute compression (preview by default)
@@ -2876,6 +2888,18 @@ async function cmdEnrich(argv) {
2876
2888
  }
2877
2889
 
2878
2890
  async function cmdOptimize(db, args) {
2891
+ // cmdOptimize parses flags positionally (args.indexOf('--task') + args[idx+1])
2892
+ // instead of the shared parseArgs, so the GNU `--flag=value` form silently
2893
+ // vanished: indexOf found no bare `--flag`, dropping the value with zero signal.
2894
+ // On the mutating --run path this was a real footgun — `optimize --run
2895
+ // --task=smart-compress --project=p --max=5` ran ALL tasks across ALL projects at
2896
+ // the default budget (tasks/project undefined → run-everything). Normalize
2897
+ // `--flag=value` into `--flag value` up front so both forms parse identically;
2898
+ // the `--execute=` special-case below already anticipated this for one flag.
2899
+ args = args.flatMap(a => {
2900
+ const m = /^(--[a-z][a-z-]*)=([\s\S]*)$/.exec(a);
2901
+ return m ? [m[1], m[2]] : [a];
2902
+ });
2879
2903
  const run = args.includes('--run');
2880
2904
  const runAll = args.includes('--run-all');
2881
2905
  // Sibling-command flag footgun: optimize executes with --run (compress uses
@@ -3045,11 +3069,15 @@ export async function run(argv) {
3045
3069
  const JSON_SUPPORTED_CMDS = new Set([
3046
3070
  'search', 'context', 'recent', 'recall', 'timeline', 'stats', 'browse', 'export', 'citation-stats',
3047
3071
  ]);
3048
- // `doctor --benchmark` already emits JSON on its own don't print the misleading
3049
- // "doctor outputs text" note for that subpath. Without --benchmark, doctor is text
3050
- // and the note is still useful.
3051
- const doctorBenchmark = cmd === 'doctor' && cmdArgs.includes('--benchmark');
3052
- if (cmdArgs.includes('--json') && !JSON_SUPPORTED_CMDS.has(cmd) && !doctorBenchmark) {
3072
+ // Suppress the note on subpaths that DO emit JSON, so it never FALSELY tells a script
3073
+ // "outputs text" while writing valid JSON to stdout a false note is worse than a
3074
+ // missing one (it makes the consumer skip a parse that would have succeeded). doctor
3075
+ // emits JSON for --benchmark / --metrics / --session-audit; activity's show/save emit
3076
+ // JSON. Without those sub-flags, doctor is text and the note stays useful.
3077
+ const jsonCapableSubpath =
3078
+ (cmd === 'doctor' && (cmdArgs.includes('--benchmark') || cmdArgs.includes('--metrics') || cmdArgs.includes('--session-audit'))) ||
3079
+ cmd === 'activity';
3080
+ if (cmdArgs.includes('--json') && !JSON_SUPPORTED_CMDS.has(cmd) && !jsonCapableSubpath) {
3053
3081
  process.stderr.write(`[mem] Note: --json is supported only on: ${[...JSON_SUPPORTED_CMDS].join(', ')}. "${cmd}" outputs text.\n`);
3054
3082
  }
3055
3083
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "claude-mem-lite",
3
- "version": "3.35.1",
3
+ "version": "3.36.0",
4
4
  "description": "Persistent long-term memory for Claude Code via MCP — captures coding decisions, bugfixes, and context across sessions. Hybrid FTS5 + TF-IDF search with episode batching. Single SQLite DB, no external services. A lighter, lower-cost alternative to claude-mem (episode batching + a smaller model; cost savings are an internal estimate, not a measured benchmark).",
5
5
  "type": "module",
6
6
  "packageManager": "npm@10.9.2",
@@ -102,7 +102,6 @@
102
102
  "registry.mjs",
103
103
  "registry-retriever.mjs",
104
104
  "registry-recommend.mjs",
105
- "registry-indexer.mjs",
106
105
  "registry-scanner.mjs",
107
106
  "registry-github.mjs",
108
107
  "registry-importer.mjs",
@@ -18,8 +18,12 @@ export function getRecommendMode() {
18
18
 
19
19
  // Provisional gate thresholds — calibrated from shadow data before Phase 2 (spec §8).
20
20
  // relevance is raw bm25 (negative; more negative = better). Candidate must clear |floor|.
21
- export const RECO_BM25_FLOOR = -1.5; // require row.relevance <= -1.5
22
- export const RECO_MARGIN = 0.5; // require candidates[1].relevance - candidates[0].relevance >= 0.5
21
+ export const RECO_BM25_FLOOR = -1.5; // floor stays on raw bm25 (absolute topical relevance)
22
+ // Margin is measured in COMPOSITE space (the metric that orders + selects the winner), NOT raw
23
+ // bm25 — see applyGate. composite = bm25*0.4 - priors, so the scale is ~0.4x raw; 0.5 raw ≈ 0.2
24
+ // composite is the principled starting point. Final value comes from `recommend-stats --sweep`
25
+ // after dogfood (D#47); DEFAULT_SWEEP_MARGINS is likewise on the composite scale.
26
+ export const RECO_MARGIN = 0.2; // require candidates[1].composite_score - candidates[0].composite_score >= this
23
27
  const RECO_COOLDOWN_MS = 300_000; // 5 min, mirrors T4 SKILL_COOLDOWN_MS (internal)
24
28
 
25
29
  // Lazy runtime-path resolution: read CLAUDE_MEM_DIR at call time (mirrors schema.mjs:13
@@ -35,9 +39,14 @@ const TOKEN_SPLIT = /[^a-z0-9一-鿿]+/;
35
39
  export function fetchInstalledSkillCandidates(rdb, promptText, limit = 10) {
36
40
  if (!rdb || !promptText) return [];
37
41
  let rows;
38
- try { rows = searchResources(rdb, promptText, { type: 'skill', limit }); }
42
+ // Over-fetch, THEN filter to installed. quality_tier='installed' is a post-SQL JS filter, so a
43
+ // plain LIMIT starves installed skills that rank below other-tier matches: a weak installed
44
+ // match sitting past the top-N is sliced off → the gate sees no candidate → spurious
45
+ // no_candidate BLOCK (the −0.15 installed composite bonus is negligible vs the bm25 spread).
46
+ // A wide headroom lets the composite-best installed skill actually survive to the gate.
47
+ try { rows = searchResources(rdb, promptText, { type: 'skill', limit: Math.max(limit * 5, 50) }); }
39
48
  catch { return []; }
40
- return rows.filter(r => r.quality_tier === 'installed');
49
+ return rows.filter(r => r.quality_tier === 'installed').slice(0, limit);
41
50
  }
42
51
 
43
52
  /**
@@ -65,7 +74,13 @@ export function applyGate(candidates, promptText, cooldownSet) {
65
74
  const top = candidates[0];
66
75
  if (!(top.relevance <= RECO_BM25_FLOOR)) return { verdict: 'BLOCK', reason: 'below_floor', candidate: top };
67
76
  if (candidates.length >= 2) {
68
- const margin = candidates[1].relevance - top.relevance; // positive when top is clearly better
77
+ // Separation measured in the SAME metric that ordered the candidates (composite_score),
78
+ // NOT relevance. candidates arrive composite ASC so `top` IS the composite winner; a raw
79
+ // relevance margin let a composite-promoted top sit BELOW candidates[1] in relevance →
80
+ // negative margin → spurious low_margin BLOCK (and a permanent dead-zone in the ROC sweep).
81
+ // composite2 - composite1 is >= 0 by sort order. The floor above stays on raw bm25 — the
82
+ // absolute-relevance gate that stops a popular-but-irrelevant composite promotion.
83
+ const margin = candidates[1].composite_score - top.composite_score;
69
84
  if (!(margin >= RECO_MARGIN)) return { verdict: 'BLOCK', reason: 'low_margin', candidate: top };
70
85
  }
71
86
  if (!intentMatch(promptText, top)) return { verdict: 'BLOCK', reason: 'intent_mismatch', candidate: top };
@@ -192,11 +207,13 @@ export function computeFunnel(days = 7) {
192
207
  return stats;
193
208
  }
194
209
 
195
- // Default sweep grid (B3). Raw BM25 magnitudes for real matches run ≈ -10..-15, so the
196
- // shipped floor (-1.5) is far more permissive than it looks; the grid spans that real range
197
- // so the ROC curve actually moves. Override via `recommend-stats --sweep --floors a,b --margins x,y`.
210
+ // Default sweep grid (B3). FLOORS are raw bm25 (real matches run ≈ -10..-15, so the shipped
211
+ // -1.5 floor is far more permissive than it looks; the grid spans that real range). MARGINS are
212
+ // on the COMPOSITE scale (composite = bm25*0.4 - priors), matching replayGate's margin metric
213
+ // the old raw-bm25 grid [0,0.5,1,2,4] was ~2.5x too wide for composite separations.
214
+ // Override via `recommend-stats --sweep --floors a,b --margins x,y`.
198
215
  export const DEFAULT_SWEEP_FLOORS = [-1.5, -5, -8, -10, -12];
199
- export const DEFAULT_SWEEP_MARGINS = [0, 0.5, 1, 2, 4];
216
+ export const DEFAULT_SWEEP_MARGINS = [0, 0.05, 0.1, 0.2, 0.4];
200
217
 
201
218
  /**
202
219
  * Recompute the gate verdict for a logged reco row at a hypothetical (floor, margin),
@@ -207,7 +224,15 @@ export function replayGate(row, floor, margin) {
207
224
  const r1 = row.relevance;
208
225
  // null/undefined relevance coerces to 0/NaN, so `r1 <= floor` is false → BLOCK (no null check).
209
226
  if (!(r1 <= floor)) return 'BLOCK';
210
- if (Number.isFinite(row.rel2) && !((row.rel2 - r1) >= margin)) return 'BLOCK';
227
+ // Margin on composite when the row logged it (post-switch rows), matching the live applyGate
228
+ // metric; fall back to the legacy relevance margin for rows written before composite1/composite2
229
+ // existed (they age out of the 7d sweep window). Single-candidate rows (no rel2/composite2) skip
230
+ // the margin gate either way. NOTE: sweep `margin` is on the COMPOSITE scale for new rows.
231
+ if (Number.isFinite(row.composite1) && Number.isFinite(row.composite2)) {
232
+ if (!((row.composite2 - row.composite1) >= margin)) return 'BLOCK';
233
+ } else if (Number.isFinite(row.rel2)) {
234
+ if (!((row.rel2 - r1) >= margin)) return 'BLOCK';
235
+ }
211
236
  if (!row.intentTop) return 'BLOCK';
212
237
  if (row.cooldownTop) return 'BLOCK';
213
238
  return 'PASS';
@@ -230,11 +255,25 @@ export function computeSweep(days = 7, floors = DEFAULT_SWEEP_FLOORS, margins =
230
255
  }
231
256
  const grid = [];
232
257
  for (const floor of floors) for (const margin of margins) {
233
- let pass = 0, matchPass = 0, matchAdopt = 0;
258
+ let pass = 0;
259
+ // Matched precision MUST dedup (session, skill) exactly like computeFunnel (mPass/mAdopt
260
+ // via per-session Sets). Counting raw reco rows here inflated matchPass by every repeat
261
+ // recommendation of the same skill in one session, so the sweep's precision silently
262
+ // disagreed with the funnel headline and was biased toward frequently-recommended skills —
263
+ // corrupting the exact (floor,margin) signal the sweep exists to inform. `pass` stays a raw
264
+ // volume count (it is not a precision denominator).
265
+ const passBySession = new Map(); // session -> Set<skillLower> that PASSED at this cell
234
266
  for (const r of recos) {
235
267
  if (replayGate(r, floor, margin) !== 'PASS') continue;
236
268
  pass++;
237
- if (r.session) { matchPass++; if (adoptBySession.get(r.session)?.has(lc(r.skill))) matchAdopt++; }
269
+ if (!r.session) continue;
270
+ if (!passBySession.has(r.session)) passBySession.set(r.session, new Set());
271
+ passBySession.get(r.session).add(lc(r.skill));
272
+ }
273
+ let matchPass = 0, matchAdopt = 0;
274
+ for (const [session, skills] of passBySession) {
275
+ const adopted = adoptBySession.get(session);
276
+ for (const sk of skills) { matchPass++; if (adopted?.has(sk)) matchAdopt++; }
238
277
  }
239
278
  grid.push({ floor, margin, pass, matchPass, matchAdopt, precision: matchPass ? matchAdopt / matchPass : null });
240
279
  }
@@ -265,8 +304,10 @@ export function recommendSkill(rdb, promptText, project, opts = {}) {
265
304
  // NOT the registry short name ('superpowers-tdd') — adoption rows log toolInput.skill (the slug),
266
305
  // so logging top.name made in-session matched precision a guaranteed 0 for every namespaced skill.
267
306
  skill: top ? (top.invocation_name || top.name) : null,
268
- relevance: top ? top.relevance : null,
269
- rel2: candidates[1] ? candidates[1].relevance : null,
307
+ relevance: top ? top.relevance : null, // floor replay (absolute bm25)
308
+ rel2: candidates[1] ? candidates[1].relevance : null, // legacy margin replay (pre-composite rows)
309
+ composite1: top ? top.composite_score : null, // margin replay (composite — the live gate metric)
310
+ composite2: candidates[1] ? candidates[1].composite_score : null,
270
311
  intentTop: top ? intentMatch(promptText, top) : false,
271
312
  cooldownTop: top ? cooldownSet.has(String(top.name).toLowerCase()) : false,
272
313
  ncand: candidates.length,
package/registry.mjs CHANGED
@@ -62,8 +62,9 @@ const RESOURCES_SCHEMA = `
62
62
  `;
63
63
 
64
64
  // Canonical FTS5 column order — all consumers must use this order.
65
- // BM25 weights (positional): trigger_patterns(5), keywords(3), capability_summary(3),
65
+ // BM25 weights (positional): trigger_patterns(3), keywords(3), capability_summary(3),
66
66
  // intent_tags(2), use_cases(2), domain_tags(1), tech_stack(1), name(1)
67
+ // — must match the bm25(resources_fts, 3,3,3,2,2,1,1,1) call in COMPOSITE_EXPR.
67
68
  const FTS5_SCHEMA = `
68
69
  CREATE VIRTUAL TABLE IF NOT EXISTS resources_fts USING fts5(
69
70
  trigger_patterns,
@@ -371,12 +372,23 @@ const UPSERT_SQL = `
371
372
  repo_stars=CASE WHEN excluded.repo_stars > 0 THEN excluded.repo_stars ELSE repo_stars END,
372
373
  local_path=excluded.local_path, file_hash=excluded.file_hash,
373
374
  invocation_name=CASE WHEN excluded.invocation_name != '' THEN excluded.invocation_name ELSE invocation_name END,
374
- intent_tags=excluded.intent_tags, domain_tags=excluded.domain_tags,
375
- action_type=excluded.action_type, trigger_patterns=excluded.trigger_patterns,
376
- capability_summary=excluded.capability_summary, input_type=excluded.input_type,
377
- output_type=excluded.output_type, prerequisites=excluded.prerequisites,
378
- keywords=excluded.keywords, tech_stack=excluded.tech_stack,
379
- use_cases=excluded.use_cases, complexity=excluded.complexity,
375
+ -- Preserve-on-empty (mirror repo_stars/invocation_name above): a PARTIAL re-upsert --
376
+ -- e.g. "registry import --name X --capability-summary ...", where mem-cli defaults every
377
+ -- other flag to '' -- must NOT blank the FTS text columns and silently drop the resource
378
+ -- out of search (import is the ONLY registry edit path; there is no update subcommand).
379
+ -- Full upserts are unaffected: every field is non-empty, so the CASE picks excluded.
380
+ intent_tags=CASE WHEN excluded.intent_tags != '' THEN excluded.intent_tags ELSE intent_tags END,
381
+ domain_tags=CASE WHEN excluded.domain_tags != '' THEN excluded.domain_tags ELSE domain_tags END,
382
+ action_type=CASE WHEN excluded.action_type != '' THEN excluded.action_type ELSE action_type END,
383
+ trigger_patterns=CASE WHEN excluded.trigger_patterns != '' THEN excluded.trigger_patterns ELSE trigger_patterns END,
384
+ capability_summary=CASE WHEN excluded.capability_summary != '' THEN excluded.capability_summary ELSE capability_summary END,
385
+ input_type=CASE WHEN excluded.input_type != '' THEN excluded.input_type ELSE input_type END,
386
+ output_type=CASE WHEN excluded.output_type != '' THEN excluded.output_type ELSE output_type END,
387
+ prerequisites=excluded.prerequisites,
388
+ keywords=CASE WHEN excluded.keywords != '' THEN excluded.keywords ELSE keywords END,
389
+ tech_stack=CASE WHEN excluded.tech_stack != '' THEN excluded.tech_stack ELSE tech_stack END,
390
+ use_cases=CASE WHEN excluded.use_cases != '' THEN excluded.use_cases ELSE use_cases END,
391
+ complexity=excluded.complexity,
380
392
  indexed_at=excluded.indexed_at, updated_at=datetime('now')
381
393
  `;
382
394
 
package/schema.mjs CHANGED
@@ -816,7 +816,14 @@ const DEFERRED_CLEANUPS = [
816
816
  }
817
817
  }
818
818
  if (canonical) {
819
- for (const table of ['observations', 'sdk_sessions', 'session_summaries']) {
819
+ // Rename the short project to canonical on EVERY project-scoped table.
820
+ // Originally only the first three were rewritten, so a short-named
821
+ // project's deferred TODOs (deferred_work), activity (events), citation
822
+ // history (citation_log), and /clear-/exit handoffs (session_handoffs)
823
+ // were stranded on the old name — invisible to every project-scoped query
824
+ // after normalization. All seven carry a `project` column (verified).
825
+ for (const table of ['observations', 'sdk_sessions', 'session_summaries',
826
+ 'session_handoffs', 'citation_log', 'events', 'deferred_work']) {
820
827
  db.prepare(`UPDATE ${table} SET project = ? WHERE project = ?`).run(canonical.project, shortName);
821
828
  }
822
829
  }
@@ -22,6 +22,12 @@ if [[ "$tool" == "Read" ]]; then
22
22
  if [[ "$input" =~ \"file_path\"[[:space:]]*:[[:space:]]*\"([^\"]+)\" ]]; then
23
23
  file_path="${BASH_REMATCH[1]}"
24
24
  _dir="${CLAUDE_PROJECT_DIR:-$PWD}"
25
+ # Strip trailing slashes so ${_dir##*/} / ${_dir%/*} match Node's path.basename /
26
+ # path.dirname in inferProject(). Without this, CLAUDE_PROJECT_DIR="/org/proj/"
27
+ # gave bash "proj--" (empty base) while JS gave "org--proj" — a name mismatch that
28
+ # made flushEpisode read a DIFFERENT reads-<project>.txt, silently dropping this
29
+ # session's Read context AND orphaning the bash-named file (nothing ever collects it).
30
+ while [[ "$_dir" == */ && ${#_dir} -gt 1 ]]; do _dir="${_dir%/}"; done
25
31
  _base="${_dir##*/}"
26
32
  _parent="${_dir%/*}"; _parent="${_parent##*/}"
27
33
  if [[ -n "$_parent" && "$_parent" != "." && "$_parent" != "/" ]]; then
@@ -29,8 +35,11 @@ if [[ "$tool" == "Read" ]]; then
29
35
  else
30
36
  project="${_base}"
31
37
  fi
32
- # Sanitize project name to match utils.mjs inferProject()
38
+ # Sanitize + truncate to 100 to match utils.mjs inferProject() EXACTLY
39
+ # (raw.replace(/[^a-zA-Z0-9_.-]/g,'-').slice(0,100)). A >100-char parent--base
40
+ # otherwise diverges from the JS side (same reads-file mismatch as above).
33
41
  project="${project//[^a-zA-Z0-9_.-]/-}"
42
+ project="${project:0:100}"
34
43
  project="${project:-unknown}"
35
44
  # Honor CLAUDE_MEM_DIR relocation (mirrors schema.mjs DB_DIR → hook-shared RUNTIME_DIR).
36
45
  # hook.mjs flushEpisode reads reads-<project>.txt from CLAUDE_MEM_DIR/runtime; if this
package/search-engine.mjs CHANGED
@@ -259,6 +259,7 @@ function expandObsByPRF(db, ctx, now, primaryCount, existingIds, results, includ
259
259
  SELECT o.title, o.narrative FROM observations_fts
260
260
  JOIN observations o ON observations_fts.rowid = o.id
261
261
  WHERE observations_fts MATCH ? AND COALESCE(o.compressed_into, 0) = 0
262
+ AND o.superseded_at IS NULL
262
263
  AND (? IS NULL OR o.project = ?)
263
264
  ORDER BY ${OBS_BM25}
264
265
  LIMIT 8
@@ -304,7 +305,9 @@ function expandObsByPRF(db, ctx, now, primaryCount, existingIds, results, includ
304
305
  * 1. FTS5 MATCH with the sanitized query (AND-by-default), recency-weighted
305
306
  * 2. If AND returns 0 → relaxFtsQueryToOr fallback (mirrors searchObservationsHybrid)
306
307
  *
307
- * Always skips compressed rows.
308
+ * Always skips compressed AND superseded rows — paired with searchObservationsHybrid /
309
+ * buildObsFtsQuery so `timeline --query` / mem_timeline never anchor on a memory that
310
+ * search itself hides (an anchor on a replaced row strands navigation on stale content).
308
311
  *
309
312
  * @param {Database} db
310
313
  * @param {object} opts
@@ -324,6 +327,7 @@ export function findFtsAnchor(db, { ftsQuery, project = null, nowT = null, halfL
324
327
  WHERE observations_fts MATCH ?
325
328
  AND (? IS NULL OR o.project = ?)
326
329
  AND COALESCE(o.compressed_into, 0) = 0
330
+ AND o.superseded_at IS NULL
327
331
  ORDER BY ${OBS_BM25}
328
332
  * (1.0 + EXP(-0.693 * MAX(0, ? - o.created_at_epoch) / ${halfLifeMs}.0))
329
333
  LIMIT 1
@@ -199,6 +199,7 @@ export function expandQueryByConcepts(db, ftsQuery, project) {
199
199
  SELECT o.concepts FROM observations_fts
200
200
  JOIN observations o ON observations_fts.rowid = o.id
201
201
  WHERE observations_fts MATCH ? AND COALESCE(o.compressed_into, 0) = 0
202
+ AND o.superseded_at IS NULL
202
203
  AND (? IS NULL OR o.project = ?)
203
204
  ORDER BY ${OBS_BM25}
204
205
  LIMIT 20
@@ -281,6 +282,11 @@ export function runIdleCleanup(db) {
281
282
  WHERE importance <= 1 AND COALESCE(access_count, 0) = 0
282
283
  AND type IN (${types})
283
284
  AND created_at_epoch < ? AND COALESCE(compressed_into, 0) = 0
285
+ -- Never auto-mark a lesson-bearing row for purge. This idle path is the
286
+ -- MCP-server sibling of maintain-core.decayAndMarkIdle and must carry the
287
+ -- SAME "lessons never auto-GC" guard; without it a lesson demoted to imp≤1
288
+ -- by citation-decay gets pending-purge'd here and hard-deleted by purgeStale.
289
+ AND (lesson_learned IS NULL OR lesson_learned = '' OR lesson_learned = 'none')
284
290
  `).run(cutoff);
285
291
  totalMarked += marked.changes;
286
292
 
@@ -289,6 +295,10 @@ export function runIdleCleanup(db) {
289
295
  WHERE COALESCE(compressed_into, 0) = 0 AND importance = 1
290
296
  AND type IN (${types})
291
297
  AND created_at_epoch < ?
298
+ -- Same lesson guard: auto-compress (-1) hides the row from all retrieval and
299
+ -- recoverBuriedLessons only re-floors live (compressed_into=0) rows, so a
300
+ -- compressed lesson is unrecoverable. Parity with selectCompressionCandidates.
301
+ AND (lesson_learned IS NULL OR lesson_learned = '' OR lesson_learned = 'none')
292
302
  `).run(cutoff);
293
303
  totalCompressed += compressed.changes;
294
304
  }
package/source-files.mjs CHANGED
@@ -13,7 +13,7 @@ export const SOURCE_FILES = [
13
13
  'plugin-cache-guard.mjs',
14
14
  'haiku-client.mjs', 'utils.mjs', 'schema.mjs',
15
15
  'package.json', 'package-lock.json', 'skill.md',
16
- 'registry.mjs', 'registry-scanner.mjs', 'registry-indexer.mjs',
16
+ 'registry.mjs', 'registry-scanner.mjs',
17
17
  'registry-retriever.mjs', 'resource-discovery.mjs',
18
18
  // registry-recommend.mjs: statically imported by hook.mjs (PostToolUse adoption probe)
19
19
  // and scripts/user-prompt-search.js (UserPromptSubmit shadow recommendation).
package/tool-schemas.mjs CHANGED
@@ -92,7 +92,7 @@ export const memSearchSchema = {
92
92
  limit: coerceInt.pipe(z.number().int().min(1).max(100)).optional().describe('Max results (default 20)'),
93
93
  offset: coerceInt.pipe(z.number().int().min(0)).optional().describe('Offset for pagination'),
94
94
  sort: z.enum(['relevance', 'time', 'importance']).optional().describe('Sort order: relevance (default, BM25), time (newest first), importance (highest first)'),
95
- include_noise: z.boolean().optional().describe('Include hook-llm fallback titles ("Modified X", "Worked on X", raw error logs) — hidden by default as they have ~3% access rate'),
95
+ include_noise: coerceBool.optional().describe('Include hook-llm fallback titles ("Modified X", "Worked on X", raw error logs) — hidden by default as they have ~3% access rate'),
96
96
  or: coerceBool.optional().describe('Force OR semantics between query terms from the start (default: AND with automatic OR-fallback when AND returns 0). Aligns with CLI --or.'),
97
97
  deep: coerceBool.optional().describe('Tri-state LLM multi-query/HyDE deep search (observations-only). true=force; false=never; omit=AUTO (default ON for mem_search): a normal search that returns weak/few results auto-escalates with ONE Haiku call (query rewritten to keyword/concept/HyDE variants, RRF-fused). Set CLAUDE_MEM_AUTO_DEEP=0 to disable AUTO. Passive recall stays single-query.'),
98
98
  rerank: coerceBool.optional().describe('Opt-in: LLM-rerank the deep-search candidates for ranking precision (one extra Haiku call, ~1.4s). Requires deep=true (no effect on AUTO/normal). Reserve for hard, ranking-sensitive queries where the right memory is likely retrieved but mis-ranked — skip for routine search. Default off.'),
@@ -249,7 +249,7 @@ export const memExportSchema = {
249
249
  export const memRecallSchema = {
250
250
  file: z.string().min(1).describe('File path or filename to recall observations for'),
251
251
  limit: coerceInt.pipe(z.number().int().min(1).max(50)).optional().describe('Max results (default 10)'),
252
- include_noise: z.boolean().optional().describe('Include hook-llm fallback titles ("Modified X", "Worked on X", raw error logs) — hidden by default for parity with mem_search'),
252
+ include_noise: coerceBool.optional().describe('Include hook-llm fallback titles ("Modified X", "Worked on X", raw error logs) — hidden by default for parity with mem_search'),
253
253
  };
254
254
 
255
255
  export const memFtsCheckSchema = {