claude-mem-lite 3.65.0 → 3.66.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/format-utils.mjs +2 -1
- package/haiku-client.mjs +39 -6
- package/hook-context.mjs +5 -4
- package/hook-llm.mjs +7 -5
- package/hook-memory.mjs +4 -3
- package/hook-optimize.mjs +11 -10
- package/hook-precompact.mjs +12 -12
- package/hook-shared.mjs +122 -5
- package/hook.mjs +40 -14
- package/lib/citation-tracker.mjs +59 -5
- package/lib/db-backup.mjs +2 -1
- package/lib/deferred-work.mjs +1 -1
- package/lib/err-sampler.mjs +1 -1
- package/lib/hook-telemetry.mjs +1 -1
- package/lib/keyctx-marker.mjs +74 -0
- package/lib/maintain-core.mjs +2 -1
- package/lib/metrics.mjs +1 -1
- package/lib/save-enrich.mjs +3 -2
- package/lib/search-core.mjs +2 -1
- package/lib/stats-core.mjs +4 -3
- package/lib/stats-quality.mjs +2 -1
- package/lib/time-constants.mjs +21 -0
- package/mem-cli.mjs +6 -5
- package/npm-shrinkwrap.json +2 -2
- package/package.json +3 -1
- package/registry-enricher.mjs +2 -2
- package/registry-recommend.mjs +3 -2
- package/scoring-sql.mjs +8 -7
- package/scripts/pre-tool-recall.js +2 -1
- package/scripts/user-prompt-search.js +2 -1
- package/search-scoring.mjs +2 -1
- package/server.mjs +10 -4
- package/source-files.mjs +6 -0
- package/tier.mjs +4 -3
|
@@ -10,7 +10,7 @@
|
|
|
10
10
|
"plugins": [
|
|
11
11
|
{
|
|
12
12
|
"name": "claude-mem-lite",
|
|
13
|
-
"version": "3.
|
|
13
|
+
"version": "3.66.1",
|
|
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.
|
|
3
|
+
"version": "3.66.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
|
"author": {
|
|
6
6
|
"name": "sdsrss"
|
package/format-utils.mjs
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { DAY_MS } from './lib/time-constants.mjs';
|
|
1
2
|
// claude-mem-lite: String formatting and display utilities
|
|
2
3
|
// Extracted from utils.mjs for focused responsibility
|
|
3
4
|
|
|
@@ -231,7 +232,7 @@ export function isoWeekKey(epochMs) {
|
|
|
231
232
|
const tmp = new Date(Date.UTC(d.getUTCFullYear(), d.getUTCMonth(), d.getUTCDate()));
|
|
232
233
|
tmp.setUTCDate(tmp.getUTCDate() + 4 - (tmp.getUTCDay() || 7));
|
|
233
234
|
const yearStart = new Date(Date.UTC(tmp.getUTCFullYear(), 0, 1));
|
|
234
|
-
const weekNum = Math.ceil(((tmp - yearStart) /
|
|
235
|
+
const weekNum = Math.ceil(((tmp - yearStart) / DAY_MS + 1) / 7);
|
|
235
236
|
const isoYear = tmp.getUTCFullYear();
|
|
236
237
|
return `${isoYear}-W${String(weekNum).padStart(2, '0')}`;
|
|
237
238
|
}
|
package/haiku-client.mjs
CHANGED
|
@@ -100,6 +100,28 @@ const MODEL_MAP = {
|
|
|
100
100
|
// A call that genuinely needs sampling can pass opts.temperature to override.
|
|
101
101
|
const DEFAULT_LLM_TEMPERATURE = 0;
|
|
102
102
|
|
|
103
|
+
/**
|
|
104
|
+
* Timeout budget for BACKGROUND LLM work (detached enrich/optimize/summary
|
|
105
|
+
* workers, registry indexing) — the calls with no latency budget at all.
|
|
106
|
+
*
|
|
107
|
+
* Every dispatcher below degrades to `claude -p` when the keyed provider fails,
|
|
108
|
+
* and the CLI leg pays a full Claude Code boot before inference: measured
|
|
109
|
+
* 8.1s / 9.2s / 11.7s / 13.4s on an idle machine for a 400-token JSON reply
|
|
110
|
+
* (2026-08-16), against API-leg latencies under 2s. Callers that sized their
|
|
111
|
+
* timeout for the API leg (15–20s) were therefore killing the fallback
|
|
112
|
+
* mid-flight — save-enrich's 15s budget left 1.6s of headroom over the worst
|
|
113
|
+
* sample, which is how 6/57 (10.5%) of instrumented runs landed on
|
|
114
|
+
* reason:'llm-null' and why manual saves stopped getting search_aliases.
|
|
115
|
+
*
|
|
116
|
+
* Deliberately NOT applied as a floor inside callModelCLI / callHaikuCLI /
|
|
117
|
+
* callModelCLIAsync: those are also reached from latency-bound callers — the
|
|
118
|
+
* lesson bridge's 2.5s fail-open budget on the PreToolUse hook, and deep-search
|
|
119
|
+
* rerank on the MCP request path — where failing fast beats blocking a user for
|
|
120
|
+
* 45s. The allowance is caller-side policy, not a clamp.
|
|
121
|
+
* Pinned both ways by `tests/llm-timeout-budget.test.mjs`.
|
|
122
|
+
*/
|
|
123
|
+
export const BG_LLM_TIMEOUT_MS = 45000;
|
|
124
|
+
|
|
103
125
|
/**
|
|
104
126
|
* Resolve the LLM model to use for background calls.
|
|
105
127
|
* Reads CLAUDE_MEM_MODEL env var, defaults to 'haiku'.
|
|
@@ -409,11 +431,20 @@ async function callModelAPI(prompt, model, { timeout, maxTokens, temperature = D
|
|
|
409
431
|
function callModelCLI(prompt, model, { timeout }) {
|
|
410
432
|
const modelName = MODEL_MAP[model] ? model : 'haiku';
|
|
411
433
|
try {
|
|
412
|
-
|
|
434
|
+
// --no-session-persistence + DISABLE_CLAUDEMD_HOOKS (2026-08-16): these
|
|
435
|
+
// headless calls were paying the full interactive-session tax — 1,004
|
|
436
|
+
// transcripts piled up in ~/.claude/projects/-tmp/, and every spawn ran
|
|
437
|
+
// the claudemd plugin's whole hook fan-out (its SessionStart banner alone
|
|
438
|
+
// logged 682 rows in 3 days, drowning that project's telemetry). The
|
|
439
|
+
// persistence flag is OAuth-safe (probed); `--bare`/CLAUDE_CODE_SIMPLE are
|
|
440
|
+
// NOT (they hard-require ANTHROPIC_API_KEY — "Not logged in" on OAuth
|
|
441
|
+
// machines). The user's global CLAUDE.md injection has no OAuth-safe
|
|
442
|
+
// opt-out; accepted (haiku + prompt caching keeps it cheap).
|
|
443
|
+
const result = execFileSync(getClaudePath(), ['-p', '--model', modelName, '--no-session-persistence'], {
|
|
413
444
|
input: flattenForCLI(prompt),
|
|
414
445
|
timeout,
|
|
415
446
|
encoding: 'utf8',
|
|
416
|
-
env: { ...process.env, CLAUDE_MEM_HOOK_RUNNING: '1' },
|
|
447
|
+
env: { ...process.env, CLAUDE_MEM_HOOK_RUNNING: '1', DISABLE_CLAUDEMD_HOOKS: '1' },
|
|
417
448
|
stdio: ['pipe', 'pipe', 'pipe'],
|
|
418
449
|
cwd: '/tmp',
|
|
419
450
|
});
|
|
@@ -453,8 +484,9 @@ export function callModelCLIAsync(prompt, model, { timeout }) {
|
|
|
453
484
|
const modelName = MODEL_MAP[model] ? model : 'haiku';
|
|
454
485
|
let child;
|
|
455
486
|
try {
|
|
456
|
-
|
|
457
|
-
|
|
487
|
+
// Same headless-tax flags as callModelCLI (rationale there).
|
|
488
|
+
child = spawn(getClaudePath(), ['-p', '--model', modelName, '--no-session-persistence'], {
|
|
489
|
+
env: { ...process.env, CLAUDE_MEM_HOOK_RUNNING: '1', DISABLE_CLAUDEMD_HOOKS: '1' },
|
|
458
490
|
cwd: '/tmp',
|
|
459
491
|
stdio: ['pipe', 'pipe', 'pipe'],
|
|
460
492
|
});
|
|
@@ -603,11 +635,12 @@ async function callOpenRouterAPI(prompt, tier, { timeout, maxTokens, temperature
|
|
|
603
635
|
function callHaikuCLI(prompt, { timeout }) {
|
|
604
636
|
const { cli: modelName } = resolveModel();
|
|
605
637
|
try {
|
|
606
|
-
|
|
638
|
+
// Same headless-tax flags as callModelCLI (rationale there).
|
|
639
|
+
const result = execFileSync(getClaudePath(), ['-p', '--model', modelName, '--no-session-persistence'], {
|
|
607
640
|
input: flattenForCLI(prompt),
|
|
608
641
|
timeout,
|
|
609
642
|
encoding: 'utf8',
|
|
610
|
-
env: { ...process.env, CLAUDE_MEM_HOOK_RUNNING: '1' },
|
|
643
|
+
env: { ...process.env, CLAUDE_MEM_HOOK_RUNNING: '1', DISABLE_CLAUDEMD_HOOKS: '1' },
|
|
611
644
|
stdio: ['pipe', 'pipe', 'pipe'],
|
|
612
645
|
cwd: '/tmp', // Prevent ghost sessions in user's /resume list
|
|
613
646
|
});
|
package/hook-context.mjs
CHANGED
|
@@ -17,6 +17,7 @@ import { extractUnfinishedSummary } from './hook-handoff.mjs';
|
|
|
17
17
|
import { recentInjectableEvents, renderInjectableEvent } from './lib/events-injection.mjs';
|
|
18
18
|
import { liveObsFilterSql } from './lib/inject-search-core.mjs';
|
|
19
19
|
|
|
20
|
+
import { DAY_MS } from './lib/time-constants.mjs';
|
|
20
21
|
/**
|
|
21
22
|
* Infer the project directory from environment variables or cwd.
|
|
22
23
|
* @returns {string} Absolute path to the project directory
|
|
@@ -43,7 +44,7 @@ function mdCell(s) {
|
|
|
43
44
|
}
|
|
44
45
|
|
|
45
46
|
export function computeAdaptiveWindows(db, project) {
|
|
46
|
-
const sevenDaysAgo = Date.now() - 7 *
|
|
47
|
+
const sevenDaysAgo = Date.now() - 7 * DAY_MS;
|
|
47
48
|
const row = db.prepare(`
|
|
48
49
|
SELECT COUNT(*) as c FROM observations
|
|
49
50
|
WHERE project = ? AND created_at_epoch > ? AND COALESCE(compressed_into, 0) = 0
|
|
@@ -52,13 +53,13 @@ export function computeAdaptiveWindows(db, project) {
|
|
|
52
53
|
|
|
53
54
|
if (velocity > 10) {
|
|
54
55
|
// High velocity: tighter windows, focus on very recent
|
|
55
|
-
return { tier1: 12 * 3600000, tier2: 3 *
|
|
56
|
+
return { tier1: 12 * 3600000, tier2: 3 * DAY_MS, tier3: 14 * DAY_MS, sessWindow: 3 * DAY_MS };
|
|
56
57
|
} else if (velocity >= 3) {
|
|
57
58
|
// Medium velocity: default windows
|
|
58
|
-
return { tier1: 24 * 3600000, tier2: 7 *
|
|
59
|
+
return { tier1: 24 * 3600000, tier2: 7 * DAY_MS, tier3: 30 * DAY_MS, sessWindow: 7 * DAY_MS };
|
|
59
60
|
} else {
|
|
60
61
|
// Low velocity: wider windows, older data still relevant
|
|
61
|
-
return { tier1: 48 * 3600000, tier2: 14 *
|
|
62
|
+
return { tier1: 48 * 3600000, tier2: 14 * DAY_MS, tier3: 60 * DAY_MS, sessWindow: 14 * DAY_MS };
|
|
62
63
|
}
|
|
63
64
|
}
|
|
64
65
|
|
package/hook-llm.mjs
CHANGED
|
@@ -10,6 +10,7 @@ import {
|
|
|
10
10
|
getCurrentBranch, notLowSignalTitleClause,
|
|
11
11
|
} from './utils.mjs';
|
|
12
12
|
import { acquireLLMSlot, releaseLLMSlot } from './hook-semaphore.mjs';
|
|
13
|
+
import { BG_LLM_TIMEOUT_MS } from './haiku-client.mjs';
|
|
13
14
|
import { scrubRecord } from './lib/scrub-record.mjs';
|
|
14
15
|
import { getVocabulary, computeVector, vecTextForRow } from './tfidf.mjs';
|
|
15
16
|
import { insertObservationRow, insertObservationFiles, insertObservationVector, normalizeScope } from './lib/observation-write.mjs';
|
|
@@ -23,6 +24,7 @@ import { isNoiseObservation, capNoiseImportance, isLowYieldChangeObs } from './l
|
|
|
23
24
|
import { episodeHasSignificantContent } from './hook-episode.mjs';
|
|
24
25
|
import { OBS_TYPE_SET } from './lib/obs-types.mjs';
|
|
25
26
|
|
|
27
|
+
import { DAY_MS } from './lib/time-constants.mjs';
|
|
26
28
|
// T9: memdir-incompatible types live in the `events` table, not `observations`.
|
|
27
29
|
// Set lookup is O(1) — authoritative source is lib/activity.mjs::EVENT_TYPES.
|
|
28
30
|
const EVENT_TYPE_SET = new Set(EVENT_TYPES);
|
|
@@ -84,7 +86,7 @@ export function recordRetryAttempt(db, recovered, bucket = dateBucketUtc()) {
|
|
|
84
86
|
* YYYY-MM-DD lexicographic order).
|
|
85
87
|
*/
|
|
86
88
|
export function readRetryStats(db, days = 30) {
|
|
87
|
-
const cutoff = new Date(Date.now() - days *
|
|
89
|
+
const cutoff = new Date(Date.now() - days * DAY_MS);
|
|
88
90
|
return db.prepare(
|
|
89
91
|
`SELECT date_bucket, attempts, recovered FROM lesson_retry_stats
|
|
90
92
|
WHERE date_bucket >= ? ORDER BY date_bucket DESC`
|
|
@@ -205,8 +207,8 @@ export function saveObservation(obs, projectOverride, sessionIdOverride, externa
|
|
|
205
207
|
// 3-day Jaccard catches near-duplicates without blocking legitimately new observations
|
|
206
208
|
const LOW_SIGNAL = LOW_SIGNAL_TITLE;
|
|
207
209
|
if (obs.title && LOW_SIGNAL.test(obs.title)) {
|
|
208
|
-
const sevenDaysAgo = now.getTime() - 7 *
|
|
209
|
-
const threeDaysAgo = now.getTime() - 3 *
|
|
210
|
+
const sevenDaysAgo = now.getTime() - 7 * DAY_MS;
|
|
211
|
+
const threeDaysAgo = now.getTime() - 3 * DAY_MS;
|
|
210
212
|
// Phase 1: exact title match within 7 days
|
|
211
213
|
const exactDup = db.prepare(`
|
|
212
214
|
SELECT 1 FROM observations
|
|
@@ -856,7 +858,7 @@ ${actionList}`;
|
|
|
856
858
|
const retrySlot = await acquireLLMSlot();
|
|
857
859
|
try {
|
|
858
860
|
const retryPrompt = buildLessonRetryPrompt(episode, parsed);
|
|
859
|
-
const retryRaw = retrySlot ? await callLLM(retryPrompt,
|
|
861
|
+
const retryRaw = retrySlot ? await callLLM(retryPrompt, BG_LLM_TIMEOUT_MS) : null;
|
|
860
862
|
if (retryRaw) {
|
|
861
863
|
const retry = parseJsonFromLLM(retryRaw);
|
|
862
864
|
const retryLesson = typeof retry?.lesson === 'string' ? retry.lesson.trim() : '';
|
|
@@ -1135,7 +1137,7 @@ ${obsList}`;
|
|
|
1135
1137
|
|
|
1136
1138
|
let raw, llmParsed;
|
|
1137
1139
|
try {
|
|
1138
|
-
raw = await callLLM(prompt,
|
|
1140
|
+
raw = await callLLM(prompt, BG_LLM_TIMEOUT_MS);
|
|
1139
1141
|
llmParsed = parseJsonFromLLM(raw);
|
|
1140
1142
|
} finally {
|
|
1141
1143
|
releaseLLMSlot();
|
package/hook-memory.mjs
CHANGED
|
@@ -9,8 +9,9 @@ import { DB_DIR } from './schema.mjs';
|
|
|
9
9
|
import { extractIdents } from './lib/lesson-idents.mjs';
|
|
10
10
|
import { formatSubagentContext } from './lib/task-imperative.mjs';
|
|
11
11
|
|
|
12
|
+
import { DAY_MS } from './lib/time-constants.mjs';
|
|
12
13
|
const MAX_MEMORY_INJECTIONS = 3;
|
|
13
|
-
const MEMORY_LOOKBACK_MS = 60 *
|
|
14
|
+
const MEMORY_LOOKBACK_MS = 60 * DAY_MS; // 60 days
|
|
14
15
|
// Aligned with TYPE_QUALITY_CASE in scoring-sql.mjs (R2 rebalance).
|
|
15
16
|
// Weights calibrated to empirical avg access_count:
|
|
16
17
|
// decision 6.05, discovery 3.32, bugfix 2.24, feature 2.04, change 0.93, refactor 0.54.
|
|
@@ -98,7 +99,7 @@ function candidateCoverage(row, queryTerms) {
|
|
|
98
99
|
return hits / queryTerms.length;
|
|
99
100
|
}
|
|
100
101
|
|
|
101
|
-
const FILE_RECALL_LOOKBACK_MS = 60 *
|
|
102
|
+
const FILE_RECALL_LOOKBACK_MS = 60 * DAY_MS; // 60 days
|
|
102
103
|
const MAX_FILE_RECALL = 2;
|
|
103
104
|
|
|
104
105
|
// P1: stale-obs verify-before-use threshold. An injected obs older than this
|
|
@@ -107,7 +108,7 @@ const MAX_FILE_RECALL = 2;
|
|
|
107
108
|
// renamed since capture. Pure-decision/architecture obs (no file_paths)
|
|
108
109
|
// don't get the hint: their drift is text-only and Claude already verifies
|
|
109
110
|
// at consumption time per the project mem-usage contract.
|
|
110
|
-
const STALE_OBS_THRESHOLD_MS = 30 *
|
|
111
|
+
const STALE_OBS_THRESHOLD_MS = 30 * DAY_MS;
|
|
111
112
|
|
|
112
113
|
/**
|
|
113
114
|
* Format a single line for the <memory-context> block emitted by
|
package/hook-optimize.mjs
CHANGED
|
@@ -13,7 +13,7 @@ import {
|
|
|
13
13
|
computeMinHash, estimateJaccardFromMinHash, jaccardSimilarity, clampImportance, cjkBigrams,
|
|
14
14
|
notLowSignalTitleClause, scrubSecrets,
|
|
15
15
|
} from './utils.mjs';
|
|
16
|
-
import { callModelJSON } from './haiku-client.mjs';
|
|
16
|
+
import { callModelJSON, BG_LLM_TIMEOUT_MS } from './haiku-client.mjs';
|
|
17
17
|
import { acquireLLMSlot, releaseLLMSlot } from './hook-semaphore.mjs';
|
|
18
18
|
import { scrubRecord } from './lib/scrub-record.mjs';
|
|
19
19
|
import { getVocabulary, computeVector, cosineSimilarity, vecTextForRow } from './tfidf.mjs';
|
|
@@ -22,6 +22,7 @@ import { DB_DIR } from './schema.mjs';
|
|
|
22
22
|
import { OBS_TYPE_SET } from './lib/obs-types.mjs';
|
|
23
23
|
import { liveObsFilterSql } from './lib/inject-search-core.mjs';
|
|
24
24
|
|
|
25
|
+
import { DAY_MS } from './lib/time-constants.mjs';
|
|
25
26
|
const RUNTIME_DIR = join(DB_DIR, 'runtime');
|
|
26
27
|
|
|
27
28
|
// ─── Budget ─────────────────────────────────────────────────────────────────
|
|
@@ -174,7 +175,7 @@ Narrative: ${truncate(cand.narrative || '(no narrative)', 500)}
|
|
|
174
175
|
|
|
175
176
|
JSON: {"search_aliases":["alt phrasing","synonym","spelled-out jargon","CJK term if the domain word has one"]}
|
|
176
177
|
Give 3-6 aliases: words a user might search for the SAME concept but that are NOT already in the title (synonyms, the spelled-out form of an acronym, the jargon term for a described symptom, a CJK translation of a key domain term).`;
|
|
177
|
-
const parsed = await callModelJSON(aliasPrompt, 'haiku', { timeout:
|
|
178
|
+
const parsed = await callModelJSON(aliasPrompt, 'haiku', { timeout: BG_LLM_TIMEOUT_MS, maxTokens: 300 });
|
|
178
179
|
const aliasArr = parsed && Array.isArray(parsed.search_aliases)
|
|
179
180
|
? parsed.search_aliases.filter((a) => typeof a === 'string' && a.trim().length > 0)
|
|
180
181
|
: [];
|
|
@@ -203,7 +204,7 @@ importance: 0=no value, 1=routine, 2=notable non-obvious insight, 3=critical. De
|
|
|
203
204
|
lesson_learned: State what was learned. If routine, write "none".
|
|
204
205
|
search_aliases: 2-6 alternative search terms (include CJK if applicable).`;
|
|
205
206
|
|
|
206
|
-
const parsed = await callModelJSON(prompt, 'haiku', { timeout:
|
|
207
|
+
const parsed = await callModelJSON(prompt, 'haiku', { timeout: BG_LLM_TIMEOUT_MS, maxTokens: 500 });
|
|
207
208
|
if (!parsed || !parsed.title) { skipped++; continue; }
|
|
208
209
|
|
|
209
210
|
// Auto-hide on importance:0 targets fully-degraded NARROW rows (this branch predates
|
|
@@ -288,7 +289,7 @@ search_aliases: 2-6 alternative search terms (include CJK if applicable).`;
|
|
|
288
289
|
// ─── Task 2: Normalize ─────────────────────────────────────────────────────
|
|
289
290
|
|
|
290
291
|
const NORMALIZE_GATE_FILE = join(RUNTIME_DIR, 'last-normalize.json');
|
|
291
|
-
const NORMALIZE_INTERVAL_MS = 7 *
|
|
292
|
+
const NORMALIZE_INTERVAL_MS = 7 * DAY_MS; // 7 days
|
|
292
293
|
|
|
293
294
|
// Pure gate decision (no IO) — exported for testing. Fail-OPEN on a
|
|
294
295
|
// malformed-but-valid-JSON gate: a missing/non-numeric `epoch` makes
|
|
@@ -355,7 +356,7 @@ Rules:
|
|
|
355
356
|
- Include CJK ↔ English equivalents if present
|
|
356
357
|
- Skip terms that have no synonyms in the list`;
|
|
357
358
|
|
|
358
|
-
const parsed = await callModelJSON(prompt, 'sonnet', { timeout:
|
|
359
|
+
const parsed = await callModelJSON(prompt, 'sonnet', { timeout: BG_LLM_TIMEOUT_MS, maxTokens: 1000 });
|
|
359
360
|
if (!parsed?.groups || !Array.isArray(parsed.groups)) return [];
|
|
360
361
|
return parsed.groups.filter(g => g.canonical && Array.isArray(g.aliases) && g.aliases.length > 0);
|
|
361
362
|
} catch (e) {
|
|
@@ -447,7 +448,7 @@ export async function executeNormalize(db, force = false, { project } = {}) {
|
|
|
447
448
|
|
|
448
449
|
// ─── Task 3: Cluster-merge ─────────────────────────────────────────────────
|
|
449
450
|
|
|
450
|
-
const MERGE_TIME_WINDOW_MS = 30 *
|
|
451
|
+
const MERGE_TIME_WINDOW_MS = 30 * DAY_MS;
|
|
451
452
|
// Merge-review band [MERGE_JACCARD_LOW, AUTO_MERGE_THRESHOLD): titles in this
|
|
452
453
|
// Jaccard range are LLM-reviewed for merge; at/above AUTO_MERGE_THRESHOLD they'd
|
|
453
454
|
// already auto-merge elsewhere, below MERGE_JACCARD_LOW they're too dissimilar.
|
|
@@ -523,7 +524,7 @@ Return ONLY valid JSON:
|
|
|
523
524
|
- If they should NOT be merged: {"should_merge":false}
|
|
524
525
|
- If they SHOULD be merged: {"should_merge":true,"merged_title":"≤120 char comprehensive title","merged_narrative":"comprehensive ≤800 char summary preserving all key details","merged_concepts":["kw1","kw2"],"merged_facts":["specific fact 1"],"merged_lesson":"synthesized non-obvious lesson or null","importance":2}`;
|
|
525
526
|
|
|
526
|
-
const parsed = await callModelJSON(prompt, 'sonnet', { timeout:
|
|
527
|
+
const parsed = await callModelJSON(prompt, 'sonnet', { timeout: BG_LLM_TIMEOUT_MS, maxTokens: 1000 });
|
|
527
528
|
if (!parsed || !parsed.should_merge) return { merged: false };
|
|
528
529
|
|
|
529
530
|
// Keeper = highest importance, then highest access_count. Previously access_count
|
|
@@ -641,11 +642,11 @@ export async function executeClusterMerge(db, maxClusters = 5, { project } = {})
|
|
|
641
642
|
|
|
642
643
|
// ─── Task 4: Smart-compress ────────────────────────────────────────────────
|
|
643
644
|
|
|
644
|
-
const COMPRESS_TIME_SPLIT_MS = 14 *
|
|
645
|
+
const COMPRESS_TIME_SPLIT_MS = 14 * DAY_MS;
|
|
645
646
|
const COMPRESS_COSINE_THRESHOLD = 0.3;
|
|
646
647
|
|
|
647
648
|
export function findSmartCompressCandidates(db, ageDays = 30, { project } = {}) {
|
|
648
|
-
const cutoff = Date.now() - ageDays *
|
|
649
|
+
const cutoff = Date.now() - ageDays * DAY_MS;
|
|
649
650
|
const projectClause = project ? 'AND project = ?' : '';
|
|
650
651
|
const stmt = db.prepare(`
|
|
651
652
|
SELECT id, title, narrative, lesson_learned, project, type, created_at_epoch
|
|
@@ -757,7 +758,7 @@ ${obsDescriptions}
|
|
|
757
758
|
|
|
758
759
|
JSON: {"title":"descriptive summary ≤120 chars","narrative":"comprehensive summary ≤800 chars preserving key decisions and lessons","concepts":["kw1","kw2"],"facts":["all specific facts preserved"],"lesson_learned":"most important synthesized lesson or 'none'","search_aliases":["alt search 1","alt search 2"]}`;
|
|
759
760
|
|
|
760
|
-
const parsed = await callModelJSON(prompt, 'sonnet', { timeout:
|
|
761
|
+
const parsed = await callModelJSON(prompt, 'sonnet', { timeout: BG_LLM_TIMEOUT_MS, maxTokens: 1000 });
|
|
761
762
|
if (!parsed || !parsed.title) return { compressed: false };
|
|
762
763
|
|
|
763
764
|
// Scrub BEFORE truncate (see re-enrich note): boundary cut on scrubbed text.
|
package/hook-precompact.mjs
CHANGED
|
@@ -5,17 +5,15 @@
|
|
|
5
5
|
// Differs from SessionStart-on-compact (which fires AFTER compaction):
|
|
6
6
|
// PreCompact ensures memory survives the compaction step itself.
|
|
7
7
|
|
|
8
|
-
import { writeFileSync } from 'fs';
|
|
9
|
-
import { join } from 'path';
|
|
10
8
|
import { buildSessionContextLines } from './hook-context.mjs';
|
|
11
9
|
import { inferProject, debugCatch, debugLog } from './utils.mjs';
|
|
12
10
|
import { RUNTIME_DIR } from './hook-shared.mjs';
|
|
13
|
-
import {
|
|
11
|
+
import { recordKeyContextInjection } from './lib/keyctx-marker.mjs';
|
|
14
12
|
|
|
15
13
|
/**
|
|
16
|
-
* Build + emit the memory context block on stdout.
|
|
17
|
-
*
|
|
18
|
-
*
|
|
14
|
+
* Build + emit the memory context block on stdout. Writes the Key Context ids
|
|
15
|
+
* the re-emitted block renders (refreshing handleUserPrompt's exclude-set — see
|
|
16
|
+
* D#123 in hook.mjs) and bumps injection_count on those rows (D#124).
|
|
19
17
|
*
|
|
20
18
|
* @param {object} ctx
|
|
21
19
|
* @param {import('better-sqlite3').Database} ctx.db
|
|
@@ -29,12 +27,14 @@ export function handlePreCompact({ db, project, sessionId }) {
|
|
|
29
27
|
const body = buildSessionContextLines(db, project, new Date(), sessionId || null, collector);
|
|
30
28
|
if (!body || String(body).trim() === '') return;
|
|
31
29
|
process.stdout.write(`<claude-mem-context>\n${body}\n</claude-mem-context>\n`);
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
30
|
+
// Same recorder as handleSessionStart: marker + injection_count bump (D#124).
|
|
31
|
+
// A re-render into a compacted context is a fresh injection of those rows.
|
|
32
|
+
recordKeyContextInjection(db, {
|
|
33
|
+
runtimeDir: RUNTIME_DIR,
|
|
34
|
+
project,
|
|
35
|
+
sessionId: sessionId || null,
|
|
36
|
+
ids: collector.keyContextIds || [],
|
|
37
|
+
});
|
|
38
38
|
} catch (e) {
|
|
39
39
|
debugCatch(e, 'handlePreCompact');
|
|
40
40
|
}
|
package/hook-shared.mjs
CHANGED
|
@@ -10,7 +10,7 @@ import { ensureDbWithWalRecovery, DB_DIR } from './schema.mjs';
|
|
|
10
10
|
// Pure-`node:`/local module (it imports only binding-probe + native-binding-hint, and
|
|
11
11
|
// neither imports this file) — no cycle.
|
|
12
12
|
import { recordHookError } from './lib/hook-telemetry.mjs';
|
|
13
|
-
import { getClaudePath as getClaudePathShared, resolveModel as resolveModelShared, flattenForCLI as _flattenForCLI, detectMode as detectLLMMode, callHaiku } from './haiku-client.mjs';
|
|
13
|
+
import { getClaudePath as getClaudePathShared, resolveModel as resolveModelShared, flattenForCLI as _flattenForCLI, detectMode as detectLLMMode, callHaiku, BG_LLM_TIMEOUT_MS } from './haiku-client.mjs';
|
|
14
14
|
// Phase D: invited-memory sentinel detection. memdir.mjs/claudemd.mjs only pull in
|
|
15
15
|
// fs/path/os/crypto; adopt-content.mjs is pure strings. No circular deps —
|
|
16
16
|
// neither imports hook-shared.
|
|
@@ -18,6 +18,7 @@ import { memdirPath as _memdirPath, isAdopted as _isAdoptedMemdir } from './memd
|
|
|
18
18
|
import { isAdopted as _isAdoptedClaudeMd } from './claudemd.mjs';
|
|
19
19
|
import { PLUGIN_SLUG as _PLUGIN_SLUG } from './adopt-content.mjs';
|
|
20
20
|
|
|
21
|
+
import { DAY_MS } from './lib/time-constants.mjs';
|
|
21
22
|
// ─── Constants ────────────────────────────────────────────────────────────────
|
|
22
23
|
|
|
23
24
|
export const RUNTIME_DIR = join(DB_DIR, 'runtime');
|
|
@@ -30,7 +31,7 @@ export const SESSION_EXPIRY_MS = 12 * 60 * 60 * 1000; // 12h
|
|
|
30
31
|
export const STALE_SESSION_MS = 24 * 60 * 60 * 1000; // 24h
|
|
31
32
|
export const STALE_LOCK_MS = 30000; // 30s
|
|
32
33
|
export const DEDUP_WINDOW_MS = 5 * 60 * 1000; // 5 min (title dedup)
|
|
33
|
-
export const RELATED_OBS_WINDOW_MS = 7 *
|
|
34
|
+
export const RELATED_OBS_WINDOW_MS = 7 * DAY_MS; // 7 days
|
|
34
35
|
export const FALLBACK_OBS_WINDOW_MS = RELATED_OBS_WINDOW_MS; // same window
|
|
35
36
|
// Candidate rows the SessionStart Key Context surface considers (hook-context.mjs
|
|
36
37
|
// keyObs; each of the two sections then renders at most 5). The user-prompt
|
|
@@ -123,6 +124,120 @@ export function sweepOrphanEpisodeFiles(runtimeDir, { ageMs = ORPHAN_EPISODE_AGE
|
|
|
123
124
|
return count;
|
|
124
125
|
}
|
|
125
126
|
|
|
127
|
+
// ─── Per-project marker GC (P2-15) ───────────────────────────────────────────
|
|
128
|
+
// RUNTIME_DIR had three sweeps and a hole. Per-SESSION files age out at 24h
|
|
129
|
+
// (hook.mjs) and orphaned episode/read trackers at 1h/24h (above), but the
|
|
130
|
+
// per-PROJECT markers — one file per project, written once, never revisited —
|
|
131
|
+
// had no reclamation path at all. A live install on 2026-08-16 held 253 files,
|
|
132
|
+
// 152 of them past 30 days, including entire families for test sandboxes
|
|
133
|
+
// deleted months earlier (session-tmp--sdscc-e2e-*, cite-recall-scratchpad--
|
|
134
|
+
// fixture-*) and a .skill-reco-cooldown-* family that nothing had ever swept.
|
|
135
|
+
//
|
|
136
|
+
// Deliberately a NAMED list rather than a wildcard: these markers share a shape
|
|
137
|
+
// but not a meaning. The GC-able ones are caches — delete them and the next
|
|
138
|
+
// session re-derives the state (or, for a cooldown, merely allows a suggestion
|
|
139
|
+
// sooner). The preserved ones are records of a side effect already performed;
|
|
140
|
+
// removing them re-arms it (.auto-adopt-* re-attempts a write into the user's
|
|
141
|
+
// project CLAUDE.md, the migration sentinels re-run their one-time work), which
|
|
142
|
+
// is a bad trade for the 13-45 bytes each occupies.
|
|
143
|
+
export const STALE_PROJECT_MARKER_AGE_MS = 30 * 24 * 60 * 60 * 1000;
|
|
144
|
+
|
|
145
|
+
// Regenerated on demand; safe to lose at any time.
|
|
146
|
+
export const GC_PROJECT_MARKER_PREFIXES = Object.freeze([
|
|
147
|
+
'session-', // project → memory-session-id pointer
|
|
148
|
+
'cite-recall-', // last session's cite-recall snapshot (nudge input)
|
|
149
|
+
'.skill-cooldown-', // suggestion throttle timestamp
|
|
150
|
+
'.skill-reco-cooldown-', // recommendation throttle timestamp
|
|
151
|
+
]);
|
|
152
|
+
|
|
153
|
+
// Records of a completed side effect — never age out. `ep-`/`ep-flush-`/
|
|
154
|
+
// `pending-`/`reads-` are absent from BOTH lists on purpose: the first holds
|
|
155
|
+
// unflushed observations (data, not cache) and the rest already belong to
|
|
156
|
+
// sweepOrphanEpisodeFiles on tighter cutoffs.
|
|
157
|
+
export const GC_PRESERVED_MARKER_PREFIXES = Object.freeze([
|
|
158
|
+
'.auto-adopt-',
|
|
159
|
+
'.deferred-block-migrated-',
|
|
160
|
+
'.legacy-claude-md-cleaned-',
|
|
161
|
+
// v3.66.1: these two shipped in the GC list for one release and had to come
|
|
162
|
+
// out. Both are version-keyed one-shot migration sentinels written by
|
|
163
|
+
// scripts/setup.sh, and their gate is `! -f <marker>` — deleting one re-runs
|
|
164
|
+
// its migration. `.mcp-dedup-v2.78` gates a block that removes
|
|
165
|
+
// mcpServers.mem / mcpServers["mem-lite"] from the user's ~/.claude.json with
|
|
166
|
+
// a raw writeFileSync (no tmp+rename, no backup), which the repo's own test
|
|
167
|
+
// documents as intentionally one-shot: "If a user later runs `claude mcp add
|
|
168
|
+
// mem ...` themselves, the gate intentionally lets it stand." A 30-day sweep
|
|
169
|
+
// turned that into a recurring purge of a config file we do not own.
|
|
170
|
+
// The mtime never refreshes (the gate skips the block once the file exists),
|
|
171
|
+
// so every install older than 30 days would have lost it on the first
|
|
172
|
+
// SessionStart after upgrading.
|
|
173
|
+
//
|
|
174
|
+
// Why it was missed: the search for writers used `grep --include=*.mjs
|
|
175
|
+
// --include=*.js`, and the writer is a SHELL script. `sentinelPrefixesFromShell`
|
|
176
|
+
// below now derives this class from scripts/*.sh instead of from memory.
|
|
177
|
+
'.mcp-dedup-',
|
|
178
|
+
'.residue-warned-',
|
|
179
|
+
]);
|
|
180
|
+
|
|
181
|
+
/**
|
|
182
|
+
* Marker-name prefixes that scripts/*.sh treats as one-shot sentinels, derived
|
|
183
|
+
* from the shell source rather than restated here. `tests/runtime-marker-gc`
|
|
184
|
+
* asserts none of them is GC-able: a shell-written sentinel is invisible to a
|
|
185
|
+
* JS-only grep, which is exactly how `.mcp-dedup-` reached the GC list.
|
|
186
|
+
*
|
|
187
|
+
* @param {string} shellSource concatenated contents of scripts/*.sh
|
|
188
|
+
* @returns {string[]} prefixes like `.mcp-dedup-`
|
|
189
|
+
*/
|
|
190
|
+
export function sentinelPrefixesFromShell(shellSource) {
|
|
191
|
+
const out = new Set();
|
|
192
|
+
// Matches `"$DATA_DIR/runtime/.mcp-dedup-v2.78"` and friends: a dotfile under
|
|
193
|
+
// runtime/ whose name carries a version-ish suffix.
|
|
194
|
+
for (const m of String(shellSource || '').matchAll(/runtime\/(\.[a-z0-9-]*?-)v?[0-9][0-9.]*/gi)) {
|
|
195
|
+
out.add(m[1]);
|
|
196
|
+
}
|
|
197
|
+
return [...out];
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
/**
|
|
201
|
+
* Sweep per-project runtime markers older than `ageMs`. fs-only, best-effort,
|
|
202
|
+
* never throws. Returns the number of files removed.
|
|
203
|
+
*
|
|
204
|
+
* The two prefix lists are injectable ONLY so the precedence rule below can be
|
|
205
|
+
* exercised: with the shipped lists they are disjoint, which makes the
|
|
206
|
+
* preserved check redundant today and load-bearing the moment a future family
|
|
207
|
+
* nests inside a GC-able one. Production callers pass neither.
|
|
208
|
+
*
|
|
209
|
+
* @param {string} runtimeDir
|
|
210
|
+
* @param {{ageMs?: number, now?: number, gcPrefixes?: string[], preservedPrefixes?: string[]}} [opts]
|
|
211
|
+
* @returns {number}
|
|
212
|
+
*/
|
|
213
|
+
export function sweepStaleProjectMarkers(runtimeDir, {
|
|
214
|
+
ageMs = STALE_PROJECT_MARKER_AGE_MS,
|
|
215
|
+
now = Date.now(),
|
|
216
|
+
gcPrefixes = GC_PROJECT_MARKER_PREFIXES,
|
|
217
|
+
preservedPrefixes = GC_PRESERVED_MARKER_PREFIXES,
|
|
218
|
+
env = process.env,
|
|
219
|
+
} = {}) {
|
|
220
|
+
// Kill switch (naming mirrors SKIP_COMPRESS / SKIP_OPTIMIZE / SKIP_SAVE_ENRICH):
|
|
221
|
+
// this is the only sweep that deletes files a user might want to inspect, so a
|
|
222
|
+
// released default that reclaims state needs a documented way back out.
|
|
223
|
+
if (env.CLAUDE_MEM_SKIP_MARKER_GC === '1') return 0;
|
|
224
|
+
let entries;
|
|
225
|
+
try { entries = readdirSync(runtimeDir); } catch { return 0; }
|
|
226
|
+
const cutoff = now - ageMs;
|
|
227
|
+
let count = 0;
|
|
228
|
+
for (const f of entries) {
|
|
229
|
+
// Preserved wins on any overlap, so a future prefix added to both lists
|
|
230
|
+
// fails safe (kept) instead of deleting a side-effect record.
|
|
231
|
+
if (preservedPrefixes.some((p) => f.startsWith(p))) continue;
|
|
232
|
+
if (!gcPrefixes.some((p) => f.startsWith(p))) continue;
|
|
233
|
+
const full = join(runtimeDir, f);
|
|
234
|
+
try {
|
|
235
|
+
if (statSync(full).mtimeMs < cutoff) { unlinkSync(full); count++; }
|
|
236
|
+
} catch { /* concurrent unlink / permission / directory — ignore */ }
|
|
237
|
+
}
|
|
238
|
+
return count;
|
|
239
|
+
}
|
|
240
|
+
|
|
126
241
|
// Ensure runtime directory exists AND is owner-only (0700), matching the DB dir
|
|
127
242
|
// (schema.mjs). Runtime aux files carry captured file paths + scrubbed activity; on a
|
|
128
243
|
// shared host a 0755 dir would let another local user read them. hardenRuntimeFiles()
|
|
@@ -195,7 +310,7 @@ export function openDb() {
|
|
|
195
310
|
// response string (callers run parseJsonFromLLM themselves) or null.
|
|
196
311
|
// maxTokens is sized for session-summary / episode JSON (larger than the
|
|
197
312
|
// registry/optimize callers' budgets).
|
|
198
|
-
export async function callLLM(prompt, timeoutMs =
|
|
313
|
+
export async function callLLM(prompt, timeoutMs = BG_LLM_TIMEOUT_MS) {
|
|
199
314
|
if (detectLLMMode() !== 'cli') {
|
|
200
315
|
const result = await callHaiku(prompt, { timeout: timeoutMs, maxTokens: 2000 });
|
|
201
316
|
return result?.text ?? null;
|
|
@@ -203,11 +318,13 @@ export async function callLLM(prompt, timeoutMs = 15000) {
|
|
|
203
318
|
|
|
204
319
|
const { cli: modelName } = resolveModelShared();
|
|
205
320
|
try {
|
|
206
|
-
|
|
321
|
+
// Same headless-tax flags as haiku-client.mjs#callModelCLI (rationale
|
|
322
|
+
// there): no transcript persistence, no claudemd hook fan-out.
|
|
323
|
+
const result = execFileSync(getClaudePathShared(), ['-p', '--model', modelName, '--no-session-persistence'], {
|
|
207
324
|
input: _flattenForCLI(prompt),
|
|
208
325
|
timeout: timeoutMs,
|
|
209
326
|
encoding: 'utf8',
|
|
210
|
-
env: { ...process.env, CLAUDE_MEM_HOOK_RUNNING: '1' },
|
|
327
|
+
env: { ...process.env, CLAUDE_MEM_HOOK_RUNNING: '1', DISABLE_CLAUDEMD_HOOKS: '1' },
|
|
211
328
|
stdio: ['pipe', 'pipe', 'pipe'],
|
|
212
329
|
cwd: '/tmp', // Prevent ghost sessions in user's /resume list
|
|
213
330
|
});
|