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.
- package/.claude-plugin/marketplace.json +1 -1
- package/.claude-plugin/plugin.json +1 -1
- package/README.md +122 -4
- package/README.zh-CN.md +18 -10
- package/haiku-client.mjs +7 -58
- package/hook-context.mjs +4 -7
- package/hook-episode.mjs +44 -20
- package/hook-llm.mjs +7 -2
- package/hook-memory.mjs +25 -10
- package/hook.mjs +132 -178
- package/hooks/hooks.json +1 -1
- package/install.mjs +35 -2
- package/lib/citation-tracker.mjs +67 -51
- package/lib/cite-back-hint.mjs +4 -18
- package/lib/doctor-drift.mjs +8 -1
- package/lib/fast-summary.mjs +87 -0
- package/lib/get-core.mjs +35 -0
- package/lib/maintain-core.mjs +95 -1
- package/lib/persist-reminder.mjs +8 -8
- package/lib/registry-core.mjs +94 -0
- package/lib/resolve-data-dir.mjs +53 -4
- package/lib/summary-extractor.mjs +2 -8
- package/lib/task-imperative.mjs +8 -1
- package/lib/transcript-scan.mjs +66 -0
- package/lib/ups-query.mjs +26 -0
- package/mem-cli.mjs +53 -44
- package/memdir.mjs +5 -1
- package/nlp.mjs +20 -2
- package/npm-shrinkwrap.json +3 -2
- package/package.json +7 -1
- package/registry-recommend.mjs +30 -4
- package/schema.mjs +3 -1
- package/scoring-sql.mjs +21 -7
- package/scripts/pre-agent-inject.sh +43 -0
- package/scripts/user-prompt-search.js +8 -3
- package/server.mjs +30 -38
- package/source-files.mjs +15 -0
- package/utils.mjs +13 -5
package/lib/resolve-data-dir.mjs
CHANGED
|
@@ -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 {
|
|
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
|
|
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;
|
package/lib/task-imperative.mjs
CHANGED
|
@@ -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
|
|
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,
|
|
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:
|
|
73
|
-
ups:
|
|
74
|
-
error_recall:
|
|
75
|
-
fyi:
|
|
76
|
-
|
|
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
|
|
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
|
-
|
|
590
|
-
|
|
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
|
|
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
|
-
|
|
603
|
-
const
|
|
604
|
-
|
|
605
|
-
|
|
606
|
-
|
|
607
|
-
|
|
608
|
-
|
|
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
|
-
//
|
|
2343
|
-
//
|
|
2344
|
-
//
|
|
2345
|
-
|
|
2346
|
-
|
|
2347
|
-
|
|
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
|
|
2375
|
-
out(
|
|
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
|
-
|
|
2383
|
-
|
|
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
|
-
|
|
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
|
|
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)
|
package/npm-shrinkwrap.json
CHANGED
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "claude-mem-lite",
|
|
3
|
-
"version": "3.
|
|
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.
|
|
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.
|
|
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
|
}
|
package/registry-recommend.mjs
CHANGED
|
@@ -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:
|
|
5
|
-
// invocations/recommend_count. Live injection is Phase 2
|
|
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
|
-
/**
|
|
15
|
-
export function
|
|
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 '
|
|
86
|
-
|
|
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
|
|