claude-mem-lite 3.35.2 → 3.37.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.
package/hook.mjs CHANGED
@@ -118,12 +118,19 @@ for (const sig of ['SIGTERM', 'SIGINT']) {
118
118
  try {
119
119
  const ep = readEpisodeRaw();
120
120
  if (ep && ep.entries && ep.entries.length > 0) {
121
- // Persist a rule-based observation synchronously BEFORE writing the flush
122
- // file that file has no consumer, so this is the only thing that prevents
123
- // the in-flight episode being lost on abnormal termination (audit #6).
124
- saveEpisodeImmediate(ep);
125
- const flushFile = join(RUNTIME_DIR, `ep-flush-${Date.now()}-${randomUUID().slice(0, 8)}.json`);
126
- writeFileSync(flushFile, JSON.stringify(ep));
121
+ // Persist a rule-based observation synchronously the ONLY thing that
122
+ // salvages the in-flight episode on abnormal termination (audit #6). A
123
+ // detached llm-episode child can't be spawned from a dying process, so no
124
+ // ep-flush-* file is written here: it would have NO consumer AND would make
125
+ // every later handleLLMSummary poll the full CLAUDE_MEM_FLUSH_TIMEOUT (~15s)
126
+ // waiting for a file that only the 24h orphan-sweep ever removes.
127
+ // Split by CC session first (v3.35.2 parity): the normal flush and the Stop
128
+ // lock-contended fallback both planEpisodeFlush, but this crash path flushed the
129
+ // WHOLE buffer as one observation, co-attributing two interleaved same-project
130
+ // sessions into one garbled row. planEpisodeFlush returns [ep] by reference when
131
+ // there is ≤1 CC session (the common case → identical to before), else one sub
132
+ // per session. Pure/sync → safe inside the signal handler.
133
+ for (const sub of planEpisodeFlush(ep)) saveEpisodeImmediate(sub);
127
134
  try { unlinkSync(join(RUNTIME_DIR, `ep-${inferProject()}.json`)); } catch {}
128
135
  }
129
136
  } catch {}
@@ -468,16 +475,24 @@ async function handleStop() {
468
475
  if (episode && episode.entries && episode.entries.length > 0 && episodeHasSignificantContent(episode)) {
469
476
  if (!episode.sessionId) episode.sessionId = sessionId;
470
477
  if (!episode.project) episode.project = project;
471
- // Immediate save: persist rule-based observation to DB before spawning background worker.
472
- // Without this, data is lost if the background worker fails.
473
- try {
474
- const obs = buildImmediateObservation(episode);
475
- const id = saveObservation(obs, episode.project, episode.sessionId);
476
- if (id) episode.savedId = id;
477
- } catch (e) { debugCatch(e, 'handleStop-fallback-immediateSave'); }
478
- const flushFile = join(RUNTIME_DIR, `ep-flush-${Date.now()}-${randomUUID().slice(0, 8)}.json`);
479
- writeFileSync(flushFile, JSON.stringify(episode));
480
- spawnBackground('llm-episode', flushFile);
478
+ // Split by CC session before saving parity with flushEpisode's non-contended path
479
+ // (v3.35.2). Without this, the lock-contended fallback re-merged exactly the interleaved
480
+ // concurrent-session buffers v3.35.2 split apart, co-attributing two sessions' work into
481
+ // one garbled observation. planEpisodeFlush returns [episode] by reference for the common
482
+ // single-session case, so this is a no-op there. Immediate-save each group BEFORE its
483
+ // flush-file write (same ordering as flushEpisodeGroup) so a worker crash can't lose it.
484
+ for (const sub of planEpisodeFlush(episode)) {
485
+ if (!sub.sessionId) sub.sessionId = sessionId;
486
+ if (!sub.project) sub.project = project;
487
+ try {
488
+ const obs = buildImmediateObservation(sub);
489
+ const id = saveObservation(obs, sub.project, sub.sessionId);
490
+ if (id) sub.savedId = id;
491
+ } catch (e) { debugCatch(e, 'handleStop-fallback-immediateSave'); }
492
+ const flushFile = join(RUNTIME_DIR, `ep-flush-${Date.now()}-${randomUUID().slice(0, 8)}.json`);
493
+ writeFileSync(flushFile, JSON.stringify(sub));
494
+ spawnBackground('llm-episode', flushFile);
495
+ }
481
496
  }
482
497
  } finally {
483
498
  try { unlinkSync(claimFile); } catch {}
package/install.mjs CHANGED
@@ -1979,30 +1979,52 @@ async function manualUpdate() {
1979
1979
  // latest code even when local install.mjs / hook-update.mjs are themselves
1980
1980
  // buggy on disk.
1981
1981
  async function repair() {
1982
- console.log('\nclaude-mem-lite repair — re-syncing from latest GitHub release\n');
1982
+ console.log('\nclaude-mem-lite repair — re-syncing from the latest SIGNED GitHub release\n');
1983
1983
  const stagingDir = mkdtempSync(join(tmpdir(), 'claude-mem-lite-repair-'));
1984
1984
  try {
1985
- const tarballUrl = 'https://api.github.com/repos/sdsrss/claude-mem-lite/tarball';
1985
+ // Resolve the latest RELEASE (tag) and cryptographically VERIFY it before running any
1986
+ // downloaded code — parity with the auto-update path (hook-update.downloadAndInstall).
1987
+ // The old code fetched `/tarball` (default-branch main HEAD, unreleased WIP) and ran its
1988
+ // install.mjs UNVERIFIED, and this path is auto-triggered by hook-launcher on any
1989
+ // ERR_MODULE_NOT_FOUND — so a drifted install silently self-healed onto main, and a
1990
+ // repo/TLS-MITM compromise achieved RCE, bypassing the Ed25519 signed-release control that
1991
+ // the manual `update` path enforces. Lazy import so a missing/broken hook-update dependency
1992
+ // degrades to the manual fallback (fail-closed) rather than to unverified auto-install.
1993
+ let fetchLatestRelease, verifyReleaseAuthenticity;
1994
+ try {
1995
+ ({ fetchLatestRelease, verifyReleaseAuthenticity } = await import('./hook-update.mjs'));
1996
+ } catch (e) {
1997
+ throw new Error(`cannot load the verified-update path (${e.message}) — refusing to auto-install unverified code`, { cause: e });
1998
+ }
1999
+ const rel = await fetchLatestRelease();
2000
+ if (!rel || !rel.tarballUrl) throw new Error('could not resolve the latest release (network / rate-limit)');
2001
+ // URL allow-list mirrors hook-update.downloadAndInstall — only github.com tarball URLs.
2002
+ if (!/^https:\/\/(?:api\.)?github\.com\/[a-zA-Z0-9./_-]+$/.test(rel.tarballUrl)) {
2003
+ throw new Error(`refusing suspicious tarball URL: ${rel.tarballUrl}`);
2004
+ }
1986
2005
  const tarballPath = join(stagingDir, 'release.tgz');
1987
- log('Downloading latest release tarball...');
1988
- execFileSync('curl', ['-sL', '-f', '-H', 'Accept: application/vnd.github+json', tarballUrl, '-o', tarballPath],
2006
+ log(`Downloading release v${rel.version}...`);
2007
+ execFileSync('curl', ['-sL', '-f', '-H', 'Accept: application/vnd.github+json', rel.tarballUrl, '-o', tarballPath],
1989
2008
  { timeout: 60000, stdio: ['ignore', 'pipe', 'inherit'] });
1990
2009
  log('Extracting...');
1991
2010
  execFileSync('tar', ['xzf', tarballPath, '-C', stagingDir, '--strip-components=1'],
1992
2011
  { timeout: 30000, stdio: ['ignore', 'pipe', 'inherit'] });
2012
+ // Verify the Ed25519 signature BEFORE running the downloaded install.mjs. Fail-closed:
2013
+ // any tampering / missing-signature / fetch-failure aborts to the manual fallback.
2014
+ log('Verifying release signature...');
2015
+ const authentic = await verifyReleaseAuthenticity(stagingDir, rel.assets);
2016
+ if (!authentic.ok) throw new Error(`release signature check failed (${authentic.action})`);
1993
2017
  const tarballInstaller = join(stagingDir, 'install.mjs');
1994
- if (!existsSync(tarballInstaller)) {
1995
- fail('Tarball missing install.mjs repair aborted');
1996
- process.exit(1);
1997
- }
1998
- log('Re-running install from freshly-downloaded sources...');
2018
+ if (!existsSync(tarballInstaller)) throw new Error('verified tarball missing install.mjs');
2019
+ log('Re-running install from the verified release sources...');
1999
2020
  execFileSync(process.execPath, [tarballInstaller, 'install'],
2000
2021
  { stdio: 'inherit', timeout: 300000 });
2001
- ok('Repair complete — broken install resynced from latest release');
2022
+ ok(`Repair complete — resynced from verified release v${rel.version}`);
2002
2023
  } catch (e) {
2003
2024
  fail(`Repair failed: ${e.message}`);
2004
2025
  console.log('');
2005
- console.log(' Manual fallback run this in any shell:');
2026
+ console.log(' Automatic repair fails closed rather than run unverified code.');
2027
+ console.log(' Manual fallback — run this in any shell (you are choosing to trust it):');
2006
2028
  console.log('');
2007
2029
  console.log(' T=$(mktemp -d) && curl -sL https://api.github.com/repos/sdsrss/claude-mem-lite/tarball | tar xz -C "$T" --strip-components=1 && node "$T/install.mjs" install');
2008
2030
  console.log('');
@@ -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
  }
@@ -525,7 +525,7 @@ export function computeCitationAdoption(db, project) {
525
525
  * decide cited vs uncited and mutate importance/streak/cited_count per spec.
526
526
  *
527
527
  * - cited: importance += 1 (cap 3), cited_count += 1, streak = 0.
528
- * - uncited: streak += 1; if it reaches 3, importance -= 1 (floor 0), streak = 0.
528
+ * - uncited: streak += 1; if it reaches 3, importance -= 1 (floor 1, IMPORTANCE_FLOOR), streak = 0.
529
529
  * - per-(session, obs) idempotent via last_decided_session_id; re-running for
530
530
  * the same session is a no-op (Stop hook may fire more than once).
531
531
  * - cross-project IDs are silently ignored by the WHERE clause.
@@ -568,7 +568,10 @@ export function applyCitationDecay(db, project, injectedIds, citedIds, sessionId
568
568
  const suppressDemotion = adoption.seen >= ADOPTION_MIN_SEEN && adoption.rate < adoptionThreshold;
569
569
 
570
570
  const selectStmt = db.prepare(
571
- 'SELECT id, importance, uncited_streak, last_decided_session_id FROM observations WHERE id = ? AND project = ?'
571
+ // superseded_at IS NULL: mirror computeCitationAdoption + the 4 injection SELECTs so a
572
+ // row superseded mid-session (injected live, then auto-dedup supersedes it before this
573
+ // decay resolves) is not decayed/streaked/mutated — defense-in-depth parity.
574
+ 'SELECT id, importance, uncited_streak, last_decided_session_id FROM observations WHERE id = ? AND project = ? AND superseded_at IS NULL'
572
575
  );
573
576
  // decay_seen_count (v34) bumps on every resolution branch — gives
574
577
  // citation-stats a denominator that's same-source as cited_count, so the
@@ -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
@@ -605,21 +605,17 @@ function cmdTimeline(db, args) {
605
605
  // (nlp.mjs string ops on a boolean). No sensible default for a search anchor — reject
606
606
  // cleanly (#8470). (`--project` bare is absorbed by resolveProject's non-string guard.)
607
607
  if (rejectBareStringFlags(flags, ['query'])) return;
608
- // parseInt('-5') === -5 is truthy, so `|| 5` doesn't rescue negative input.
609
- // Match cmdSearch's warn-then-default pattern for consistency across CLI flags.
610
- const parseWindow = (label, raw) => {
611
- if (raw === undefined) return 5;
612
- const n = parseInt(raw, 10);
613
- // isNumericToken first: "2abc"→2 / "1e2"→1 are non-negative integers the bare check
614
- // accepted silently; reject garbage tokens like the negative path already does.
615
- if (!isNumericToken(raw) || !Number.isInteger(n) || n < 0) {
616
- process.stderr.write(`[mem] Invalid --${label} "${raw}" (must be a non-negative integer); using default 5\n`);
617
- return 5;
618
- }
619
- return n;
620
- };
621
- const before = parseWindow('before', flags.before);
622
- const after = parseWindow('after', flags.after);
608
+ // Route --before/--after through the shared bounded parser, range [0,50] (mirrors MCP
609
+ // mem_timeline's before/after .min(0).max(50)). NOTE this is parseIntFlag's reject-to-default
610
+ // convention, NOT a clamp: an out-of-range value (e.g. `--before 100`) warns to stderr and
611
+ // falls back to the default 5 — same as recent/search/recall, so the behavior is consistent
612
+ // across the CLI (the user sees the valid range and can retry). The point is the UPPER bound:
613
+ // the old hand-rolled validator had none, so `--before 999999999` flowed straight into
614
+ // `LIMIT before+after+1` / the window fetch as a raw SQL LIMIT (whole-table dump) — the
615
+ // #8802 uncapped-LIMIT footgun. min:0 keeps a 0 window legal; parseIntFlag preserves the
616
+ // warn-then-default garbage handling (float truncation + "2abc"/"1e2" rejection).
617
+ const before = parseIntFlag(flags.before, { name: '--before', defaultValue: 5, min: 0, max: 50 });
618
+ const after = parseIntFlag(flags.after, { name: '--after', defaultValue: 5, min: 0, max: 50 });
623
619
  const project = flags.project ? resolveProject(db, flags.project) : null;
624
620
  const jsonOutput = flags.json === true || flags.json === 'true';
625
621
 
@@ -1621,7 +1617,17 @@ function cmdExport(db, args) {
1621
1617
  process.stderr.write(`[mem] Note: --from "${flags.from}" is after --to "${flags.to}"; this range is empty\n`);
1622
1618
  }
1623
1619
 
1624
- const limit = parseIntFlag(flags.limit, { name: '--limit', defaultValue: 200, max: 1000 });
1620
+ // Backup default: with no --limit, export the COMPLETE matching set. `export` is
1621
+ // the documented backup half of backup/restore (README; cmdRestore header), yet its
1622
+ // old default capped at 200 (hard max 1000) and the "capped" warning went only to
1623
+ // stderr — so a bare `export > backup.json` on a >200-row store silently wrote a
1624
+ // truncated backup that lost rows on restore, and `--limit 5000` was REJECTED back
1625
+ // to 200 (can't back up >1000 at all). Now: omit --limit → LIMIT -1 (SQLite = no
1626
+ // limit); pass --limit N → honor any positive N (a backup may exceed 1000).
1627
+ const limitGiven = flags.limit !== undefined && flags.limit !== null && flags.limit !== '';
1628
+ const limit = limitGiven
1629
+ ? parseIntFlag(flags.limit, { name: '--limit', defaultValue: 200 })
1630
+ : -1;
1625
1631
  const format = flags.format || 'json';
1626
1632
  if (!['json', 'jsonl'].includes(format)) {
1627
1633
  fail(`[mem] Invalid format "${format}". Use: json or jsonl`);
@@ -1662,8 +1668,8 @@ function cmdExport(db, args) {
1662
1668
  out(JSON.stringify(rows, null, 2));
1663
1669
  }
1664
1670
 
1665
- if (rows.length >= limit) {
1666
- process.stderr.write(`[mem] Note: Results capped at ${limit}. Use --from/--to or --limit to export more.\n`);
1671
+ if (limitGiven && rows.length >= limit) {
1672
+ process.stderr.write(`[mem] Note: Results capped at ${limit}. Raise --limit or narrow --from/--to to export more.\n`);
1667
1673
  }
1668
1674
  }
1669
1675
 
@@ -1785,8 +1791,13 @@ function cmdCompress(db, args) {
1785
1791
  // isNumericToken (not bare parseInt) so "1e5"→1 and "30x"→30 are rejected rather than
1786
1792
  // silently mis-parsed into a far-too-broad cutoff — parity with recent/search/maintain.
1787
1793
  const parsed = Number(flags['age-days']);
1788
- if (!isNumericToken(flags['age-days']) || !Number.isInteger(parsed) || parsed < 1) {
1789
- fail(`[mem] Invalid --age-days "${flags['age-days']}". Must be a positive integer.`);
1794
+ // [30,365] floor/ceil = parity with mem_compress (tool-schemas memCompressSchema
1795
+ // .min(30).max(365)). The CLI previously accepted any positive int, so `--age-days 1`
1796
+ // compressed day-old rows while the MCP description claimed the CLI "rejects <30 anyway"
1797
+ // — a false parity claim (the candidate gate is the real data guard, but the age floor
1798
+ // should match the tool the description promises equivalence with).
1799
+ if (!isNumericToken(flags['age-days']) || !Number.isInteger(parsed) || parsed < 30 || parsed > 365) {
1800
+ fail(`[mem] Invalid --age-days "${flags['age-days']}". Must be an integer between 30 and 365 (parity with mem_compress).`);
1790
1801
  return;
1791
1802
  }
1792
1803
  ageDays = parsed;
@@ -2575,15 +2586,17 @@ Commands:
2575
2586
  --narrative T New narrative
2576
2587
  --concepts T Space-separated concept tags
2577
2588
 
2578
- export Export observations as JSON/JSONL
2579
- restore <file> Restore observations from an export file (JSON/JSONL); --dry-run to preview
2589
+ export Export observations as JSON/JSONL (complete backup by default)
2580
2590
  --project P Filter by project
2581
2591
  --type T Filter by type
2582
2592
  --format F json (default) or jsonl
2583
2593
  --from DATE Start date
2584
2594
  --to DATE End date
2585
2595
  --include-compressed Include compressed observations
2586
- --limit N Max results (default 200, max 1000)
2596
+ --limit N Cap output at N rows (default: export ALL matching rows)
2597
+ restore <file> Restore observations from an export file (JSON/JSONL)
2598
+ --project P Override the restored project for every row
2599
+ --dry-run Preview what would be restored without writing
2587
2600
 
2588
2601
  compress Compress old low-value observations
2589
2602
  --execute Execute compression (preview by default)
@@ -2876,6 +2889,18 @@ async function cmdEnrich(argv) {
2876
2889
  }
2877
2890
 
2878
2891
  async function cmdOptimize(db, args) {
2892
+ // cmdOptimize parses flags positionally (args.indexOf('--task') + args[idx+1])
2893
+ // instead of the shared parseArgs, so the GNU `--flag=value` form silently
2894
+ // vanished: indexOf found no bare `--flag`, dropping the value with zero signal.
2895
+ // On the mutating --run path this was a real footgun — `optimize --run
2896
+ // --task=smart-compress --project=p --max=5` ran ALL tasks across ALL projects at
2897
+ // the default budget (tasks/project undefined → run-everything). Normalize
2898
+ // `--flag=value` into `--flag value` up front so both forms parse identically;
2899
+ // the `--execute=` special-case below already anticipated this for one flag.
2900
+ args = args.flatMap(a => {
2901
+ const m = /^(--[a-z][a-z-]*)=([\s\S]*)$/.exec(a);
2902
+ return m ? [m[1], m[2]] : [a];
2903
+ });
2879
2904
  const run = args.includes('--run');
2880
2905
  const runAll = args.includes('--run-all');
2881
2906
  // Sibling-command flag footgun: optimize executes with --run (compress uses
@@ -3045,11 +3070,15 @@ export async function run(argv) {
3045
3070
  const JSON_SUPPORTED_CMDS = new Set([
3046
3071
  'search', 'context', 'recent', 'recall', 'timeline', 'stats', 'browse', 'export', 'citation-stats',
3047
3072
  ]);
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) {
3073
+ // Suppress the note on subpaths that DO emit JSON, so it never FALSELY tells a script
3074
+ // "outputs text" while writing valid JSON to stdout a false note is worse than a
3075
+ // missing one (it makes the consumer skip a parse that would have succeeded). doctor
3076
+ // emits JSON for --benchmark / --metrics / --session-audit; activity's show/save emit
3077
+ // JSON. Without those sub-flags, doctor is text and the note stays useful.
3078
+ const jsonCapableSubpath =
3079
+ (cmd === 'doctor' && (cmdArgs.includes('--benchmark') || cmdArgs.includes('--metrics') || cmdArgs.includes('--session-audit'))) ||
3080
+ cmd === 'activity';
3081
+ if (cmdArgs.includes('--json') && !JSON_SUPPORTED_CMDS.has(cmd) && !jsonCapableSubpath) {
3053
3082
  process.stderr.write(`[mem] Note: --json is supported only on: ${[...JSON_SUPPORTED_CMDS].join(', ')}. "${cmd}" outputs text.\n`);
3054
3083
  }
3055
3084
 
package/nlp.mjs CHANGED
@@ -151,10 +151,14 @@ export function extractCjkLikePatterns(query) {
151
151
  /**
152
152
  * Post-FTS precision filter for CJK queries.
153
153
  *
154
- * Background: FTS5 unicode61 tokenizer splits every CJK character into its
155
- * own token. An application-layer bigram query like "我是" then reduces to
156
- * (我 AND 是) at match time matching any document that happens to contain
157
- * both chars anywhere, which is extremely permissive in Chinese prose.
154
+ * Background: this build's FTS5 unicode61 tokenizer indexes an entire CJK run
155
+ * as ONE token (it does NOT split each CJK character). CJK text is made
156
+ * searchable by the write path, which stores the content plus its space-
157
+ * separated overlapping bigrams; a query is likewise reduced to bigrams. An
158
+ * application-layer bigram query therefore matches via those stored bigrams,
159
+ * and after the AND→OR fallback (relaxFtsQueryToOr) any document sharing even a
160
+ * single query bigram becomes a hit — extremely permissive in Chinese prose,
161
+ * where common bigrams recur across unrelated topics.
158
162
  *
159
163
  * Precision check: given the raw query and a candidate result's full text,
160
164
  * require that at least `threshold` fraction of the query's CJK bigrams
@@ -276,6 +280,15 @@ export function sanitizeFtsQuery(query) {
276
280
  t && !/^-+$/.test(t) && !FTS5_KEYWORDS.has(t.toUpperCase()) && !/^NEAR(\/\d*)?$/i.test(t)
277
281
  // Skip single ASCII-letter tokens — too noisy for FTS5 (CJK single chars handled separately below)
278
282
  && !(t.length === 1 && /^[a-zA-Z]$/.test(t))
283
+ // Drop tokens with NO index-able character — emoji 💥, symbols ★☆✦, pure
284
+ // punctuation. unicode61 strips those at index time, so ftsToken would phrase-quote
285
+ // such a token ("💥") into a REQUIRED AND term that can never match → strict FTS
286
+ // returns 0 (and a lone-emoji query has no OR recovery). Gate on any Unicode LETTER
287
+ // or NUMBER (\p{L}/\p{N}), NOT an ASCII+Han allowlist: unicode61 indexes every
288
+ // script's letters (Cyrillic / Greek / kana / Hangul / Thai / accented Latin …), so
289
+ // an allowlist silently killed search for all non-Latin/non-Han scripts (round-5
290
+ // review catch). Letters are kept; only true symbols/emoji/punctuation are dropped.
291
+ && /[\p{L}\p{N}]/u.test(t)
279
292
  );
280
293
  // Filter stop words (but keep all if filtering would empty the query)
281
294
  const filtered = tokens.filter(t => !FTS_STOP_WORDS.has(t.toLowerCase()));
@@ -303,6 +316,25 @@ export function sanitizeFtsQuery(query) {
303
316
  }
304
317
  continue;
305
318
  }
319
+ // No dictionary word matched. For a PURE-CJK run, pushing the whole
320
+ // unsegmented token creates a required AND term that matches neither the
321
+ // stored full-run token nor its overlapping bigrams: the write path stores
322
+ // content + space-separated bigrams, so a run like "同义词扩展" is indexed
323
+ // only as the longer whole-run token AND as 同义/义词/词扩/扩展 — never as
324
+ // "同义词扩展" itself. The strict AND is thus unsatisfiable (strict FTS = 0);
325
+ // only relaxFtsQueryToOr in the callers salvaged recall. Emit the non-noise
326
+ // bigrams the index actually holds instead. Mixed-script tokens (latin+CJK,
327
+ // e.g. "xyzAbc不存在") stay whole — the latin portion is a literal anchor and
328
+ // bigramming the CJK suffix over-recalls (mirrors the bigram guard below).
329
+ if (!/[A-Za-z0-9]/.test(t)) {
330
+ const fallbackBigrams = cjkBigrams(t)
331
+ .split(' ')
332
+ .filter(bg => bg && !isCjkNoiseBigram(bg));
333
+ if (fallbackBigrams.length > 0) {
334
+ expandedTokens.push(...fallbackBigrams);
335
+ continue;
336
+ }
337
+ }
306
338
  }
307
339
  expandedTokens.push(t);
308
340
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "claude-mem-lite",
3
- "version": "3.35.2",
3
+ "version": "3.37.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",