claude-mem-lite 6.12.1 → 6.12.2

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.
@@ -9,7 +9,7 @@
9
9
  "plugins": [
10
10
  {
11
11
  "name": "claude-mem-lite",
12
- "version": "6.12.1",
12
+ "version": "6.12.2",
13
13
  "source": "./",
14
14
  "homepage": "https://github.com/sdsrss/claude-mem-lite",
15
15
  "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)."
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "claude-mem-lite",
3
- "version": "6.12.1",
3
+ "version": "6.12.2",
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
  "author": {
6
6
  "name": "sdsrss"
package/hook-handoff.mjs CHANGED
@@ -386,18 +386,28 @@ export function buildAndSaveHandoff(db, sessionId, project, type, episodeSnapsho
386
386
  // (session_summaries.key_decisions is a different column, a JSON array from Haiku), and
387
387
  // every existing assertion on it is a substring/regex match on the title, so the added
388
388
  // prefix is not a contract change for them.
389
- const decisions = db
389
+ //
390
+ // The cap applies AFTER the low-signal filter (D#40). This read was `LIMIT 10` → filter →
391
+ // slice(0, 5), which made the SQL limit a reachability bound: six low-signal rows among the
392
+ // newest ten evicted older real decisions the section had room for, and two writers raise
393
+ // importance without reading the title, so such rows do reach this pool. Streaming and
394
+ // stopping at five keeps the JS filter exactly as it was (the SQL NOT LIKE twin is
395
+ // case-insensitive, so swapping it in would also change WHICH titles count as noise).
396
+ const decisions = [];
397
+ for (const d of db
390
398
  .prepare(
391
399
  `
392
400
  SELECT title, type FROM observations
393
401
  WHERE (memory_session_id = ? OR project = ?) AND COALESCE(importance, 1) >= 2
394
402
  AND ${liveObsFilterSql('')} ${obsWindowClause}
395
- ORDER BY created_at_epoch DESC LIMIT 10
403
+ ORDER BY created_at_epoch DESC, id DESC
396
404
  `,
397
405
  )
398
- .all(sessionId, project, ...obsWindowParams)
399
- .filter((d) => d.title && !LOW_SIGNAL_TITLE.test(d.title))
400
- .slice(0, 5);
406
+ .iterate(sessionId, project, ...obsWindowParams)) {
407
+ if (!d.title || LOW_SIGNAL_TITLE.test(d.title)) continue;
408
+ decisions.push(d);
409
+ if (decisions.length === 5) break;
410
+ }
401
411
 
402
412
  // 5b. Next steps — the remaining work a paused note spells out, which is the only
403
413
  // next-step source in this system that a human wrote down on purpose. Deliberately not
package/install.mjs CHANGED
@@ -97,6 +97,7 @@ import { clearNativeBindingBreakage, readNativeBindingBreakage } from './lib/nat
97
97
  import { sweepStaleTestFixtures } from './lib/tmp-fixture-sweep.mjs';
98
98
  import { acquireLock } from './lib/proc-lock.mjs';
99
99
  import { atomicWriteFileSync } from './lib/atomic-write.mjs';
100
+ import { shellWord } from './cli-path.mjs';
100
101
  import { isMemHook, launcherEntryPath } from './lib/hook-prune.mjs';
101
102
 
102
103
  // Re-export for backward compatibility — tests/install-hook-scripts.test.mjs
@@ -272,7 +273,7 @@ export function hookManifestRepairHint(cacheRoot, marketplaceRoot) {
272
273
  const src = join(marketplaceRoot, 'hooks', 'hooks.json');
273
274
  const dst = join(cacheRoot, 'hooks', 'hooks.json');
274
275
  return pluginCacheHookEvents(marketplaceRoot).ok
275
- ? `cp "${src}" "${dst}" && restart Claude Code`
276
+ ? `cp ${shellWord(src)} ${shellWord(dst)} && restart Claude Code`
276
277
  : `no usable marketplace copy to restore from — reinstall the plugin (/plugin uninstall then /plugin install), then restart Claude Code`;
277
278
  }
278
279
 
@@ -723,7 +724,7 @@ function registerMcpServer() {
723
724
  ok('MCP server registered: mem-lite');
724
725
  } catch (e) {
725
726
  fail('MCP registration failed: ' + e.message);
726
- warn('Try manually: claude mcp add -s user -t stdio mem-lite -- node "' + SERVER_PATH + '"');
727
+ warn(`Try manually: claude mcp add -s user -t stdio mem-lite -- node ${shellWord(SERVER_PATH)}`);
727
728
  }
728
729
  }
729
730
  }
@@ -1674,7 +1675,7 @@ async function status() {
1674
1675
  push(
1675
1676
  'warn',
1676
1677
  'cli',
1677
- `CLI: installed at ${join(binDir, 'claude-mem-lite')} but ${binDir} is not on PATH — add it: export PATH="${binDir}:$PATH"`,
1678
+ `CLI: installed at ${join(binDir, 'claude-mem-lite')} but ${binDir} is not on PATH — add it: export PATH=${shellWord(binDir)}:"$PATH"`,
1678
1679
  { available: false, linked: join(binDir, 'claude-mem-lite') },
1679
1680
  );
1680
1681
  } else {
@@ -1754,8 +1755,9 @@ async function doctor() {
1754
1755
  return;
1755
1756
  }
1756
1757
  const last = checks[checks.length - 1];
1757
- // A detail before any check has nothing to attach to — same as today's drop, but the
1758
- // human face would show it, so this is the one line the two faces cannot share.
1758
+ // Unreachable today: the Node-version check at the top of doctor() always records a
1759
+ // check before the first log(). Kept as a guard — a detail with no check to attach to is
1760
+ // dropped under --json, where the human face would have printed it.
1759
1761
  if (!last) return;
1760
1762
  (last.details ??= []).push(msg.trim());
1761
1763
  };
@@ -2004,7 +2006,7 @@ async function doctor() {
2004
2006
  // misbehaving, which is the worst moment to hand out the one entry that cannot
2005
2007
  // survive a missing module. Pre-ship review of v6.7.0 caught this one left behind.
2006
2008
  dwarn(
2007
- `Hook self-heal: a recent hook fire degraded to exit-0${detail} — run \`node "${join(PROJECT_DIR, 'cli.mjs')}" repair\``,
2009
+ `Hook self-heal: a recent hook fire degraded to exit-0${detail} — run \`node ${shellWord(join(PROJECT_DIR, 'cli.mjs'))} repair\``,
2008
2010
  );
2009
2011
  } else {
2010
2012
  ok('Hook self-heal: no recent silent hook breakage');
@@ -2020,7 +2022,7 @@ async function doctor() {
2020
2022
  // should not pay for another round of child spawns to ask it twice.
2021
2023
  if (brokenRoots.length > 0) {
2022
2024
  fail(
2023
- `Native DB binding: unusable in ${brokenRoots.map((b) => b.label).join(', ')} — run \`node "${join(PROJECT_DIR, 'cli.mjs')}" rebuild-binding\` (repairs every broken install, not just this one)`,
2025
+ `Native DB binding: unusable in ${brokenRoots.map((b) => b.label).join(', ')} — run \`node ${shellWord(join(PROJECT_DIR, 'cli.mjs'))} rebuild-binding\` (repairs every broken install, not just this one)`,
2024
2026
  );
2025
2027
  issues++;
2026
2028
  } else if (breakage) {
@@ -2137,7 +2139,7 @@ async function doctor() {
2137
2139
  for (const p of orphanPaths.slice(0, 5)) log(` missing: ${p}`);
2138
2140
  if (orphanPaths.length > 5) log(` ... +${orphanPaths.length - 5} more`);
2139
2141
  log(
2140
- ` Repair: node "${join(PROJECT_DIR, 'install.mjs')}" uninstall # removes the dead hook entries`,
2142
+ ` Repair: node ${shellWord(join(PROJECT_DIR, 'install.mjs'))} uninstall # removes the dead hook entries`,
2141
2143
  );
2142
2144
  issues++;
2143
2145
  } else if (hasHooks) {
@@ -2408,12 +2410,12 @@ async function doctor() {
2408
2410
  // already there.
2409
2411
  const noCodeInstall =
2410
2412
  !shape.managed && !shape.activePluginVersion && !hasAnyManagedCode(INSTALL_DIR, SOURCE_FILES);
2411
- const installRemedy = `node "${join(PROJECT_DIR, 'install.mjs')}" install`;
2413
+ const installRemedy = `node ${shellWord(join(PROJECT_DIR, 'install.mjs'))} install`;
2412
2414
  try {
2413
2415
  const skipDrift = !shape.managed && !!shape.activePluginVersion;
2414
2416
  const { checkDevDrift } = await import('./lib/doctor-drift.mjs');
2415
2417
  const r = skipDrift ? null : checkDevDrift(INSTALL_DIR, SOURCE_FILES);
2416
- const devRemedy = `re-run: node "${join(PROJECT_DIR, 'install.mjs')}" install --dev`;
2418
+ const devRemedy = `re-run: node ${shellWord(join(PROJECT_DIR, 'install.mjs'))} install --dev`;
2417
2419
  const nameList = (files, count) => {
2418
2420
  const suffix = count > files.length ? ` +${count - files.length} more` : '';
2419
2421
  return `${files.join(', ')}${suffix}`;
@@ -2485,7 +2487,7 @@ async function doctor() {
2485
2487
  `a damaged one. Fix: ${installRemedy}`
2486
2488
  : `Managed files: ${r.missingCount} missing (${parts.join('; ')}) — a copy install resolves ` +
2487
2489
  `imports against the install dir, so these throw at hook time. Fix: claude-mem-lite self-update ` +
2488
- `(or: node "${join(INSTALL_DIR, 'cli.mjs')}" repair)`,
2490
+ `(or: node ${shellWord(join(INSTALL_DIR, 'cli.mjs'))} repair)`,
2489
2491
  );
2490
2492
  }
2491
2493
  // Complete copy install: no message — drift is a dev-install concern.
@@ -2513,7 +2515,7 @@ async function doctor() {
2513
2515
  // `noCodeInstall` above: the `repair` route runs from an entry point that is itself absent.
2514
2516
  const scriptRemedy = noCodeInstall
2515
2517
  ? installRemedy
2516
- : `claude-mem-lite self-update (or: node "${join(INSTALL_DIR, 'cli.mjs')}" repair)`;
2518
+ : `claude-mem-lite self-update (or: node ${shellWord(join(INSTALL_DIR, 'cli.mjs'))} repair)`;
2517
2519
  if (skipScripts) {
2518
2520
  ok('Hook scripts: n/a (plugin-only install — hooks run from the plugin cache)');
2519
2521
  } else if (!h.present) {
@@ -10,6 +10,7 @@ import { execSync, spawnSync } from 'node:child_process';
10
10
  import { existsSync, renameSync } from 'node:fs';
11
11
  import { createRequire } from 'node:module';
12
12
  import { join } from 'node:path';
13
+ import { shellWord } from '../cli-path.mjs';
13
14
 
14
15
  // npm >= 12 blocks lifecycle scripts by default, so a plain `npm rebuild` exits 0
15
16
  // WITHOUT compiling — see the rebuild() comment below. Step 1 of the heal chain, and
@@ -57,9 +58,10 @@ export const NATIVE_BINDING_SOURCE_BUILD_CMD = 'npm run --prefix node_modules/be
57
58
  * @returns {string}
58
59
  */
59
60
  export function nativeBindingRepairHint(root) {
60
- // Quoted: INSTALL_DIR / a plugin-cache root can contain spaces, and an unquoted `cd`
61
- // hands the user a command that fails on exactly the machines least able to debug it.
62
- const npmPair = `cd "${root}" && ${NATIVE_BINDING_REBUILD_CMD} && ${NATIVE_BINDING_SOURCE_BUILD_CMD}`;
61
+ // shellWord, not "…": INSTALL_DIR / a plugin-cache root can contain spaces (and, under
62
+ // double quotes, a `$` or backtick still expands), and a `cd` that splits hands the user a
63
+ // command that fails on exactly the machines least able to debug it.
64
+ const npmPair = `cd ${shellWord(root)} && ${NATIVE_BINDING_REBUILD_CMD} && ${NATIVE_BINDING_SOURCE_BUILD_CMD}`;
63
65
  // The pair above heals a platform better-sqlite3 ships NO prebuild for. It cannot heal a
64
66
  // prebuild that is present and will not load — 13 selects `prebuilds/<target>.node` on
65
67
  // existence alone and prefers it over `build/`, so the addon those two commands compile
@@ -68,7 +70,7 @@ export function nativeBindingRepairHint(root) {
68
70
  // runs the first command they are given and stops looking when it reports success.
69
71
  const cli = join(root, 'cli.mjs');
70
72
  if (!existsSync(cli)) return npmPair;
71
- return `node "${cli}" rebuild-binding (or, without the CLI: ${npmPair})`;
73
+ return `node ${shellWord(cli)} rebuild-binding (or, without the CLI: ${npmPair})`;
72
74
  }
73
75
 
74
76
  // Set on a re-exec'd child so one failed heal cannot fork-bomb the CLI.
@@ -32,6 +32,7 @@
32
32
 
33
33
  import { basename, dirname } from 'node:path';
34
34
  import { readSnapshots } from './db-backup.mjs';
35
+ import { shellWord } from '../cli-path.mjs';
35
36
 
36
37
  /** Marker prefix for lib/record-once.mjs. Per project, like the skew one. */
37
38
  export const DB_UNUSABLE_MARKER_PREFIX = '.db-unusable-logged-';
@@ -140,7 +141,7 @@ export function isDbUnusableError(err) {
140
141
  * @returns {{kind: 'restore'|'set-aside'|'unknown', command: string, note: string, snapshotCount: number}}
141
142
  */
142
143
  export function dbUnusableRemedy(dbPath) {
143
- const clear = `rm -f "${dbPath}-wal" "${dbPath}-shm"`;
144
+ const clear = `rm -f ${shellWord(`${dbPath}-wal`)} ${shellWord(`${dbPath}-shm`)}`;
144
145
  const snap = readSnapshots(dbPath);
145
146
  if (!snap.ok) {
146
147
  return {
@@ -155,7 +156,7 @@ export function dbUnusableRemedy(dbPath) {
155
156
  if (snap.snapshots.length === 0) {
156
157
  return {
157
158
  kind: 'set-aside',
158
- command: `${clear} && mv "${dbPath}" "${dbPath}.corrupt"`,
159
+ command: `${clear} && mv ${shellWord(dbPath)} ${shellWord(`${dbPath}.corrupt`)}`,
159
160
  snapshotCount: 0,
160
161
  note:
161
162
  'No backup snapshot exists beside the database. That command sets the broken file ' +
@@ -170,7 +171,7 @@ export function dbUnusableRemedy(dbPath) {
170
171
  .sort((a, b) => b.mtimeMs - a.mtimeMs || (a.path < b.path ? 1 : -1))[0];
171
172
  return {
172
173
  kind: 'restore',
173
- command: `${clear} && cp "${newest.path}" "${dbPath}"`,
174
+ command: `${clear} && cp ${shellWord(newest.path)} ${shellWord(dbPath)}`,
174
175
  snapshotCount: snap.snapshots.length,
175
176
  note:
176
177
  `Restores the newest of ${snap.snapshots.length} backup snapshot(s). Move the broken ` +
@@ -45,18 +45,25 @@ const TOOL_TO_TYPE = {
45
45
  * something the scrubber rewrites (a bearer token in a `Bash` command, a path segment shaped
46
46
  * like a key) never matched itself and re-imported on every run (R12 pre-ship review P3-4).
47
47
  *
48
- * This returns the RAW title and both sites scrub it through `scrubRecord` exactly once.
49
- * The first cut scrubbed HERE, which left the storage side scrubbed twice (`scrubRecord`
50
- * scrubs `title` again) against the preview's once — equal only while `scrubSecrets` is
51
- * idempotent. It is NOT: `Bash: deploy --token ghp_… secret: v` imported three rows for
52
- * three runs of one transcript, and `AccountKey=https://a:b@h` drifts through a second
53
- * arm. `tests/property.test.mjs` claimed to enforce idempotence and was vacuous — its
54
- * generator produced nothing the scrubber touches. One scrub per side removes the
55
- * dependency instead of waiting for the pattern table to be reordered (D#46).
48
+ * One scrub per side, and it happens HERE, so both sites get the same string by
49
+ * construction and neither may wrap this call in another scrub. An earlier cut scrubbed
50
+ * here AND left the storage side's `scrubRecord` scrubbing `title` again — equal only while
51
+ * `scrubSecrets` is idempotent, which it was not until D#52 (`Bash: deploy --token ghp_…
52
+ * secret: v` imported three rows for three runs). The fix then returned the RAW title for
53
+ * both sites to scrub (D#46).
54
+ *
55
+ * Scrub FIRST, cut second (D#47 P3-8). Cutting first handed the scrubber a token the cut
56
+ * had split, too short for its own pattern (`ghp_` needs 30+ characters), and the prefix
57
+ * was stored in plaintext — up to 29 of a GitHub token's 36 secret characters, at 29 of 41
58
+ * cut positions. The whole title goes through the scrubber; the cap counts from the end of
59
+ * the `<tool>: ` prefix.
56
60
  */
61
+ const IMPORTED_TITLE_DETAIL_MAX = 80;
57
62
  function importedObsTitle(toolUse) {
58
63
  const detail = toolUse?.input?.command || toolEditPath(toolUse?.input) || '';
59
- return `${toolUse?.name || 'unknown'}: ${String(detail).slice(0, 80)}`;
64
+ const prefix = `${toolUse?.name || 'unknown'}: `;
65
+ const scrubbed = scrubRecord('observations', { title: `${prefix}${detail}` }).title;
66
+ return scrubbed.slice(0, prefix.length + IMPORTED_TITLE_DETAIL_MAX);
60
67
  }
61
68
 
62
69
  function dedupKey(parts) {
@@ -113,7 +120,9 @@ function importPrompt(db, ev, project, seenPrompts) {
113
120
  if (text.startsWith('<task-notification>')) return false;
114
121
  const sessionId = ev.sessionId || 'imported';
115
122
  const ts = ev.timestamp || new Date().toISOString();
116
- const safe = scrubSecrets(text.slice(0, 10000));
123
+ // Scrub, THEN cap: cutting first handed the scrubber a token the cap had split, too short
124
+ // for its own pattern, and stored the prefix (same order bug as importedObsTitle).
125
+ const safe = scrubSecrets(text).slice(0, 10000);
117
126
  // Dedup key uses the scrubbed text so a re-run computes the same key as the
118
127
  // first run (which persisted the scrubbed text). Keying on raw input would
119
128
  // make idempotency fragile if the scrub policy changes.
@@ -146,14 +155,16 @@ function importToolPair(db, toolUse, toolResult, project) {
146
155
 
147
156
  const toolName = toolUse.name || 'unknown';
148
157
  const type = TOOL_TO_TYPE[toolName] || 'change';
149
- const inputJson =
150
- typeof toolUse.input === 'object'
151
- ? JSON.stringify(toolUse.input).slice(0, 4000)
152
- : String(toolUse.input ?? '').slice(0, 4000);
158
+ // Each part is scrubbed BEFORE its 4000-character cap, for the reason importedObsTitle
159
+ // gives: a cap that splits a token leaves a prefix too short for the scrubber's pattern.
160
+ // The body is therefore kept out of the scrubRecord call below, so it is scrubbed once.
161
+ const inputJson = scrubSecrets(
162
+ typeof toolUse.input === 'object' ? JSON.stringify(toolUse.input) : String(toolUse.input ?? ''),
163
+ ).slice(0, 4000);
153
164
  const resultText =
154
165
  typeof toolResult?.content === 'string'
155
- ? toolResult.content
156
- : JSON.stringify(toolResult?.content ?? '').slice(0, 4000);
166
+ ? scrubSecrets(toolResult.content)
167
+ : scrubSecrets(JSON.stringify(toolResult?.content ?? '')).slice(0, 4000);
157
168
 
158
169
  // D#35: this gated on `input.file_path` alone, and `NotebookEdit` carries
159
170
  // `notebook_path` and never `file_path` — so the branch named NotebookEdit
@@ -184,8 +195,18 @@ function importToolPair(db, toolUse, toolResult, project) {
184
195
  // both: `narrative` as the durable home, `text` as the index copy the ingest paths
185
196
  // write directly.
186
197
  const body = `${inputJson}\n---\n${resultText}`;
187
- const safe = scrubRecord('observations', {
188
- // This string IS the cross-run dedup key — `importedObsTitle` is the single home for
198
+ const safe = {
199
+ ...scrubRecord('observations', {
200
+ subtitle: '',
201
+ concepts: '',
202
+ facts: '',
203
+ lesson_learned: null,
204
+ search_aliases: null,
205
+ }),
206
+ // Outside scrubRecord on purpose: body and title were each already scrubbed, once.
207
+ text: body,
208
+ narrative: body,
209
+ // The title: this string IS the cross-run dedup key — `importedObsTitle` is the single home for
189
210
  // it, because the two sites that must agree byte-for-byte did not. An earlier cut of
190
211
  // this round kept the title on `file_path` alone to avoid the migration — and shipped
191
212
  // `NotebookEdit: ` with an empty label for exactly the rows D#35 had just made
@@ -196,14 +217,7 @@ function importToolPair(db, toolUse, toolResult, project) {
196
217
  // carry the old key, so the next import re-adds each of them ONCE and then
197
218
  // matches forever. Stated in the CHANGELOG rather than absorbed silently.
198
219
  title: importedObsTitle(toolUse),
199
- subtitle: '',
200
- text: body,
201
- narrative: body,
202
- concepts: '',
203
- facts: '',
204
- lesson_learned: null,
205
- search_aliases: null,
206
- });
220
+ };
207
221
 
208
222
  const inserted = db
209
223
  .prepare(
@@ -326,9 +340,9 @@ export async function importJsonl(db, path, { project }) {
326
340
  seenToolUseIds.add(callKey);
327
341
 
328
342
  // Cross-call dedup: synthesize the title the previous run would have written and check
329
- // the seenObs set seeded from the DB. Same raw expression AND the same single scrub as
330
- // the storage site, so the scrubber cannot rewrite one and not the other.
331
- const titlePreview = scrubRecord('observations', { title: importedObsTitle(useEv) }).title;
343
+ // the seenObs set seeded from the DB. The same function as the storage site, scrub and
344
+ // cut included, so the scrubber cannot rewrite one and not the other.
345
+ const titlePreview = importedObsTitle(useEv);
332
346
  const ts = useEv.timestamp || new Date().toISOString();
333
347
  // Match the storage convention from importToolPair (memId-prefixed) so
334
348
  // the seenObs entries seeded from the DB can be matched on a re-run.
@@ -33,6 +33,7 @@
33
33
  import { existsSync, readdirSync, realpathSync, readFileSync } from 'node:fs';
34
34
  import { join } from 'node:path';
35
35
  import { homedir } from 'node:os';
36
+ import { shellWord } from '../cli-path.mjs';
36
37
 
37
38
  import {
38
39
  probeBindingInFreshProcess,
@@ -324,7 +325,7 @@ export function probeRuntimeRoots(roots, deps = {}) {
324
325
  // "none resolvable from a parent directory" and was caught printing that
325
326
  // next to a probe error naming the parent tree it had just loaded from.
326
327
  error: `${error} — and this install owns no node_modules/better-sqlite3, so there is nothing here to rebuild`,
327
- repair: `cd "${root}" && npm install --omit=dev`,
328
+ repair: `cd ${shellWord(root)} && npm install --omit=dev`,
328
329
  };
329
330
  });
330
331
  }
@@ -25,6 +25,7 @@ import { join, dirname } from 'node:path';
25
25
  import { readFileSync, writeFileSync, mkdirSync, renameSync, unlinkSync } from 'node:fs';
26
26
  import { fileURLToPath } from 'node:url';
27
27
  import { isNativeBindingError, flattenBindingError } from './binding-probe.mjs';
28
+ import { shellWord } from '../cli-path.mjs';
28
29
 
29
30
  export const NATIVE_BINDING_HINT_COOLDOWN_MS = 6 * 60 * 60 * 1000; // 6h
30
31
  const MARKER_NAME = 'native-binding-hint-last';
@@ -45,7 +46,7 @@ export const NATIVE_BINDING_BROKEN_MARKER = 'native-binding-broken';
45
46
  // D#207: join(), not `new URL('../cli.mjs', …)` — that form makes knip drop the named
46
47
  // module out of its unused-export report. cli.mjs is a knip entry point so nothing was
47
48
  // lost here, but the rule is enforced for the class rather than per-file.
48
- const CLI_REBUILD_BINDING = `node "${join(dirname(fileURLToPath(import.meta.url)), '..', 'cli.mjs')}" rebuild-binding`;
49
+ const CLI_REBUILD_BINDING = `node ${shellWord(join(dirname(fileURLToPath(import.meta.url)), '..', 'cli.mjs'))} rebuild-binding`;
49
50
 
50
51
  // Stable-ish identity of a fault so DISTINCT failures get DISTINCT cooldown
51
52
  // windows: the same fault → same key (suppressed within the window), a different
@@ -1,12 +1,12 @@
1
1
  {
2
2
  "name": "claude-mem-lite",
3
- "version": "6.12.1",
3
+ "version": "6.12.2",
4
4
  "lockfileVersion": 3,
5
5
  "requires": true,
6
6
  "packages": {
7
7
  "": {
8
8
  "name": "claude-mem-lite",
9
- "version": "6.12.1",
9
+ "version": "6.12.2",
10
10
  "os": [
11
11
  "darwin",
12
12
  "linux",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "claude-mem-lite",
3
- "version": "6.12.1",
3
+ "version": "6.12.2",
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",
@@ -125,7 +125,10 @@ const NB_MANUAL_CMD =
125
125
  // INSTALL_DIR (import.meta.url) so it works on a plugin-only install, where
126
126
  // bare `claude-mem-lite` is not on PATH and ~/.claude-mem-lite/ holds no source.
127
127
  // cli.mjs routes `repair` → install.mjs. (review #3)
128
- const CLI_REPAIR = `node "${join(INSTALL_DIR, 'cli.mjs')}" repair`;
128
+ // Quote a path as ONE shell word for a printed command. A copy of cli-path.mjs's, not an
129
+ // import (node: builtins only here); tests/cli-path-invocation.test.mjs keeps them equal.
130
+ const shellWord = (s) => (/^[\w@%+=:,./-]+$/.test(s) ? s : `'${s.replace(/'/g, `'\\''`)}'`);
131
+ const CLI_REPAIR = `node ${shellWord(join(INSTALL_DIR, 'cli.mjs'))} repair`;
129
132
 
130
133
  // Last-resort recovery string for users whose `cli.mjs repair` path
131
134
  // itself failed (install.mjs missing / repair errored / retry still drifting).
@@ -490,7 +493,7 @@ function healNativeBindingIfBroken() {
490
493
  const installer = join(INSTALL_DIR, 'install.mjs');
491
494
  if (!existsSync(installer)) {
492
495
  process.stderr.write(
493
- `[claude-mem-lite] native DB binding unusable and install.mjs is missing — run: cd "${INSTALL_DIR}" && ${NB_MANUAL_CMD}\n`,
496
+ `[claude-mem-lite] native DB binding unusable and install.mjs is missing — run: cd ${shellWord(INSTALL_DIR)} && ${NB_MANUAL_CMD}\n`,
494
497
  );
495
498
  return;
496
499
  }
@@ -9,6 +9,9 @@ import { homedir } from 'node:os';
9
9
 
10
10
  const __dirname = dirname(fileURLToPath(import.meta.url));
11
11
  const ROOT = process.env.CLAUDE_PLUGIN_ROOT || join(__dirname, '..');
12
+ // Quote a path as ONE shell word for a printed command. A copy of cli-path.mjs's, not an
13
+ // import (node: builtins only here); tests/cli-path-invocation.test.mjs keeps them equal.
14
+ const shellWord = (s) => (/^[\w@%+=:,./-]+$/.test(s) ? s : `'${s.replace(/'/g, `'\\''`)}'`);
12
15
 
13
16
  if (!existsSync(join(ROOT, 'node_modules', 'better-sqlite3'))) {
14
17
  // Platform gate BEFORE npm, not after it. package.json's `os` field is an npm install
@@ -34,7 +37,7 @@ if (!existsSync(join(ROOT, 'node_modules', 'better-sqlite3'))) {
34
37
  `[claude-mem-lite] Nothing was installed, so the MCP server cannot start. See "Platform Support" in the README.\n`,
35
38
  );
36
39
  process.stderr.write(
37
- `[claude-mem-lite] To install anyway: cd "${ROOT}" && npm install --omit=dev --force\n`,
40
+ `[claude-mem-lite] To install anyway: cd ${shellWord(ROOT)} && npm install --omit=dev --force\n`,
38
41
  );
39
42
  process.exit(1);
40
43
  }
@@ -93,7 +96,7 @@ if (!existsSync(join(ROOT, 'node_modules', 'better-sqlite3'))) {
93
96
  process.stderr.write(
94
97
  `[claude-mem-lite] npm printed its own error above — read its "npm error code" line first. Common causes: read-only directory, disk full, network blocked.\n`,
95
98
  );
96
- process.stderr.write(`[claude-mem-lite] Repair: cd "${ROOT}" && npm install --omit=dev\n`);
99
+ process.stderr.write(`[claude-mem-lite] Repair: cd ${shellWord(ROOT)} && npm install --omit=dev\n`);
97
100
  process.exit(1);
98
101
  }
99
102
  }
package/server.mjs CHANGED
@@ -75,7 +75,7 @@ import { buildLessonNudge } from './lib/save-nudge.mjs';
75
75
  import { formatObsFieldValue, obsFieldLabel, formatPendingPurgeLine } from './cli/common.mjs';
76
76
  // The partial-export warning points the caller at the CLI twin, which exports the complete
77
77
  // set by default — the invocation has to be the one that actually works on this install.
78
- import { CLI_INVOKE } from './cli-path.mjs';
78
+ import { CLI_INVOKE, shellWord } from './cli-path.mjs';
79
79
  import { neutralizeContextDelimiters, neutralizeSkillDelimiters, queryLabel } from './format-utils.mjs';
80
80
  import {
81
81
  memSearchSchema,
@@ -214,7 +214,7 @@ try {
214
214
  console.error(`[claude-mem-lite] FATAL: Database cannot be opened: ${err.message}`);
215
215
  if (err.walRecoveryAttempted) {
216
216
  console.error(
217
- `[claude-mem-lite] Try: rm "${DB_PATH}-wal" "${DB_PATH}-shm" or reinstall with: node install.mjs install`,
217
+ `[claude-mem-lite] Try: rm ${shellWord(`${DB_PATH}-wal`)} ${shellWord(`${DB_PATH}-shm`)} or reinstall with: node install.mjs install`,
218
218
  );
219
219
  } else {
220
220
  console.error(