claude-mem-lite 3.74.1 → 3.75.1

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.
@@ -13,8 +13,57 @@
13
13
  // - A relative path silently resolves against each process's cwd, scattering
14
14
  // state across directories.
15
15
  // Falsy (unset/empty) is the only non-absolute value we treat as "use default".
16
- import { homedir } from 'node:os';
17
- import { join, isAbsolute } from 'node:path';
16
+ import { homedir, tmpdir } from 'node:os';
17
+ import { join, isAbsolute, resolve } from 'node:path';
18
+
19
+ /**
20
+ * Test-run containment (audit 2026-08-22 P2-4).
21
+ *
22
+ * vitest.config.mjs clears CLAUDE_MEM_DIR for every worker, which stops a relocated
23
+ * dev DB from being read — but it cannot stop a test that never sets the var at all:
24
+ * that test simply resolves the DEFAULT, and the default is the maintainer's real
25
+ * `~/.claude-mem-lite`. It is not hypothetical. During the v3.73.0 release a test
26
+ * wrote a `rateLimited` marker into the live data dir, because the module computed
27
+ * its path at import time and the test's later env stub arrived too late.
28
+ *
29
+ * The guard rides the same channel as the leak: a subprocess that inherits
30
+ * `...process.env` (how e2e tests spawn hooks, and how the var goes missing in the
31
+ * first place) also inherits CLAUDE_MEM_TEST_GUARD, so the redirect follows it there.
32
+ * A subprocess given a curated env has to pass CLAUDE_MEM_DIR explicitly and is safe
33
+ * by construction.
34
+ *
35
+ * It REDIRECTS rather than throws, deliberately. Throwing was tried first and took 181
36
+ * of 289 test files down at collection: schema.mjs resolves the dir at IMPORT time, so
37
+ * every test that imports it — including the hundreds that then use `:memory:` and never
38
+ * touch a file — would have to opt out of a guard they never needed. "Imported the
39
+ * module" is not the failure; "wrote to the real directory" is, and a redirect makes
40
+ * that one impossible while leaving the import harmless.
41
+ *
42
+ * The redirect target is shared per run (CLAUDE_MEM_TEST_SANDBOX, set by
43
+ * tests/global-setup.mjs) so a parent and the subprocess it spawns still agree on one
44
+ * directory — the same reason the ambient env is inherited at all.
45
+ *
46
+ * Escape hatch: CLAUDE_MEM_TEST_GUARD=off, for a test that must exercise real default
47
+ * resolution. It should be rare and it should say why.
48
+ */
49
+ function containInTests(dir) {
50
+ if (process.env.CLAUDE_MEM_TEST_GUARD !== '1') return dir;
51
+ // Block ONE directory: the real one. "Anywhere outside os.tmpdir()" was tried first
52
+ // and was wrong twice over — fixtures hardcode '/tmp' while os.tmpdir() follows
53
+ // $TMPDIR (relocated under $HOME by a sandboxed shell), and several suites keep their
54
+ // scratch DB inside the repo at tests/.tmp-*. Both are isolated; neither is the leak.
55
+ // The leak is always the same shape: nobody set CLAUDE_MEM_DIR, so the DEFAULT
56
+ // resolved, and the default is the developer's live database.
57
+ //
58
+ // Compared against the path captured by tests/global-setup.mjs BEFORE the suite
59
+ // relocated anything, not against homedir() — several suites deliberately run with
60
+ // HOME pointed at a fixture, and re-deriving the default here would redirect exactly
61
+ // those legitimate cases while missing the real dir once HOME moved.
62
+ const real = process.env.CLAUDE_MEM_TEST_REALDIR || join(homedir(), '.claude-mem-lite');
63
+ if (resolve(dir) !== resolve(real)) return dir;
64
+ const sandbox = process.env.CLAUDE_MEM_TEST_SANDBOX;
65
+ return sandbox && isAbsolute(sandbox) ? sandbox : join(resolve(tmpdir()), 'claude-mem-test-fallback');
66
+ }
18
67
 
19
68
  /**
20
69
  * @param {string|undefined|null} raw Typically process.env.CLAUDE_MEM_DIR.
@@ -23,7 +72,7 @@ import { join, isAbsolute } from 'node:path';
23
72
  */
24
73
  export function resolveDataDir(raw) {
25
74
  if (raw === undefined || raw === null || raw === '') {
26
- return join(homedir(), '.claude-mem-lite');
75
+ return containInTests(join(homedir(), '.claude-mem-lite'));
27
76
  }
28
77
  if (typeof raw !== 'string' || raw === 'undefined' || raw === 'null' || !isAbsolute(raw)) {
29
78
  throw new Error(
@@ -31,5 +80,5 @@ export function resolveDataDir(raw) {
31
80
  `Leave it unset to use ~/.claude-mem-lite.`
32
81
  );
33
82
  }
34
- return raw;
83
+ return containInTests(raw);
35
84
  }
@@ -9,7 +9,7 @@
9
9
  // with empty remaining_items. This extractor runs synchronously in
10
10
  // handleStop and gives a deterministic floor.
11
11
 
12
- import { readFileSync, existsSync } from 'fs';
12
+ import { readTranscriptEntries } from './transcript-scan.mjs';
13
13
 
14
14
  const EN_HEADER = /^[\s●*>-]*(Done|Not\s+done|Failed|Uncertain)\s*[::]\s*/im;
15
15
  const ZH_HEADER = /^[\s●*>-]*(剩下的?|剩余|还剩|未完成|下次(?:要做|做|继续)?|待做|未做)\s*[::]?\s*/m;
@@ -27,14 +27,8 @@ const ZH_KEY_IS_NOTDONE = /剩下|剩余|还剩|未完成|下次|待做|未做/;
27
27
  * @returns {string|null}
28
28
  */
29
29
  export function extractTailAssistantText(transcriptPath) {
30
- if (!transcriptPath || !existsSync(transcriptPath)) return null;
31
- let raw;
32
- try { raw = readFileSync(transcriptPath, 'utf8'); } catch { return null; }
33
30
  let last = null;
34
- for (const line of raw.split('\n')) {
35
- if (!line.trim()) continue;
36
- let entry;
37
- try { entry = JSON.parse(line); } catch { continue; }
31
+ for (const entry of readTranscriptEntries(transcriptPath)) {
38
32
  if (entry.type !== 'assistant' || !entry.message) continue;
39
33
  const content = entry.message.content;
40
34
  if (!Array.isArray(content)) continue;
@@ -12,11 +12,18 @@ import { neutralizeContextDelimiters } from '../format-utils.mjs';
12
12
  // dropped.
13
13
  // Spec: docs/superpowers/specs/2026-06-29-task-imperative-memory-injection-design.md
14
14
 
15
+ // The emitted line's fixed head. Exported because lib/citation-tracker.mjs matches on
16
+ // it to meter this face: an extractor carrying its own copy of the framing is the exact
17
+ // shape that silenced Go panics in v3.74.0 (trigger and filter were two lists that were
18
+ // never the same list). One constant, both directions — change the framing and the
19
+ // meter follows, or nothing does.
20
+ export const TASK_IMPERATIVE_PREFIX = 'Memory — a past lesson applies to THIS task.';
21
+
15
22
  export function formatTaskImperative(lesson, id) {
16
23
  const body = neutralizeContextDelimiters(String(lesson || '').trim().replace(/\.$/, ''));
17
24
  if (!body) return '';
18
25
  const tag = (id === undefined || id === null || id === '') ? '' : ` (#${id})`;
19
- return `Memory — a past lesson applies to THIS task. You must: ${body}.${tag}`;
26
+ return `${TASK_IMPERATIVE_PREFIX} You must: ${body}.${tag}`;
20
27
  }
21
28
 
22
29
  // Subagent-dispatch framing. Subagents are memory-blind (plugin hooks don't fire
@@ -0,0 +1,66 @@
1
+ // One parse of a Claude Code transcript, shared by everything that scans it.
2
+ //
3
+ // Audit 2026-08-22 P2-8. handleStop asked the same .jsonl the same question eight
4
+ // different ways — tail assistant text, citations, citations again with mainOnly,
5
+ // injected-by-surface, cite-back signals, main-thread text, cite-recall, bugfix shape —
6
+ // and every one of them did its own readFileSync + split('\n') + JSON.parse per line.
7
+ // Measured on a real 5.7MB transcript: ~25ms per pass, so the repeated work is roughly
8
+ // 175ms of a 5s Stop budget, and it scales linearly with a session that runs long.
9
+ // Parsing is nearly all of it; iterating an already-parsed array of 2908 entries is
10
+ // 0.1ms.
11
+ //
12
+ // This is a memo, not a rewrite: each scanner keeps its own per-entry logic exactly as
13
+ // it was, and only stops re-reading and re-parsing the file to get at it.
14
+ import { readFileSync, existsSync, statSync } from 'fs';
15
+
16
+ // Retention cap. Parsed entries cost ~3.45× the file size in heap (measured, same
17
+ // transcript: 5.7MB → 19.5MB). Holding that across a whole Stop is fine for the sessions
18
+ // people actually have; holding it for a 50MB transcript is not, and a hook that gets
19
+ // OOM-killed loses the session's work outright. Above the cap each caller parses on its
20
+ // own exactly as before, so the worst case is today's behaviour rather than a new one.
21
+ export const TRANSCRIPT_CACHE_MAX_BYTES = 24 * 1024 * 1024;
22
+
23
+ let cacheKey = '';
24
+ let cacheEntries = null;
25
+
26
+ /**
27
+ * Parsed transcript records, in file order, unparsable lines dropped.
28
+ *
29
+ * The cache key carries size and mtime: a transcript is append-only and Claude Code is
30
+ * still writing to it while the Stop hook runs, so a later caller in the same process
31
+ * must see the same fresh data it would have read for itself.
32
+ *
33
+ * @param {string|null|undefined} transcriptPath
34
+ * @returns {object[]} entries (empty array when the path is missing or unreadable)
35
+ */
36
+ export function readTranscriptEntries(transcriptPath) {
37
+ if (!transcriptPath || !existsSync(transcriptPath)) return [];
38
+ let st;
39
+ try { st = statSync(transcriptPath); } catch { return []; }
40
+ const key = `${transcriptPath} ${st.size} ${st.mtimeMs}`;
41
+ if (key === cacheKey && cacheEntries) return cacheEntries;
42
+
43
+ let raw;
44
+ try { raw = readFileSync(transcriptPath, 'utf8'); } catch { return []; }
45
+ const entries = [];
46
+ for (const line of raw.split('\n')) {
47
+ if (!line.trim()) continue;
48
+ try { entries.push(JSON.parse(line)); } catch { /* a partially written tail line */ }
49
+ }
50
+ if (st.size <= TRANSCRIPT_CACHE_MAX_BYTES) {
51
+ cacheKey = key;
52
+ cacheEntries = entries;
53
+ } else {
54
+ // Drop whatever was held: an oversized transcript should not keep an older, smaller
55
+ // one alive in memory for the rest of the process either.
56
+ cacheKey = '';
57
+ cacheEntries = null;
58
+ }
59
+ return entries;
60
+ }
61
+
62
+ /** Test seam: forget the memo so a fixture rewritten within one mtime tick is re-read. */
63
+ export function _resetTranscriptCache() {
64
+ cacheKey = '';
65
+ cacheEntries = null;
66
+ }
@@ -0,0 +1,26 @@
1
+ // lib/ups-query.mjs — the ONE query-cap definition for the UserPromptSubmit event.
2
+ //
3
+ // That event fires two hooks: scripts/user-prompt-search.js (the FYI block) and
4
+ // `hook.mjs user-prompt` (the <memory-context> block). v3.75.0 capped the first and left
5
+ // the second building an uncapped query from the raw prompt — the guard-on-one-path shape
6
+ // this codebase pays for more than any other. Both faces now import from here, so the cap
7
+ // cannot be present on one and absent on the other.
8
+ //
9
+ // The caps bound what is COMPUTED, not what is read. The stdin guards upstream
10
+ // (MAX_UPS_PROMPT_BYTES 64KB on path A, MAX_HOOK_STDIN_BYTES 256KB on path B) cap the
11
+ // input; sanitizeFtsQuery still costs 0.8ms on a normal prompt, 6.2ms on a 64KB ASCII one
12
+ // and 31.8ms on a 64KB CJK one (extractCjkKeywords is O(len x dict) over an unsegmented
13
+ // run), all of it before the model sees the turn. 2000 characters is a long prompt by any
14
+ // measure, and past ~64 meaningful AND-joined terms an FTS5 query matches nothing anyway
15
+ // and survives only through the OR fallback.
16
+ //
17
+ // An explicit `claude-mem-lite search` stays UNCAPPED — a person who types a long query
18
+ // meant it. Only these two automatic surfaces pass the caps.
19
+ import { sanitizeFtsQuery } from '../utils.mjs';
20
+
21
+ export const UPS_QUERY_CAPS = { maxChars: 2000, maxTokens: 64 };
22
+
23
+ /** The capped query builder every automatic prompt-time search path goes through. */
24
+ export function upsFtsQuery(text) {
25
+ return sanitizeFtsQuery(text, UPS_QUERY_CAPS);
26
+ }
package/mem-cli.mjs CHANGED
@@ -17,10 +17,11 @@ import { resolveCliProject as cliProject } from './lib/cli-project.mjs';
17
17
  import { _resetVocabCache, vecTextForRow, vectorsEnabled } from './tfidf.mjs';
18
18
  import { reRankWithContext } from './search-scoring.mjs';
19
19
  import { searchObservationsHybrid } from './search-engine.mjs';
20
- import { fetchObsDetail, OBS_FIELDS, SESSION_DETAIL_FIELDS, supersededNotice } from './lib/get-core.mjs';
20
+ import { fetchObsDetail, fetchPromptDetail, fetchEventDetail, OBS_FIELDS, SESSION_DETAIL_FIELDS, PROMPT_DETAIL_FIELDS, EVENT_DETAIL_FIELDS, supersededNotice } from './lib/get-core.mjs';
21
21
  import { collectBrowseTiers, getActiveMemorySessionId, BROWSE_TIERS, BROWSE_TIER_LABELS } from './lib/browse-core.mjs';
22
22
  import { deepSearch, resolveDeepMode, shouldEscalateToDeep, autoDeepLlmReady } from './deep-search.mjs';
23
- import { ensureRegistryDb, upsertResource, collectRegistryStats, listResourcesRanked, formatRegistryListLine } from './registry.mjs';
23
+ import { ensureRegistryDb, collectRegistryStats, listResourcesRanked, formatRegistryListLine } from './registry.mjs';
24
+ import { IMPORT_STRING_FIELDS, importResource, removeResource, reindexResources } from './lib/registry-core.mjs';
24
25
  import { searchResources } from './registry-retriever.mjs';
25
26
  import { computeFunnel, formatFunnel, computeSweep, formatSweep, DEFAULT_SWEEP_FLOORS, DEFAULT_SWEEP_MARGINS } from './registry-recommend.mjs';
26
27
  import { selectCompressionCandidates, groupByProjectWeek, compressGroup } from './lib/compress-core.mjs';
@@ -69,11 +70,12 @@ import { computeCitationFunnelTrend, computeSurfaceFunnel } from './lib/citation
69
70
  // the citation-stats face table lines up; the enum itself lives in
70
71
  // lib/citation-tracker.mjs (CITATION_SURFACES).
71
72
  const SURFACE_LABELS = {
72
- pretool: 'PreToolUse recall ',
73
- ups: 'UserPromptSubmit ',
74
- error_recall: 'error-recall ',
75
- fyi: 'FYI (prompt-search)',
76
- keyctx: 'Key Context ',
73
+ pretool: 'PreToolUse recall ',
74
+ ups: 'UserPromptSubmit ',
75
+ error_recall: 'error-recall ',
76
+ fyi: 'FYI (prompt-search)',
77
+ task_imperative: 'task-imperative ',
78
+ keyctx: 'Key Context ',
77
79
  };
78
80
  import { aggregateMetrics, readMetrics } from './lib/metrics.mjs';
79
81
  import {
@@ -579,34 +581,50 @@ function renderSessionRows(db, ids) {
579
581
  return { text: parts.join('\n\n'), count: rows.length };
580
582
  }
581
583
 
584
+ // The CLI's established labels for the prompt/event detail faces. Sharing the FIELD SET
585
+ // with MCP (P2-6) must not rename what users already grep for, so the columns that had a
586
+ // label keep it; anything added later falls back to title-case.
587
+ const CLI_DETAIL_LABELS = {
588
+ prompt_text: 'Text',
589
+ content_session_id: 'Session',
590
+ file_paths: 'Files',
591
+ git_sha: 'Git',
592
+ };
593
+
594
+ /** Label a column for the CLI's `Label: value` render style. */
595
+ const cliFieldLabel = (f) =>
596
+ CLI_DETAIL_LABELS[f] || f[0].toUpperCase() + f.slice(1).replace(/_/g, ' ');
597
+
582
598
  function renderPromptRows(db, ids) {
583
- const placeholders = ids.map(() => '?').join(',');
584
- const rows = db.prepare(`SELECT * FROM user_prompts WHERE id IN (${placeholders}) ORDER BY created_at_epoch ASC`).all(...ids);
599
+ const rows = fetchPromptDetail(db, ids);
585
600
  if (rows.length === 0) return null;
586
601
  const parts = [];
587
602
  for (const r of rows) {
588
603
  const lines = [`P#${r.id} ${fmtDateShort(r.created_at)}`];
589
- if (r.prompt_text) lines.push(`Text: ${r.prompt_text}`);
590
- if (r.content_session_id) lines.push(`Session: ${r.content_session_id}`);
604
+ // id and created_at are already in the header (same convention as the session face).
605
+ for (const f of PROMPT_DETAIL_FIELDS) {
606
+ if (f === 'id' || f === 'created_at') continue;
607
+ const val = r[f];
608
+ if (val === null || val === undefined || val === '') continue;
609
+ lines.push(`${cliFieldLabel(f)}: ${val}`);
610
+ }
591
611
  parts.push(lines.join('\n'));
592
612
  }
593
613
  return { text: parts.join('\n\n'), count: rows.length };
594
614
  }
595
615
 
596
616
  function renderEventRows(db, ids) {
597
- const placeholders = ids.map(() => '?').join(',');
598
- const rows = db.prepare(`SELECT * FROM events WHERE id IN (${placeholders}) ORDER BY created_at_epoch ASC`).all(...ids);
617
+ const rows = fetchEventDetail(db, ids); // derives created_at from created_at_epoch
599
618
  if (rows.length === 0) return null;
600
619
  const parts = [];
601
620
  for (const r of rows) {
602
- // events store the distilled lesson in `body`; only *_epoch is available for the date.
603
- const lines = [`E#${r.id} [${r.event_type}] ${r.created_at_epoch ? fmtDateShort(new Date(r.created_at_epoch).toISOString()) : ''}`];
604
- if (r.title) lines.push(`Title: ${r.title}`);
605
- if (r.body) lines.push(`Body: ${r.body}`);
606
- if (r.project) lines.push(`Project: ${r.project}`);
607
- if (r.importance !== null && r.importance !== undefined) lines.push(`Importance: ${r.importance}`);
608
- if (r.file_paths) lines.push(`Files: ${r.file_paths}`);
609
- if (r.git_sha) lines.push(`Git: ${r.git_sha}`);
621
+ const lines = [`E#${r.id} [${r.event_type}] ${r.created_at ? fmtDateShort(r.created_at) : ''}`];
622
+ for (const f of EVENT_DETAIL_FIELDS) {
623
+ if (f === 'id' || f === 'event_type' || f === 'created_at') continue; // in the header
624
+ const val = r[f];
625
+ if (val === null || val === undefined || val === '') continue;
626
+ lines.push(`${cliFieldLabel(f)}: ${val}`);
627
+ }
610
628
  parts.push(lines.join('\n'));
611
629
  }
612
630
  return { text: parts.join('\n\n'), count: rows.length };
@@ -2339,24 +2357,12 @@ function cmdRegistry(_memDb, args) {
2339
2357
  fail(`[mem] Invalid --source "${flags.source}". Valid: preinstalled, user, github`);
2340
2358
  return;
2341
2359
  }
2342
- // Preserve provenance on a metadata-only re-import: default source to 'user' only for
2343
- // a genuinely NEW resource. Re-importing an existing github/preinstalled row without
2344
- // --source must not flip it to 'user' (which also mis-grants the user-source rank boost).
2345
- let source = flags.source;
2346
- if (!source) {
2347
- const existing = rdb.prepare('SELECT source FROM resources WHERE type = ? AND name = ?').get(resourceType, name);
2348
- source = existing ? existing.source : 'user';
2349
- }
2350
- const fields = { name, type: resourceType, status: 'active', source };
2351
- for (const f of ['repo-url', 'local-path', 'invocation-name', 'intent-tags', 'domain-tags', 'trigger-patterns', 'capability-summary', 'keywords', 'tech-stack', 'use-cases']) {
2352
- const camel = f.replace(/-([a-z])/g, (_, c) => '_' + c);
2353
- fields[camel] = flags[f] || '';
2354
- }
2355
- const id = upsertResource(rdb, fields);
2356
- // User-imported resources get 'installed' quality tier (user explicitly chose to add them)
2357
- if (id && !flags.source) {
2358
- rdb.prepare("UPDATE resources SET quality_tier = 'installed' WHERE id = ?").run(id);
2359
- }
2360
+ // Provenance preservation + the 'installed' tier grant live in lib/registry-core.mjs,
2361
+ // shared with the mem_registry MCP twin (audit 2026-08-22 P1-3). CLI flags are
2362
+ // kebab-case; the core's field list is the canonical snake_case source.
2363
+ const fields = {};
2364
+ for (const f of IMPORT_STRING_FIELDS) fields[f] = flags[f.replace(/_/g, '-')] || '';
2365
+ const { id } = importResource(rdb, { name, type: resourceType, source: flags.source, fields });
2360
2366
  out(`[mem] Imported: ${resourceType}:${name} (id=${id})`);
2361
2367
  if (!flags['capability-summary'] && !flags['use-cases']) {
2362
2368
  out('[mem] Tip: Add --capability-summary or --use-cases so the resource appears in searches.');
@@ -2371,17 +2377,16 @@ function cmdRegistry(_memDb, args) {
2371
2377
  const name = flags.name;
2372
2378
  const resourceType = flags['resource-type'];
2373
2379
  if (!name || !resourceType) { fail('[mem] Usage: claude-mem-lite registry remove --name N --resource-type skill|agent'); return; }
2374
- const result = rdb.prepare('DELETE FROM resources WHERE type = ? AND name = ?').run(resourceType, name);
2375
- out(result.changes > 0
2380
+ const { removed } = removeResource(rdb, { name, type: resourceType });
2381
+ out(removed
2376
2382
  ? `[mem] Removed: ${resourceType}:${name}`
2377
2383
  : `[mem] Not found: ${resourceType}:${name}`);
2378
2384
  return;
2379
2385
  }
2380
2386
 
2381
2387
  if (action === 'reindex') {
2382
- rdb.exec("INSERT INTO resources_fts(resources_fts) VALUES('rebuild')");
2383
- const count = rdb.prepare('SELECT COUNT(*) as c FROM resources WHERE status = ?').get('active');
2384
- out(`[mem] FTS5 reindexed. ${count.c} active resources.`);
2388
+ const { activeCount } = reindexResources(rdb);
2389
+ out(`[mem] FTS5 reindexed. ${activeCount} active resources.`);
2385
2390
  return;
2386
2391
  }
2387
2392
  } finally {
@@ -2802,6 +2807,9 @@ Commands:
2802
2807
 
2803
2808
  doctor Environment diagnostics and benchmarks
2804
2809
  --benchmark Run perf benchmark and emit JSON
2810
+ --metrics Summarize the recorded metrics window (CLAUDE_MEM_METRICS=1)
2811
+ --session-audit Audit session/episode state for orphans and drift
2812
+ --json Machine-readable output (plain doctor run)
2805
2813
 
2806
2814
  fts-check <check|rebuild> FTS5 index check or rebuild
2807
2815
 
@@ -2837,6 +2845,7 @@ Commands:
2837
2845
  import Import resource --name N --resource-type T [--repo-url U] [--local-path P] [--use-cases U]
2838
2846
  remove Remove resource --name N --resource-type T
2839
2847
  reindex Rebuild FTS5 index
2848
+ recommend-stats Shadow recommendation funnel [--days N] [--sweep] [--json]
2840
2849
 
2841
2850
  import-jsonl <file-or-dir> Import Claude Code JSONL transcripts (cold-start backfill)
2842
2851
  --project P Project name (default: inferred from cwd)
package/memdir.mjs CHANGED
@@ -209,7 +209,11 @@ export function writePluginSection(memdir, { slug, version, contentLine, force =
209
209
  }
210
210
  }
211
211
 
212
- const next = raw.replace(match[0], freshSection);
212
+ // Function form, NOT the string form: a string second argument has `$&` / `$1` /
213
+ // "$`" / `$'` interpreted, and freshSection carries caller-supplied contentLine — a
214
+ // `$&` there would expand to the whole matched sentinel block. Same fix claudemd.mjs
215
+ // took at its own replace (v3.40); this was the twin that kept the string form.
216
+ const next = raw.replace(match[0], () => freshSection);
213
217
  const changed = next !== raw;
214
218
  if (changed) atomicWrite(path, next);
215
219
  writeState(memdir, slug, { version, bodyHash: freshHash, writtenAt: new Date().toISOString() });
package/nlp.mjs CHANGED
@@ -245,12 +245,26 @@ export const FTS_STOP_WORDS = new Set([...BASE_STOP_WORDS, ...CONTRACTION_FRAGME
245
245
  /**
246
246
  * Sanitize and expand a user query into a valid FTS5 query string.
247
247
  * Strips special characters, expands synonyms, and joins with AND/space.
248
+ *
249
+ * Unbounded by default — an explicit `search` from a human should search what they
250
+ * typed. The two caps exist for the AUTOMATIC surface (audit 2026-08-22 P2-13):
251
+ * UserPromptSubmit runs this on every prompt the user sends, with only a 64KB byte
252
+ * guard upstream, and cost grows with prompt length: 0.8ms on a normal prompt, 6.2ms
253
+ * on a 64KB ASCII one, 31.8ms on a 64KB CJK one (extractCjkKeywords is O(len x dict)
254
+ * over an unsegmented run). That is paid before the model sees the turn.
255
+ *
248
256
  * @param {string} query Raw user search query
257
+ * @param {object} [opts]
258
+ * @param {number} [opts.maxChars] Truncate the raw query first (bounds CJK segmentation,
259
+ * which sees one huge token where ASCII sees many small ones).
260
+ * @param {number} [opts.maxTokens] Keep at most this many meaningful terms, applied
261
+ * after stopword filtering and before expansion.
249
262
  * @returns {string|null} FTS5-safe query or null if empty
250
263
  */
251
- export function sanitizeFtsQuery(query) {
264
+ export function sanitizeFtsQuery(query, opts = {}) {
252
265
  if (!query) return null;
253
- const cleaned = query
266
+ const { maxChars = 0, maxTokens = 0 } = opts;
267
+ const cleaned = (maxChars > 0 ? String(query).slice(0, maxChars) : query)
254
268
  // Strip ASCII control chars / NUL FIRST. A NUL survives tokenization (it's not
255
269
  // \s), gets phrase-quoted by expandToken, and then terminates SQLite's C string
256
270
  // mid-phrase → FTS5 "unterminated string" throw, breaking the documented
@@ -293,6 +307,10 @@ export function sanitizeFtsQuery(query) {
293
307
  // Filter stop words (but keep all if filtering would empty the query)
294
308
  const filtered = tokens.filter(t => !FTS_STOP_WORDS.has(t.toLowerCase()));
295
309
  if (filtered.length > 0) tokens = filtered;
310
+ // Cap AFTER stopword filtering and BEFORE expansion: the terms kept are meaningful
311
+ // ones, and everything downstream (CJK segmentation, synonym expansion, bigrams) is
312
+ // per-token work that the cap therefore bounds too.
313
+ if (maxTokens > 0 && tokens.length > maxTokens) tokens = tokens.slice(0, maxTokens);
296
314
  // Split unsegmented CJK tokens into known vocabulary words using CJK_COMPOUNDS dictionary.
297
315
  // Uses broader dictionary than synonym-only extraction for better recall.
298
316
  // e.g. "这个函数是做什么的" → ["函数"] (not noisy bigrams)
@@ -1,12 +1,12 @@
1
1
  {
2
2
  "name": "claude-mem-lite",
3
- "version": "3.74.1",
3
+ "version": "3.75.1",
4
4
  "lockfileVersion": 3,
5
5
  "requires": true,
6
6
  "packages": {
7
7
  "": {
8
8
  "name": "claude-mem-lite",
9
- "version": "3.74.1",
9
+ "version": "3.75.1",
10
10
  "dependencies": {
11
11
  "@modelcontextprotocol/sdk": "^1.26.0",
12
12
  "better-sqlite3": "^12.6.2",
@@ -21,6 +21,7 @@
21
21
  "eslint": "^10.0.0",
22
22
  "fast-check": "^4.5.3",
23
23
  "knip": "^6.12.1",
24
+ "picomatch": "^4.0.4",
24
25
  "vitest": "^4.0.18"
25
26
  },
26
27
  "engines": {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "claude-mem-lite",
3
- "version": "3.74.1",
3
+ "version": "3.75.1",
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",
@@ -96,6 +96,7 @@
96
96
  "lib/save-observation.mjs",
97
97
  "lib/observation-write.mjs",
98
98
  "lib/recall-core.mjs",
99
+ "lib/registry-core.mjs",
99
100
  "lib/timeline-core.mjs",
100
101
  "lib/search-core.mjs",
101
102
  "lib/recent-core.mjs",
@@ -110,6 +111,9 @@
110
111
  "lib/save-enrich.mjs",
111
112
  "lib/persist-reminder.mjs",
112
113
  "lib/maintain-core.mjs",
114
+ "lib/fast-summary.mjs",
115
+ "lib/transcript-scan.mjs",
116
+ "lib/ups-query.mjs",
113
117
  "lib/dedup-constants.mjs",
114
118
  "lib/deferred-work.mjs",
115
119
  "lib/upgrade-banner.mjs",
@@ -166,6 +170,7 @@
166
170
  "scripts/pre-tool-recall.js",
167
171
  "scripts/post-tool-recall.js",
168
172
  "scripts/pre-skill-bridge.js",
173
+ "scripts/pre-agent-inject.sh",
169
174
  "scripts/pre-agent-inject.js",
170
175
  "scripts/prompt-search-utils.mjs",
171
176
  "scripts/hook-launcher.mjs",
@@ -194,6 +199,7 @@
194
199
  "eslint": "^10.0.0",
195
200
  "fast-check": "^4.5.3",
196
201
  "knip": "^6.12.1",
202
+ "picomatch": "^4.0.4",
197
203
  "vitest": "^4.0.18"
198
204
  }
199
205
  }
@@ -1,8 +1,10 @@
1
1
  // Intent-based skill recommendation — Phase 1 (shadow).
2
2
  // See docs/superpowers/specs/2026-06-23-skill-recommendation-loop-design.md
3
3
  //
4
- // Phase-1 invariant: shadow AND live only LOG. Neither emits to stdout nor writes
5
- // invocations/recommend_count. Live injection is Phase 2. `off` skips all work.
4
+ // Phase-1 invariant: the engine only LOGS. It never emits to stdout and never writes
5
+ // invocations/recommend_count. Live injection is Phase 2 and does not exist yet, so
6
+ // `live` resolves to shadow with a one-time warning (see UNIMPLEMENTED_MODES).
7
+ // `off` skips all work.
6
8
  import { readFileSync, writeFileSync, renameSync, existsSync, mkdirSync, appendFileSync, readdirSync, unlinkSync } from 'fs';
7
9
  import { join } from 'path';
8
10
  import { resolveDataDir } from './lib/resolve-data-dir.mjs';
@@ -10,13 +12,37 @@ import { searchResources, cjkIntentTokens } from './registry-retriever.mjs';
10
12
 
11
13
  import { DAY_MS } from './lib/time-constants.mjs';
12
14
  const VALID_MODES = new Set(['shadow', 'live', 'off']);
15
+ // Phase 2 (live injection) was never built. `live` stayed in VALID_MODES and
16
+ // getRecommendMode returned it verbatim, so setting it bought a user exactly
17
+ // nothing and said nothing — the run behaved as shadow while the mode field in
18
+ // every logged row claimed 'live'. Audit 2026-08-22 P2-5: an accepted value that
19
+ // silently means something else is worse than an unsupported one. It still parses
20
+ // (nobody's config breaks), it now resolves to what actually happens, and it says
21
+ // so once per process.
22
+ export const RECOMMEND_MODE_UNIMPLEMENTED = new Set(['live']);
23
+ let warnedUnimplemented = false;
13
24
 
14
- /** Recommendation mode from env; default 'shadow', unknown → 'shadow'. */
15
- export function getRecommendMode() {
25
+ /** The mode the environment ASKED for — 'live' included. For diagnostics (doctor). */
26
+ export function getRequestedRecommendMode() {
16
27
  const raw = (process.env.CLAUDE_MEM_RECOMMEND_MODE || 'shadow').toLowerCase().trim();
17
28
  return VALID_MODES.has(raw) ? raw : 'shadow';
18
29
  }
19
30
 
31
+ /** Recommendation mode actually in effect; default 'shadow', unknown → 'shadow'. */
32
+ export function getRecommendMode() {
33
+ const requested = getRequestedRecommendMode();
34
+ if (!RECOMMEND_MODE_UNIMPLEMENTED.has(requested)) return requested;
35
+ if (!warnedUnimplemented && !process.env.MEM_QUIET_HOOKS) {
36
+ warnedUnimplemented = true;
37
+ // stderr, once: hook stdout is a parsed envelope and must not carry this.
38
+ process.stderr.write(
39
+ `[claude-mem-lite] CLAUDE_MEM_RECOMMEND_MODE=${requested} is not implemented `
40
+ + '(live injection is Phase 2); running in shadow mode — nothing is injected. '
41
+ + 'Set shadow or off to silence this.\n');
42
+ }
43
+ return 'shadow';
44
+ }
45
+
20
46
  // Provisional gate thresholds — calibrated from shadow data before Phase 2 (spec §8).
21
47
  // relevance is raw bm25 (negative; more negative = better). Candidate must clear |floor|.
22
48
  export const RECO_BM25_FLOOR = -1.5; // floor stays on raw bm25 (absolute topical relevance)
package/schema.mjs CHANGED
@@ -280,7 +280,9 @@ const CORE_SCHEMA = `
280
280
  -- v45: per-INJECTION-FACE twin of citation_log. One row per
281
281
  -- (project, session, surface); the surface column is one of the
282
282
  -- CITATION_SURFACES enum in lib/citation-tracker.mjs
283
- -- (pretool | ups | error_recall | fyi | keyctx).
283
+ -- (pretool | ups | error_recall | fyi | task_imperative | keyctx). The column is plain
284
+ -- TEXT with no CHECK: the JS enum is the gate (recordCitationSurfaces drops unknown
285
+ -- labels), which is why adding a face needs no migration.
284
286
  --
285
287
  -- session_id is the CLAUDE CODE session id, NOT the memory session id that
286
288
  -- keys citation_log. The two tables therefore do NOT join, on purpose. The
package/scoring-sql.mjs CHANGED
@@ -80,15 +80,29 @@ export const TYPE_DECAY_CASE = `(
80
80
  * The old (pre-R2) table had bugfix=0.75 < change=0.8, inverted vs reality.
81
81
  * Applied as: BM25 × time_decay × TYPE_QUALITY × project_boost × importance
82
82
  */
83
+ export const TYPE_QUALITY = {
84
+ decision: 1.5,
85
+ discovery: 1.3,
86
+ bugfix: 1.1,
87
+ feature: 1.0,
88
+ refactor: 0.6,
89
+ change: 0.5,
90
+ };
91
+
92
+ /** Multiplier for a type not in the table (legacy rows, manual saves). */
93
+ export const TYPE_QUALITY_DEFAULT = 1.0;
94
+
95
+ /**
96
+ * The SQL form, generated from TYPE_QUALITY rather than written beside it.
97
+ * The table used to exist three times — here, hook-context.mjs, hook-memory.mjs — with
98
+ * "aligned with scoring-sql.mjs (R2)" comments as the only thing keeping them equal
99
+ * (audit 2026-08-22, P2-10). Values happened to agree; the next re-weighting is what the
100
+ * copies were waiting for. Both JS consumers now import TYPE_QUALITY from here.
101
+ */
83
102
  export const TYPE_QUALITY_CASE = `(
84
103
  CASE o.type
85
- WHEN 'decision' THEN 1.5
86
- WHEN 'discovery' THEN 1.3
87
- WHEN 'bugfix' THEN 1.1
88
- WHEN 'feature' THEN 1.0
89
- WHEN 'refactor' THEN 0.6
90
- WHEN 'change' THEN 0.5
91
- ELSE 1.0
104
+ ${Object.entries(TYPE_QUALITY).map(([t, w]) => ` WHEN '${t}' THEN ${w.toFixed(1)}`).join('\n')}
105
+ ELSE ${TYPE_QUALITY_DEFAULT.toFixed(1)}
92
106
  END
93
107
  )`;
94
108