claude-mem-lite 3.10.0 → 3.11.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.
@@ -10,7 +10,7 @@
10
10
  "plugins": [
11
11
  {
12
12
  "name": "claude-mem-lite",
13
- "version": "3.10.0",
13
+ "version": "3.11.0",
14
14
  "source": "./",
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)."
16
16
  }
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "claude-mem-lite",
3
- "version": "3.10.0",
3
+ "version": "3.11.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
  "author": {
6
6
  "name": "sdsrss"
@@ -163,6 +163,12 @@ export async function importJsonl(db, path, { project }) {
163
163
 
164
164
  const pendingToolUse = new Map();
165
165
  let prompts = 0, observations = 0, skipped = 0;
166
+ // Count lines that ARE Claude Code transcript events (user/assistant/tool_result),
167
+ // independent of whether they produced a new row. Lets the caller tell apart a
168
+ // genuine wrong-shape file (export output / garbage → recognized 0) from a valid
169
+ // transcript that was simply already imported (recognized > 0, all deduped) — the
170
+ // "0 imported, N skipped" warning must not cry "wrong shape" at an idempotent re-run.
171
+ let recognized = 0;
166
172
 
167
173
  // Snapshot importToolPair so we can wrap it with a per-run uniqueness
168
174
  // check that hits both in-call and cross-call dedup. (Inline because we
@@ -193,6 +199,8 @@ export async function importJsonl(db, path, { project }) {
193
199
  if (!line.trim()) continue;
194
200
  const ev = parseLine(line);
195
201
  if (!ev) { skipped++; continue; }
202
+ // Transcript-shape signal (incl. embedded + top-level tool_result, #8413).
203
+ if (ev.type === 'user' || ev.type === 'assistant' || ev.type === 'tool_result') recognized++;
196
204
  if (ev.type === 'user') {
197
205
  // Real Claude Code transcripts wrap tool_result inside a user-typed
198
206
  // event's message.content array (alongside the rare text part). The
@@ -256,5 +264,5 @@ export async function importJsonl(db, path, { project }) {
256
264
  tx2();
257
265
  }
258
266
 
259
- return { prompts, observations, skipped, orphans };
267
+ return { prompts, observations, skipped, orphans, recognized };
260
268
  }
@@ -301,7 +301,12 @@ export function maintenanceStats(db, { projectFilter, baseParams, staleAge }) {
301
301
  const stats = db.prepare(`
302
302
  SELECT
303
303
  COUNT(*) as total,
304
+ -- injection_count=0 MUST mirror decayAndMarkIdle's mark-idle guard (#8614):
305
+ -- the scan stat previews what decay will mark idle, and decay protects
306
+ -- injected rows. Omitting it over-counted "stale" by the injected-but-decayed
307
+ -- rows decay never touches (e.g. demote_pinned's output: imp=1 but inj>0).
304
308
  COALESCE(SUM(CASE WHEN COALESCE(importance, 1) = 1 AND COALESCE(access_count, 0) = 0
309
+ AND COALESCE(injection_count, 0) = 0
305
310
  AND created_at_epoch < ? THEN 1 ELSE 0 END), 0) as stale,
306
311
  COALESCE(SUM(CASE WHEN (title IS NULL OR title = '') AND (narrative IS NULL OR narrative = '')
307
312
  THEN 1 ELSE 0 END), 0) as broken,
package/mem-cli.mjs CHANGED
@@ -308,18 +308,26 @@ function cmdRecent(db, args) {
308
308
  const { positional, flags } = parseArgs(args);
309
309
  const rawArg = positional[0];
310
310
  const rawLimit = parseInt(rawArg, 10);
311
+ // Single source of the upper bound for BOTH the positional [N] and the --limit
312
+ // flag (help: "alias for [N] (max 1000)"). Pre-fix the positional path skipped
313
+ // this cap, so `recent 999999` issued an uncapped `LIMIT 999999` full-table dump
314
+ // while `recent --limit 999999` correctly rejected → default — exactly the
315
+ // "none capped --limit dumps the whole set" footgun parseIntFlag was extracted
316
+ // to close (lib/cli-flags.mjs). Keep the literal in one place so the two paths
317
+ // can't drift apart again.
318
+ const RECENT_MAX = 1000;
311
319
  // isNumericToken first: "2abc"→2 / "1e2"→1 are positive integers that the bare check
312
320
  // accepted silently; the positional path must reject garbage like the --limit flag does.
313
- const isValid = rawArg !== undefined && isNumericToken(rawArg) && Number.isInteger(rawLimit) && rawLimit > 0;
321
+ const isValid = rawArg !== undefined && isNumericToken(rawArg) && Number.isInteger(rawLimit) && rawLimit > 0 && rawLimit <= RECENT_MAX;
314
322
  if (rawArg !== undefined && !isValid) {
315
- process.stderr.write(`[mem] Invalid count "${rawArg}" (must be a positive integer); using default 10\n`);
323
+ process.stderr.write(`[mem] Invalid count "${rawArg}" (must be an integer between 1 and ${RECENT_MAX}); using default 10\n`);
316
324
  }
317
325
  // Positional [N] wins for backward-compat; --limit is sibling-parity alias
318
326
  // (search/recall/browse/stats all accept --limit). Pre-2.69 `recent --limit N`
319
327
  // was silently ignored — surprising users extrapolating from siblings.
320
328
  const limit = isValid
321
329
  ? rawLimit
322
- : parseIntFlag(flags.limit, { name: '--limit', defaultValue: 10, max: 1000 });
330
+ : parseIntFlag(flags.limit, { name: '--limit', defaultValue: 10, max: RECENT_MAX });
323
331
  const project = flags.project ? resolveProject(db, flags.project) : inferProject();
324
332
  const jsonOutput = flags.json === true || flags.json === 'true';
325
333
 
@@ -1768,7 +1776,7 @@ function cmdMaintain(db, args) {
1768
1776
  out(`[mem] Maintenance scan:`);
1769
1777
  out(` Total active: ${stats.total}`);
1770
1778
  out(` Near-duplicate pairs: ${duplicates.length}`);
1771
- out(` Stale (>30d, imp=1, no access): ${stats.stale}`);
1779
+ out(` Stale (>30d, imp=1, no access, never injected): ${stats.stale}`);
1772
1780
  out(` Broken (no title/narrative): ${stats.broken}`);
1773
1781
  out(` Boostable (accessed>3, imp<3): ${stats.boostable}`);
1774
1782
  out(` Pinned-but-uncited (inj>=${PINNED_INJ_THRESHOLD}, cited=0, imp>1): ${stats.pinned} — run: maintain execute --ops demote_pinned`);
@@ -2585,7 +2593,7 @@ async function cmdImportJsonl(db, argv) {
2585
2593
  if (files.length === 0) { out('[mem] No .jsonl files found.'); return; }
2586
2594
 
2587
2595
  const { importJsonl } = await import('./lib/import-jsonl.mjs');
2588
- let totalPrompts = 0, totalObs = 0, totalSkip = 0, totalOrphans = 0, errorCount = 0;
2596
+ let totalPrompts = 0, totalObs = 0, totalSkip = 0, totalOrphans = 0, totalRecognized = 0, errorCount = 0;
2589
2597
  for (const f of files) {
2590
2598
  // Per-file isolation: one unreadable file (EACCES, EBUSY, mid-batch IO error)
2591
2599
  // shouldn't crash the whole import — readFileSync inside importJsonl would
@@ -2605,18 +2613,29 @@ async function cmdImportJsonl(db, argv) {
2605
2613
  totalObs += r.observations;
2606
2614
  totalSkip += r.skipped;
2607
2615
  totalOrphans += r.orphans || 0;
2616
+ totalRecognized += r.recognized || 0;
2608
2617
  out(`[mem] ${f}: +${r.prompts} prompts, +${r.observations} observations, ${r.orphans || 0} orphan tool_use, ${r.skipped} skipped`);
2609
2618
  }
2610
2619
  const errorTail = errorCount > 0 ? `, ${errorCount} file(s) errored` : '';
2611
2620
  out(`[mem] Total: ${totalPrompts} prompts, ${totalObs} observations, ${totalOrphans} orphan tool_use, ${totalSkip} skipped from ${files.length} file(s)${errorTail}.`);
2612
- if (totalPrompts > 0 || totalObs > 0) {
2621
+ if (totalPrompts > 0 || totalObs > 0 || totalOrphans > 0) {
2622
+ // Orphan tool_use events persist as (truncated) observations, so they count as
2623
+ // "something was imported" — otherwise an orphan-only first import would wrongly
2624
+ // fall through to the "already imported" no-op branch below.
2613
2625
  out(`[mem] Try: claude-mem-lite recent 5 --project ${project}`);
2626
+ } else if (totalRecognized > 0) {
2627
+ // Lines WERE Claude Code transcript events but produced no new rows — the file
2628
+ // was already imported (idempotent re-run) or carried no extractable content.
2629
+ // Distinct from the wrong-shape case below: do NOT cry "wrong shape" at a valid
2630
+ // transcript the user successfully imported earlier (cold-start backfill re-runs
2631
+ // hit this on every already-ingested file).
2632
+ out(`[mem] Nothing new: ${totalRecognized} transcript event(s) already imported (re-running import-jsonl on the same transcript is a safe no-op).`);
2614
2633
  } else if (totalSkip > 0 && errorCount === 0) {
2615
- // Nothing imported but every line was skipped — almost always the wrong file
2616
- // format (import-jsonl ingests Claude Code transcript JSONL, not `export` output,
2617
- // which is observation-shaped). Pre-fix this exited 0 with no signal, so pointing
2618
- // it at the wrong file looked like success. Make the no-op explicit (stdout, like
2619
- // the summary lines above).
2634
+ // No transcript event recognized at all — almost always the wrong file format
2635
+ // (import-jsonl ingests Claude Code transcript JSONL, not `export` output, which
2636
+ // is observation-shaped). Pre-fix this exited 0 with no signal, so pointing it at
2637
+ // the wrong file looked like success. Make the no-op explicit (stdout, like the
2638
+ // summary lines above).
2620
2639
  out(`[mem] Warning: 0 imported, ${totalSkip} line(s) skipped — none matched the expected Claude Code transcript JSONL shape (user/assistant/tool_result). 'export' output is NOT re-importable via import-jsonl.`);
2621
2640
  }
2622
2641
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "claude-mem-lite",
3
- "version": "3.10.0",
3
+ "version": "3.11.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",
package/secret-scrub.mjs CHANGED
@@ -11,12 +11,14 @@ export const SECRET_PATTERNS = [
11
11
  // and short values (<6 chars) that are typically variable names not secrets.
12
12
  //
13
13
  // Split into two patterns so prose mentions don't get scrubbed:
14
- // 1. Bare credential nouns (password|passwd|token|bearer) commonly appear in
15
- // English prose — "Marker token: xyzpdq", "the bearer: alice". We require
16
- // the keyword NOT to be preceded by an English-word + horizontal-space
17
- // (the prose mention shape). Code/config has the keyword at start-of-line,
18
- // after a separator, or in object-literal context none of which match
19
- // "letter-then-space" preceding the keyword.
14
+ // 1. Bare credential nouns (password|passwd|token|bearer|secret) commonly appear
15
+ // in English prose — "Marker token: xyzpdq", "the bearer: alice". The prose
16
+ // mention shape is the `:` form, so the prose lookbehind (NOT preceded by
17
+ // English-word + horizontal-space) guards ONLY the `:` separator. An `=` is
18
+ // config-assignment syntax, never prose, so `<word> password=<secret>` ALWAYS
19
+ // scrubs without this split that leaked (the lookbehind skipped any noun
20
+ // after "word ", regardless of separator). No pinned prose case uses `=` (all
21
+ // are `:`), so the `=` arm is leak-closing with no FP shift on the protected set.
20
22
  // 2. Structured keys (api_key, auth_token, …) keep the original behavior —
21
23
  // a separator/compound key is unambiguous config syntax even when
22
24
  // preceded by prose ("see auth_token: shhhhhh").
@@ -26,7 +28,10 @@ export const SECRET_PATTERNS = [
26
28
  // keyword. Allowing a leading `_` catches those while the prose lookbehind still
27
29
  // excludes "Marker token: …". `secret` added so a bare SECRET=… with a mixed-alnum
28
30
  // value is covered (the hex-only assignment pattern below misses non-hex values).
29
- [/((?<![A-Za-z][ \t])(?:\b|_)(?:password|passwd|token|bearer|secret)\s*[=:]\s*)(?!process\.env\.)(?!new\s)(?!\w+\()(?!(?:null|undefined|true|false|None|nil|empty|""|''|0)\b)[^\s,;'"}\]]{6,}/gi, '$1***'],
31
+ // 1a. `=` assignment → ALWAYS scrub (config syntax, never prose):
32
+ [/((?:\b|_)(?:password|passwd|token|bearer|secret)\s*=\s*)(?!process\.env\.)(?!new\s)(?!\w+\()(?!(?:null|undefined|true|false|None|nil|empty|""|''|0)\b)[^\s,;'"}\]]{6,}/gi, '$1***'],
33
+ // 1b. `:` separator → keep the prose lookbehind ("the token: alice" is prose):
34
+ [/((?<![A-Za-z][ \t])(?:\b|_)(?:password|passwd|token|bearer|secret)\s*:\s*)(?!process\.env\.)(?!new\s)(?!\w+\()(?!(?:null|undefined|true|false|None|nil|empty|""|''|0)\b)[^\s,;'"}\]]{6,}/gi, '$1***'],
30
35
  // access_token / refresh_token are the canonical OAuth2 field names — they were
31
36
  // missing from this KV list (drift vs the JSON list below). `(?:\b|_)` for the same
32
37
  // underscore-prefix reason.
@@ -47,8 +52,11 @@ export const SECRET_PATTERNS = [
47
52
  // object-literal / YAML / quoted-.env shapes. Split into the SAME two patterns as the
48
53
  // unquoted KV pairs above so prose survives — a quoted value does not turn prose into
49
54
  // config (`the token: "x"` is still prose, must NOT scrub; #8283 / utils.test.mjs:1090).
50
- // (a) bare credential nouns keep the prose lookbehind:
51
- [/((?<![A-Za-z][ \t])(?:\b|_)(?:password|passwd|token|bearer|secret)\s*[=:]\s*)(['"])[^'"]{6,}\2/gi, '$1$2***$2'],
55
+ // (a) bare credential nouns: `=` always scrubs; `:` keeps the prose lookbehind
56
+ // (mirrors the unquoted 1a/1b split — a quoted value doesn't turn `:` prose
57
+ // into config, but `<word> password="x"` is still a leak):
58
+ [/((?:\b|_)(?:password|passwd|token|bearer|secret)\s*=\s*)(['"])[^'"]{6,}\2/gi, '$1$2***$2'],
59
+ [/((?<![A-Za-z][ \t])(?:\b|_)(?:password|passwd|token|bearer|secret)\s*:\s*)(['"])[^'"]{6,}\2/gi, '$1$2***$2'],
52
60
  // (b) structured keys + named env vars are unambiguous config even after a word
53
61
  // (`see api_key: "x"` DOES scrub, mirroring the unquoted structured-key path):
54
62
  [/((?:\b|_)(?:pgpassword|pgpass|mysql_pwd|api[_-]?key|api[_-]?secret|secret[_-]?key|access[_-]?key|private[_-]?key|client[_-]?secret|auth[_-]?token|access[_-]?token|refresh[_-]?token)\s*[=:]\s*)(['"])[^'"]{6,}\2/gi, '$1$2***$2'],
package/server.mjs CHANGED
@@ -997,7 +997,7 @@ server.registerTool(
997
997
  `Memory maintenance scan:`,
998
998
  ` Total active observations: ${stats.total}`,
999
999
  ` Near-duplicate pairs: ${duplicates.length}`,
1000
- ` Stale (>30d, imp=1, no access): ${stats.stale}`,
1000
+ ` Stale (>30d, imp=1, no access, never injected): ${stats.stale}`,
1001
1001
  ` Broken (no title/narrative): ${stats.broken}`,
1002
1002
  ` Boostable (accessed>3, imp<3): ${stats.boostable}`,
1003
1003
  ` Pending purge (idle-marked): ${stats.pendingPurge}`,