claude-mem-lite 4.0.4 → 5.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.claude-plugin/marketplace.json +1 -1
- package/.claude-plugin/plugin.json +1 -1
- package/README.md +18 -72
- package/README.zh-CN.md +24 -74
- package/adopt-content.mjs +0 -1
- package/cli/common.mjs +0 -17
- package/cli.mjs +19 -3
- package/commands/mem.md +3 -3
- package/commands/update.md +5 -12
- package/format-utils.mjs +0 -21
- package/haiku-client.mjs +36 -5
- package/hash-utils.mjs +21 -0
- package/hook-episode.mjs +7 -1
- package/hook-optimize.mjs +103 -50
- package/hook-shared.mjs +8 -2
- package/hook-update.mjs +56 -14
- package/hook.mjs +21 -29
- package/hooks/hooks.json +0 -10
- package/install.mjs +100 -678
- package/lib/atomic-write.mjs +16 -0
- package/lib/deferred-work.mjs +7 -3
- package/lib/doctor-drift.mjs +0 -1
- package/lib/frontmatter.mjs +1 -2
- package/lib/get-core.mjs +27 -1
- package/lib/hook-prune.mjs +133 -0
- package/lib/hook-stdin.mjs +1 -1
- package/lib/hook-telemetry.mjs +1 -1
- package/lib/install-shape.mjs +45 -4
- package/lib/maintain-core.mjs +9 -0
- package/lib/metrics.mjs +2 -2
- package/lib/proc-lock.mjs +111 -13
- package/lib/proxy-fetch.mjs +26 -2
- package/lib/scrub-record.mjs +8 -0
- package/lib/shard-gc.mjs +11 -7
- package/lib/tmp-fixture-sweep.mjs +9 -0
- package/mem-cli.mjs +46 -434
- package/nlp.mjs +1 -1
- package/npm-shrinkwrap.json +6 -2
- package/package.json +9 -16
- package/project-utils.mjs +43 -15
- package/schema.mjs +112 -50
- package/scripts/hook-launcher.mjs +9 -3
- package/scripts/prompt-search-utils.mjs +0 -30
- package/scripts/setup.sh +5 -0
- package/scripts/user-prompt-search.js +3 -125
- package/secret-scrub.mjs +32 -3
- package/server.mjs +19 -451
- package/source-files.mjs +12 -29
- package/synonyms.mjs +2 -1
- package/tool-schemas.mjs +23 -91
- package/utils.mjs +5 -13
- package/commands/tools.md +0 -67
- package/install-metadata.mjs +0 -2193
- package/lib/registry-core.mjs +0 -264
- package/registry/preinstalled.json +0 -2419
- package/registry-enricher.mjs +0 -124
- package/registry-github.mjs +0 -86
- package/registry-importer.mjs +0 -569
- package/registry-recommend.mjs +0 -503
- package/registry-retriever.mjs +0 -611
- package/registry-scanner.mjs +0 -261
- package/registry.mjs +0 -665
- package/resource-discovery.mjs +0 -199
- package/scripts/pre-skill-bridge.js +0 -146
package/commands/update.md
CHANGED
|
@@ -1,11 +1,11 @@
|
|
|
1
1
|
---
|
|
2
2
|
name: update
|
|
3
|
-
description: "Use when: search results seem noisy
|
|
3
|
+
description: "Use when: search results seem noisy or for periodic memory maintenance"
|
|
4
4
|
---
|
|
5
5
|
|
|
6
|
-
# Memory
|
|
6
|
+
# Memory Maintenance
|
|
7
7
|
|
|
8
|
-
Run intelligent maintenance on
|
|
8
|
+
Run intelligent maintenance on the memory database.
|
|
9
9
|
|
|
10
10
|
## Usage
|
|
11
11
|
|
|
@@ -22,17 +22,10 @@ When the user invokes `/mem:update`, perform the following maintenance cycle:
|
|
|
22
22
|
3. Call `mem_maintain(action="execute", operations=["cleanup","decay","boost"])` to apply safe automatic changes
|
|
23
23
|
4. If duplicates were found in scan, review them and call `mem_maintain(action="execute", operations=["dedup"], merge_ids=[[keepId, removeId1, ...], ...])` — keep the more important/recent observation in each pair
|
|
24
24
|
5. Run `mem_compress(preview=false)` for old low-value observations
|
|
25
|
-
6. **If pending purge items > 0**: Report the count to the user and ask for confirmation. If confirmed, call `mem_maintain(action="execute", operations=["purge_stale"])`. User may optionally specify `retain_days` (default 30) to control how many days of data to keep. Do NOT purge without explicit user confirmation.
|
|
25
|
+
6. **If pending purge items > 0**: Report the count to the user and ask for confirmation. If confirmed, call `mem_maintain(action="execute", operations=["purge_stale"], confirm=true)`. **`confirm=true` is required:** without it the call returns a dry-run preview and deletes nothing while still succeeding, so you would report a purge that never happened. User may optionally specify `retain_days` (default 30, allowed range 7–365) to control how many days of data to keep. Do NOT purge without explicit user confirmation.
|
|
26
26
|
|
|
27
|
-
### Phase 2:
|
|
28
|
-
|
|
29
|
-
1. Call `mem_registry(action="stats")` to get registry overview
|
|
30
|
-
2. Call `mem_registry(action="reindex")` to rebuild FTS5 search index
|
|
31
|
-
3. Report updated stats
|
|
32
|
-
|
|
33
|
-
### Phase 3: Summary
|
|
27
|
+
### Phase 2: Summary
|
|
34
28
|
|
|
35
29
|
Summarize all maintenance actions taken in zh-CN:
|
|
36
30
|
- Memory: observations cleaned, decayed, boosted, deduplicated, compressed
|
|
37
|
-
- Registry: total resources, adoption rates, reindex status
|
|
38
31
|
- Overall health assessment
|
package/format-utils.mjs
CHANGED
|
@@ -125,27 +125,6 @@ export function neutralizeSkillDelimiters(s) {
|
|
|
125
125
|
return defangToFixpoint(s, SKILL_BLOCK_RE);
|
|
126
126
|
}
|
|
127
127
|
|
|
128
|
-
// <skill-bridge> is the wrapper scripts/pre-skill-bridge.js puts around a managed
|
|
129
|
-
// skill body it injects as PreToolUse additionalContext. The body comes from a
|
|
130
|
-
// third-party repo (tools/adopt import) — an untrusted boundary — so a literal
|
|
131
|
-
// `</skill-bridge>` inside it would close the wrapper early and spill the rest of
|
|
132
|
-
// the payload (e.g. a forged <system-reminder>) as undelimited context (audit
|
|
133
|
-
// 2026-08-14 M-4). Not in CONTEXT_DELIMITER_RE for the same reason <skill-loaded>
|
|
134
|
-
// isn't: the bridge's OWN wrapper must stay live, so the defang is applied per
|
|
135
|
-
// call site to the untrusted body only.
|
|
136
|
-
const SKILL_BRIDGE_RE = /<\/?skill-bridge(?:\s[^>]*)?>/gi;
|
|
137
|
-
|
|
138
|
-
/**
|
|
139
|
-
* Defang a literal `<skill-bridge>` opener/closer in untrusted text that is about
|
|
140
|
-
* to be wrapped in a real skill-bridge block. Same fixpoint treatment as the
|
|
141
|
-
* classes above. Never apply to the wrapper itself.
|
|
142
|
-
* @param {string} s Input string (any type; coerced)
|
|
143
|
-
* @returns {string} Text with skill-bridge delimiters defanged
|
|
144
|
-
*/
|
|
145
|
-
export function neutralizeSkillBridgeDelimiters(s) {
|
|
146
|
-
return defangToFixpoint(s, SKILL_BRIDGE_RE);
|
|
147
|
-
}
|
|
148
|
-
|
|
149
128
|
/**
|
|
150
129
|
* Render the PostToolUse error-recall hint block (hook.mjs::triggerErrorRecall).
|
|
151
130
|
* The single most-relevant hit (rows[0]) that carries a lesson_learned gets its
|
package/haiku-client.mjs
CHANGED
|
@@ -6,13 +6,40 @@
|
|
|
6
6
|
// overridable via OPENROUTER_MODEL
|
|
7
7
|
|
|
8
8
|
import { execFileSync, spawn } from 'child_process';
|
|
9
|
+
import { mkdirSync } from 'fs';
|
|
9
10
|
import { readFileSync } from 'fs';
|
|
10
11
|
import { join } from 'path';
|
|
11
12
|
import { randomUUID } from 'crypto';
|
|
12
13
|
import { debugLog, debugCatch, parseJsonFromLLM } from './utils.mjs';
|
|
13
14
|
import { DB_DIR } from './schema.mjs';
|
|
15
|
+
import { resolveRuntimeDir } from './lib/resolve-data-dir.mjs';
|
|
14
16
|
import { httpConnectProxyFor, postViaConnectProxy } from './lib/proxy-fetch.mjs';
|
|
15
17
|
|
|
18
|
+
/**
|
|
19
|
+
* cwd for every `claude -p` spawn. R10 P2-13.
|
|
20
|
+
*
|
|
21
|
+
* This was `/tmp`, which is world-writable. Claude Code loads a project-level CLAUDE.md
|
|
22
|
+
* and .claude/settings.json from its cwd, so on a shared host ANY local account could
|
|
23
|
+
* create /tmp/CLAUDE.md and inject instructions into every episode summary, session
|
|
24
|
+
* summary and optimize call this process makes — and the CLI leg is the fallback every
|
|
25
|
+
* keyed provider failure lands on, so it is not an exotic path.
|
|
26
|
+
*
|
|
27
|
+
* The original reason for /tmp was ghost sessions in the user's /resume list, and that is
|
|
28
|
+
* already solved by --no-session-persistence on the same spawns. A private directory under
|
|
29
|
+
* the runtime dir (whose parent is 0700) keeps that property and removes the injection
|
|
30
|
+
* surface. Created lazily; if creation fails we still do not fall back to /tmp — an
|
|
31
|
+
* unwritable cwd fails the spawn loudly, which is the better failure.
|
|
32
|
+
*/
|
|
33
|
+
function cliSpawnCwd() {
|
|
34
|
+
const dir = join(resolveRuntimeDir(DB_DIR), 'cli-cwd');
|
|
35
|
+
try {
|
|
36
|
+
mkdirSync(dir, { recursive: true, mode: 0o700 });
|
|
37
|
+
} catch {
|
|
38
|
+
/* already there, or unwritable — the spawn reports it */
|
|
39
|
+
}
|
|
40
|
+
return dir;
|
|
41
|
+
}
|
|
42
|
+
|
|
16
43
|
// ─── Model Resolution ────────────────────────────────────────────────────────
|
|
17
44
|
|
|
18
45
|
// CLI name → API model ID mapping
|
|
@@ -237,7 +264,10 @@ export async function callHaikuJSON(prompt, opts) {
|
|
|
237
264
|
|
|
238
265
|
/**
|
|
239
266
|
* Non-blocking sibling of callHaikuJSON for callers reachable from an MCP request
|
|
240
|
-
* handler
|
|
267
|
+
* handler. R10 P3-28: this used to name `mem_registry enrich / import_url` as the caller,
|
|
268
|
+
* a tool removed in v5.0.0 — read as a live example, it sent readers looking for a handler
|
|
269
|
+
* that does not exist. The REASON is what still applies to whatever calls it next: an MCP
|
|
270
|
+
* request handler must not block the server event loop. Same
|
|
241
271
|
* provider priority; the CLI leg — primary AND post-provider-failure fallback —
|
|
242
272
|
* is the async spawn, so a keyed-provider outage cannot freeze the server event
|
|
243
273
|
* loop for BG_LLM_TIMEOUT_MS (D#138 MEDIUM-3).
|
|
@@ -245,8 +275,9 @@ export async function callHaikuJSON(prompt, opts) {
|
|
|
245
275
|
* `resolveModel().cli`, NOT the literal 'haiku': despite the name, callHaikuJSON
|
|
246
276
|
* reaches the model through resolveModel() on ALL three legs (callHaikuAPI,
|
|
247
277
|
* callOpenRouterAPI, callHaikuCLI), so it honours the documented CLAUDE_MEM_MODEL
|
|
248
|
-
* knob. Pinning 'haiku' here would silently downgrade
|
|
249
|
-
*
|
|
278
|
+
* knob. Pinning 'haiku' here would silently downgrade any caller's model for every user
|
|
279
|
+
* who set CLAUDE_MEM_MODEL=sonnet — pre-tag review finding, v3.68.0, when the caller in
|
|
280
|
+
* question was registry enrichment.
|
|
250
281
|
*
|
|
251
282
|
* Defaults also mirror callHaiku (10s / 500 tokens), not callModelJSONAsync's
|
|
252
283
|
* 15s / 1000: a caller that omits opts must get the sync twin's budget.
|
|
@@ -565,7 +596,7 @@ export function execClaudeCliSync(modelName, { input, timeout }) {
|
|
|
565
596
|
encoding: 'utf8',
|
|
566
597
|
env: { ...process.env, CLAUDE_MEM_HOOK_RUNNING: '1', DISABLE_CLAUDEMD_HOOKS: '1' },
|
|
567
598
|
stdio: ['pipe', 'pipe', 'pipe'],
|
|
568
|
-
cwd:
|
|
599
|
+
cwd: cliSpawnCwd(), // private dir, not /tmp — see cliSpawnCwd (R10 P2-13)
|
|
569
600
|
};
|
|
570
601
|
const args = claudeArgs(modelName);
|
|
571
602
|
const started = Date.now();
|
|
@@ -650,7 +681,7 @@ export async function callModelCLIAsync(prompt, model, { timeout }) {
|
|
|
650
681
|
// Same headless-tax flags + flag-compat retry as callModelCLI (rationale there).
|
|
651
682
|
child = spawn(getClaudePath(), args, {
|
|
652
683
|
env: { ...process.env, CLAUDE_MEM_HOOK_RUNNING: '1', DISABLE_CLAUDEMD_HOOKS: '1' },
|
|
653
|
-
cwd:
|
|
684
|
+
cwd: cliSpawnCwd(), // private dir, not /tmp — see cliSpawnCwd (R10 P2-13)
|
|
654
685
|
stdio: ['pipe', 'pipe', 'pipe'],
|
|
655
686
|
});
|
|
656
687
|
} catch (e) {
|
package/hash-utils.mjs
CHANGED
|
@@ -53,6 +53,27 @@ export function computeMinHash(text, numHashes = 64) {
|
|
|
53
53
|
.replace(/[^a-z0-9\s]/g, ' ')
|
|
54
54
|
.split(/\s+/)
|
|
55
55
|
.filter((t) => t.length > 2);
|
|
56
|
+
// R10 P3-10: CJK fallback, and ONLY as a fallback. The tokenizer above deletes every
|
|
57
|
+
// non-ASCII character, so a title or narrative written entirely in Chinese or Japanese
|
|
58
|
+
// produced zero tokens and this returned null — and a null signature makes the row
|
|
59
|
+
// invisible to the MinHash prefilter that findDuplicates and selectFuzzyDedupeIds run,
|
|
60
|
+
// so CJK-only rows could never be deduplicated against anything.
|
|
61
|
+
//
|
|
62
|
+
// Confined to rows that get NOTHING from the ASCII path, which is the whole point.
|
|
63
|
+
// Signatures are STORED, and estimateJaccardFromMinHash compares a stored signature
|
|
64
|
+
// against a freshly computed one; widening the tokenization for text that already
|
|
65
|
+
// signs would make the entire existing corpus incomparable with everything written
|
|
66
|
+
// afterwards — dedup degrading everywhere, silently, until a full rebuild. Every text
|
|
67
|
+
// that signs today still produces the same bytes.
|
|
68
|
+
//
|
|
69
|
+
// Character bigrams rather than nlp.mjs's dictionary segmentation: this module is
|
|
70
|
+
// dependency-free on purpose, and bigrams are the standard CJK shingle for exactly this
|
|
71
|
+
// job — no vocabulary to maintain and no dependence on a dictionary that lags usage.
|
|
72
|
+
if (tokens.length === 0) {
|
|
73
|
+
for (const run of text.match(/[\u4e00-\u9fff\u3400-\u4dbf\u3040-\u30ff]{2,}/g) || []) {
|
|
74
|
+
for (let i = 0; i + 2 <= run.length; i++) tokens.push(run.slice(i, i + 2));
|
|
75
|
+
}
|
|
76
|
+
}
|
|
56
77
|
// Require at least 3 tokens for meaningful signature (avoids high collision on short texts)
|
|
57
78
|
if (tokens.length < 3) return null;
|
|
58
79
|
|
package/hook-episode.mjs
CHANGED
|
@@ -248,7 +248,13 @@ export function mergePendingEntries(episode) {
|
|
|
248
248
|
let files;
|
|
249
249
|
try {
|
|
250
250
|
files = readdirSync(RUNTIME_DIR)
|
|
251
|
-
.
|
|
251
|
+
// R10 P3-4: `.json.tmp` is the WRITER's in-flight temp, not a pending entry.
|
|
252
|
+
// writePendingEntry writes `pending-<ts>-<rand>.json.tmp` then renames it into place;
|
|
253
|
+
// the bare `pending-` prefix matched the temp too, this loop's JSON.parse failed on a
|
|
254
|
+
// partial file, the catch deleted it as corrupt, and the writer's rename then failed
|
|
255
|
+
// ENOENT inside its own swallowed catch. One tool entry lost, silently, every time
|
|
256
|
+
// the two raced.
|
|
257
|
+
.filter((f) => f.startsWith('pending-') && f.endsWith('.json'))
|
|
252
258
|
.sort();
|
|
253
259
|
} catch {
|
|
254
260
|
return;
|
package/hook-optimize.mjs
CHANGED
|
@@ -20,6 +20,7 @@ import {
|
|
|
20
20
|
cjkBigrams,
|
|
21
21
|
notLowSignalTitleClause,
|
|
22
22
|
scrubSecrets,
|
|
23
|
+
getCurrentBranch,
|
|
23
24
|
} from './utils.mjs';
|
|
24
25
|
import { callModelJSONAsync, BG_LLM_TIMEOUT_MS } from './haiku-client.mjs';
|
|
25
26
|
import { acquireLLMSlot, releaseLLMSlot } from './hook-semaphore.mjs';
|
|
@@ -28,7 +29,12 @@ import { getVocabulary, computeVector, cosineSimilarity } from './tfidf.mjs';
|
|
|
28
29
|
import { MERGE_JACCARD_LOW, AUTO_MERGE_THRESHOLD } from './lib/dedup-constants.mjs';
|
|
29
30
|
import { DB_DIR } from './schema.mjs';
|
|
30
31
|
import { OBS_TYPE_SET } from './lib/obs-types.mjs';
|
|
31
|
-
import {
|
|
32
|
+
import {
|
|
33
|
+
normalizeScope,
|
|
34
|
+
SCOPE_PROMPT_LEGEND,
|
|
35
|
+
upsertObservationVector,
|
|
36
|
+
insertObservationRow,
|
|
37
|
+
} from './lib/observation-write.mjs';
|
|
32
38
|
import { liveObsFilterSql } from './lib/inject-search-core.mjs';
|
|
33
39
|
import { resolveRuntimeDir } from './lib/resolve-data-dir.mjs';
|
|
34
40
|
|
|
@@ -364,8 +370,25 @@ scope: ${SCOPE_PROMPT_LEGEND}`;
|
|
|
364
370
|
Array.isArray(parsed.search_aliases) && parsed.search_aliases.length
|
|
365
371
|
? parsed.search_aliases.slice(0, 6).join(' ')
|
|
366
372
|
: cand.search_aliases || null;
|
|
367
|
-
|
|
368
|
-
|
|
373
|
+
// R10 P1-4. `wide` is the lesson-backfill pass by definition — its WHERE selects rows
|
|
374
|
+
// that already have a substantive narrative and only lack a lesson. Rewriting their
|
|
375
|
+
// title and cutting their narrative at 500 chars is not enrichment, it is content
|
|
376
|
+
// loss: this UPDATE also stamps optimized_at, which evicts the row from all three
|
|
377
|
+
// re-enrich pools permanently, and unlike applyObsUpdate there is no snapshot to
|
|
378
|
+
// restore from. The affected rows are exactly the hand-written ones (mem_save,
|
|
379
|
+
// mem_update --narrative, import-jsonl); hook-llm caps its own writes at 500, which
|
|
380
|
+
// is why this stayed invisible. So wide carries the stored values through unchanged.
|
|
381
|
+
//
|
|
382
|
+
// For `narrow`, the truncate stays on LLM output but NOT on the preserve-on-empty
|
|
383
|
+
// fallback — truncating the row's own stored narrative to buy nothing was the same
|
|
384
|
+
// bug in miniature.
|
|
385
|
+
const isWide = scope === 'wide';
|
|
386
|
+
const title = isWide ? cand.title : truncate(scrubSecrets(parsed.title || ''), 120);
|
|
387
|
+
const narrative = isWide
|
|
388
|
+
? cand.narrative
|
|
389
|
+
: parsed.narrative
|
|
390
|
+
? truncate(scrubSecrets(parsed.narrative), 500)
|
|
391
|
+
: cand.narrative || '';
|
|
369
392
|
// Floor at the stored importance: re-enrich adds a lesson, it must never silently downgrade
|
|
370
393
|
// a user-set/promoted importance (the UPDATE also sets optimized_at → the loss is permanent).
|
|
371
394
|
// Upgrades are still honored.
|
|
@@ -386,31 +409,44 @@ scope: ${SCOPE_PROMPT_LEGEND}`;
|
|
|
386
409
|
lesson_learned: lessonLearned,
|
|
387
410
|
search_aliases: searchAliases,
|
|
388
411
|
});
|
|
389
|
-
|
|
390
|
-
|
|
412
|
+
// R10 P3-3: the live-row guard on the WHERE. The LLM round-trip above is up to 45 s,
|
|
413
|
+
// and a concurrent hook can supersede or auto-compress this row inside that window —
|
|
414
|
+
// without the guard the dead row was rewritten with stale content AND stamped
|
|
415
|
+
// optimized_at, which resurrects it into every pool that filters on the stamp. The
|
|
416
|
+
// scopes branch already guards with `AND scope IS NULL`; this is the same idea.
|
|
417
|
+
// 0 changes is a skip, not a success: it must not count as processed and must not
|
|
418
|
+
// rebuild a vector for a row that is no longer live.
|
|
419
|
+
const res = db
|
|
420
|
+
.prepare(
|
|
421
|
+
`
|
|
391
422
|
UPDATE observations SET type=?, title=?, narrative=?, concepts=?, facts=?,
|
|
392
423
|
text=?, importance=?, lesson_learned=?, search_aliases=?, minhash_sig=?, optimized_at=?,
|
|
393
424
|
scope=COALESCE(?, scope)
|
|
394
|
-
WHERE id = ?
|
|
425
|
+
WHERE id = ? AND ${liveObsFilterSql('')}
|
|
395
426
|
`,
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
427
|
+
)
|
|
428
|
+
.run(
|
|
429
|
+
type,
|
|
430
|
+
safe.title,
|
|
431
|
+
safe.narrative,
|
|
432
|
+
safe.concepts,
|
|
433
|
+
safe.facts,
|
|
434
|
+
safe.text,
|
|
435
|
+
importance,
|
|
436
|
+
safe.lesson_learned,
|
|
437
|
+
safe.search_aliases,
|
|
438
|
+
minhashSig,
|
|
439
|
+
Date.now(),
|
|
440
|
+
// COALESCE (mirrors the hook-llm upgrade path): a re-enrich that omits
|
|
441
|
+
// scope, or emits an off-enum value, must never blank an existing label —
|
|
442
|
+
// and THIS update stamps optimized_at, so the loss would be permanent.
|
|
443
|
+
normalizeScope(parsed.scope),
|
|
444
|
+
cand.id,
|
|
445
|
+
);
|
|
446
|
+
if (res.changes === 0) {
|
|
447
|
+
skipped++;
|
|
448
|
+
continue;
|
|
449
|
+
}
|
|
414
450
|
|
|
415
451
|
rebuildVector(db, cand.id, {
|
|
416
452
|
title,
|
|
@@ -544,8 +580,15 @@ export function applyNormalization(db, groups, { project = null } = {}) {
|
|
|
544
580
|
.all(project, project);
|
|
545
581
|
|
|
546
582
|
let updated = 0;
|
|
583
|
+
// R10 P2-2: normalize does NOT stamp optimized_at. That column is the "re-enrich has
|
|
584
|
+
// seen this row" marker read by all three re-enrich pools (:156, :177) and by
|
|
585
|
+
// cluster-merge (:634); writing it here evicted a row from lesson backfill because a
|
|
586
|
+
// synonym replacement touched one concept term — two unrelated passes sharing one flag.
|
|
587
|
+
// Nothing in normalize needs it: its own re-run gate is the 7-day NORMALIZE_GATE_FILE
|
|
588
|
+
// timer, and the pass is idempotent anyway because a canonicalized term stops matching
|
|
589
|
+
// an alias, so `changed` stays false on the second run.
|
|
547
590
|
const updateStmt = db.prepare(`
|
|
548
|
-
UPDATE observations SET concepts = ?, search_aliases =
|
|
591
|
+
UPDATE observations SET concepts = ?, search_aliases = ? WHERE id = ?
|
|
549
592
|
`);
|
|
550
593
|
|
|
551
594
|
for (const row of rows) {
|
|
@@ -574,7 +617,7 @@ export function applyNormalization(db, groups, { project = null } = {}) {
|
|
|
574
617
|
concepts: uniqueConcepts,
|
|
575
618
|
search_aliases: newAliases,
|
|
576
619
|
});
|
|
577
|
-
updateStmt.run(safe.concepts, safe.search_aliases,
|
|
620
|
+
updateStmt.run(safe.concepts, safe.search_aliases, row.id);
|
|
578
621
|
// V-F3: normalize mutated concepts + search_aliases (both vector fields) — rebuild the
|
|
579
622
|
// vector so it reflects the canonicalized terms (no-op when the vector arm is disabled).
|
|
580
623
|
rebuildVector(db, row.id, {
|
|
@@ -628,7 +671,11 @@ export function findMergeCandidates(db, maxClusters = 5, { project } = {}) {
|
|
|
628
671
|
const cutoff = Date.now() - MERGE_TIME_WINDOW_MS;
|
|
629
672
|
const projectClause = project ? 'AND project = ?' : '';
|
|
630
673
|
const stmt = db.prepare(`
|
|
631
|
-
|
|
674
|
+
-- R10 P3-7: search_aliases is in the list because the merge path reads
|
|
675
|
+
-- keeper.search_aliases when it rebuilds the keeper's vector. Without the column it was
|
|
676
|
+
-- always undefined, so a merge silently dropped the keeper's aliases from its vector
|
|
677
|
+
-- text. No effect while the vector arm is off by default, which is why it went unseen.
|
|
678
|
+
SELECT id, title, narrative, project, type, access_count, importance, created_at_epoch, minhash_sig, lesson_learned, concepts, facts, search_aliases
|
|
632
679
|
FROM observations
|
|
633
680
|
WHERE ${liveObsFilterSql('')}
|
|
634
681
|
AND optimized_at IS NULL
|
|
@@ -1039,29 +1086,35 @@ JSON: {"title":"descriptive summary ≤120 chars","narrative":"comprehensive sum
|
|
|
1039
1086
|
lesson_learned: lessonLearned,
|
|
1040
1087
|
search_aliases: searchAliases,
|
|
1041
1088
|
});
|
|
1042
|
-
|
|
1043
|
-
|
|
1044
|
-
|
|
1045
|
-
|
|
1046
|
-
|
|
1047
|
-
|
|
1048
|
-
|
|
1049
|
-
|
|
1050
|
-
|
|
1051
|
-
|
|
1052
|
-
|
|
1053
|
-
|
|
1054
|
-
|
|
1055
|
-
|
|
1056
|
-
|
|
1057
|
-
|
|
1058
|
-
|
|
1059
|
-
|
|
1060
|
-
|
|
1061
|
-
|
|
1062
|
-
|
|
1063
|
-
|
|
1064
|
-
|
|
1089
|
+
// R10 P3-8: through the shared writer, not a fourth hand-spelled INSERT. The copy
|
|
1090
|
+
// this replaces had already drifted from OBS_COLUMNS in three ways — no minhash_sig
|
|
1091
|
+
// (so the summary was invisible to the MinHash prefilter that findDuplicates and
|
|
1092
|
+
// selectFuzzyDedupeIds run, and could never be deduplicated against anything), no
|
|
1093
|
+
// branch, and subtitle written as '' instead of the schema's NULL. That is exactly
|
|
1094
|
+
// the drift lib/observation-write.mjs's docblock says the shared column list exists
|
|
1095
|
+
// to make impossible.
|
|
1096
|
+
//
|
|
1097
|
+
// optimized_at is not in OBS_COLUMNS, so it stays a separate UPDATE below — the
|
|
1098
|
+
// summary must be marked processed so the re-enrich pools do not pick it up.
|
|
1099
|
+
const newId = insertObservationRow(db, {
|
|
1100
|
+
memory_session_id: sessionId,
|
|
1101
|
+
project,
|
|
1102
|
+
text: safe.text,
|
|
1103
|
+
type: 'discovery',
|
|
1104
|
+
title: safe.title,
|
|
1105
|
+
narrative: safe.narrative,
|
|
1106
|
+
concepts: safe.concepts,
|
|
1107
|
+
facts: safe.facts,
|
|
1108
|
+
importance: 2,
|
|
1109
|
+
minhash_sig: computeMinHash(`${title || ''} ${narrative || ''}`),
|
|
1110
|
+
lesson_learned: safe.lesson_learned,
|
|
1111
|
+
search_aliases: safe.search_aliases,
|
|
1112
|
+
branch: getCurrentBranch(),
|
|
1113
|
+
created_at: new Date(medianEpoch).toISOString(),
|
|
1114
|
+
created_at_epoch: medianEpoch,
|
|
1115
|
+
});
|
|
1116
|
+
db.prepare('UPDATE observations SET optimized_at = ? WHERE id = ?').run(Date.now(), newId);
|
|
1117
|
+
const result = { lastInsertRowid: newId };
|
|
1065
1118
|
|
|
1066
1119
|
const sId = Number(result.lastInsertRowid);
|
|
1067
1120
|
|
package/hook-shared.mjs
CHANGED
|
@@ -233,8 +233,11 @@ export const STALE_PROJECT_MARKER_AGE_MS = 30 * 24 * 60 * 60 * 1000;
|
|
|
233
233
|
export const GC_PROJECT_MARKER_PREFIXES = Object.freeze([
|
|
234
234
|
'session-', // project → memory-session-id pointer
|
|
235
235
|
CITE_RECALL_FILE_PREFIX, // last session's cite-recall snapshot (nudge input)
|
|
236
|
-
|
|
237
|
-
|
|
236
|
+
// R10 P3-5: the auto-compressible 24h gate. One file per project, regenerated on demand,
|
|
237
|
+
// and it was in NEITHER list — so a project the user stops working on kept its marker
|
|
238
|
+
// forever. `.skill-cooldown-` / `.skill-reco-cooldown-` left with the skill registry in
|
|
239
|
+
// v5.0.0; a prefix for files nothing writes any more is dead weight in a hot-path loop.
|
|
240
|
+
'last-mark-compressible-', // per-project auto-compress 24h gate
|
|
238
241
|
]);
|
|
239
242
|
|
|
240
243
|
// Records of a completed side effect — never age out. `ep-`/`ep-flush-`/
|
|
@@ -312,6 +315,9 @@ export function sweepStaleProjectMarkers(
|
|
|
312
315
|
// Kill switch (naming mirrors SKIP_COMPRESS / SKIP_OPTIMIZE / SKIP_SAVE_ENRICH):
|
|
313
316
|
// this is the only sweep that deletes files a user might want to inspect, so a
|
|
314
317
|
// released default that reclaims state needs a documented way back out.
|
|
318
|
+
// R10 P3-6: exact '1', not a truthy check, and that is on purpose — the truthy form the
|
|
319
|
+
// sibling CLAUDE_MEM_SKIP_* flags use makes `=0` mean "skip", the opposite of intent.
|
|
320
|
+
// README documents the difference; do not "align" this without aligning the others too.
|
|
315
321
|
if (env.CLAUDE_MEM_SKIP_MARKER_GC === '1') return 0;
|
|
316
322
|
let entries;
|
|
317
323
|
try {
|
package/hook-update.mjs
CHANGED
|
@@ -7,7 +7,6 @@ import {
|
|
|
7
7
|
readFileSync,
|
|
8
8
|
writeFileSync,
|
|
9
9
|
copyFileSync,
|
|
10
|
-
cpSync,
|
|
11
10
|
readdirSync,
|
|
12
11
|
existsSync,
|
|
13
12
|
lstatSync,
|
|
@@ -404,11 +403,11 @@ export function getCurrentVersion() {
|
|
|
404
403
|
}
|
|
405
404
|
|
|
406
405
|
// SWITCHABLE_PATHS = everything in SOURCE_FILES plus the recursive dirs that
|
|
407
|
-
// install.mjs copies as whole subtrees (scripts,
|
|
406
|
+
// install.mjs copies as whole subtrees (scripts, node_modules). It's
|
|
408
407
|
// built per-call from the *tarball's* manifest, not the locally-imported one —
|
|
409
408
|
// see loadReleaseManifest comment for why.
|
|
410
409
|
function buildSwitchablePaths(sourceFiles) {
|
|
411
|
-
return [...sourceFiles, 'scripts', '
|
|
410
|
+
return [...sourceFiles, 'scripts', 'node_modules'];
|
|
412
411
|
}
|
|
413
412
|
|
|
414
413
|
// Load the SOURCE_FILES / HOOK_SCRIPT_FILES manifest from the *extracted
|
|
@@ -473,7 +472,11 @@ async function downloadAndInstall(tarballUrl, expectedVersion, assets = []) {
|
|
|
473
472
|
const tarballPath = join(tmpDir, 'release.tar.gz');
|
|
474
473
|
execFileSync(
|
|
475
474
|
'curl',
|
|
476
|
-
|
|
475
|
+
// R10 P3-18: `-f` makes curl exit non-zero on an HTTP error instead of writing the
|
|
476
|
+
// error BODY to the output file. Without it a 404 or a rate-limit page was saved as
|
|
477
|
+
// release.tar.gz, tar failed on it, and the debug log reported "tarball corrupt" for
|
|
478
|
+
// what was really a network or auth problem — the wrong thing to go debug.
|
|
479
|
+
['-fsL', '-H', 'Accept: application/vnd.github+json', tarballUrl, '-o', tarballPath],
|
|
477
480
|
{ timeout: 30000, stdio: 'pipe' },
|
|
478
481
|
);
|
|
479
482
|
execFileSync('tar', ['xzf', tarballPath, '-C', tmpDir, '--strip-components=1'], {
|
|
@@ -989,6 +992,41 @@ export async function installExtractedRelease(sourceDir, targetDir = INSTALL_DIR
|
|
|
989
992
|
rmSync(stagingDir, { recursive: true, force: true });
|
|
990
993
|
rmSync(backupDir, { recursive: true, force: true });
|
|
991
994
|
|
|
995
|
+
// Post-update migration: reconcile settings.json against the release we just
|
|
996
|
+
// swapped in. `configureHooks()` already strips stale mem entries — but its only
|
|
997
|
+
// caller is `install()`, and this path never runs it, while `buildSwitchablePaths`
|
|
998
|
+
// replaces `scripts/` wholesale. So an upgrade that REMOVES a hook script deletes
|
|
999
|
+
// the file and leaves settings.json pointing at it; the launcher then reports a
|
|
1000
|
+
// broken install on every fire, for a file that is not coming back (R9 review P1-1,
|
|
1001
|
+
// first hit by the `PreToolUse:Skill` removal). Plugin-channel installs are
|
|
1002
|
+
// unaffected — hooks/hooks.json is replaced wholesale — so this is npm-channel only.
|
|
1003
|
+
//
|
|
1004
|
+
// The reconciler lives in lib/hook-prune.mjs, not install.mjs: install.mjs already
|
|
1005
|
+
// imports THIS file, so importing it back would close a cycle the repo's import-graph
|
|
1006
|
+
// guard rejects. Failure is non-fatal — a stale entry is noisy, not broken, and must
|
|
1007
|
+
// never roll back an otherwise-good update.
|
|
1008
|
+
try {
|
|
1009
|
+
const settingsPath = join(homedir(), '.claude', 'settings.json');
|
|
1010
|
+
if (existsSync(settingsPath)) {
|
|
1011
|
+
const { pruneDanglingMemHooks } = await import('./lib/hook-prune.mjs');
|
|
1012
|
+
const before = JSON.parse(readFileSync(settingsPath, 'utf8'));
|
|
1013
|
+
const { settings, removed } = pruneDanglingMemHooks(before, targetDir);
|
|
1014
|
+
if (removed.length > 0) {
|
|
1015
|
+
// Through the shared writer: settings.json is user-owned config and may be a
|
|
1016
|
+
// symlink into a dotfiles repo, which a bare temp+rename would sever (P0-5).
|
|
1017
|
+
const { atomicWriteFileSync } = await import('./lib/atomic-write.mjs');
|
|
1018
|
+
atomicWriteFileSync(settingsPath, JSON.stringify(settings, null, 2) + '\n', { backup: true });
|
|
1019
|
+
debugLog(
|
|
1020
|
+
'INFO',
|
|
1021
|
+
'update',
|
|
1022
|
+
`pruned ${removed.length} dangling hook entr(ies): ${removed.join(', ')}`,
|
|
1023
|
+
);
|
|
1024
|
+
}
|
|
1025
|
+
}
|
|
1026
|
+
} catch (e) {
|
|
1027
|
+
debugCatch(e, 'pruneDanglingMemHooks');
|
|
1028
|
+
}
|
|
1029
|
+
|
|
992
1030
|
// Post-update migration: clean stale global MCPs if plugin handles it.
|
|
993
1031
|
// Both "mem" (legacy, pre-v2.78) and "mem-lite" (current) are purged so a
|
|
994
1032
|
// user who manually ran `claude mcp add` in either era doesn't end up with
|
|
@@ -1182,7 +1220,7 @@ function copyReleaseIntoStaging(
|
|
|
1182
1220
|
// scripts/ is curated to HOOK_SCRIPT_FILES — settings.json hook commands
|
|
1183
1221
|
// resolve only to these 5 files, and plugin mode does not consume this
|
|
1184
1222
|
// directory at all. Pre-v2.55 used cpSync({recursive:true}) which silently
|
|
1185
|
-
// shipped dev-only files (mock-claude.mjs, extract-repos.mjs
|
|
1223
|
+
// shipped dev-only files (mock-claude.mjs, extract-repos.mjs…)
|
|
1186
1224
|
// from the GitHub Releases tarball into every user's data dir.
|
|
1187
1225
|
const stagingScripts = join(stagingDir, 'scripts');
|
|
1188
1226
|
const sourceScripts = join(sourceDir, 'scripts');
|
|
@@ -1194,15 +1232,6 @@ function copyReleaseIntoStaging(
|
|
|
1194
1232
|
}
|
|
1195
1233
|
}
|
|
1196
1234
|
|
|
1197
|
-
// registry/ stays recursive — preinstalled.json is the only current entry
|
|
1198
|
-
// but the directory is consumed wholesale by the registry indexer and may
|
|
1199
|
-
// grow subtrees. Pre-v2.55 readdirSync+copyFileSync would EISDIR-throw on
|
|
1200
|
-
// any subdir and silently roll back the entire update.
|
|
1201
|
-
const sourceRegistry = join(sourceDir, 'registry');
|
|
1202
|
-
if (existsSync(sourceRegistry)) {
|
|
1203
|
-
cpSync(sourceRegistry, join(stagingDir, 'registry'), { recursive: true });
|
|
1204
|
-
}
|
|
1205
|
-
|
|
1206
1235
|
const stagedScripts = join(stagingDir, 'scripts');
|
|
1207
1236
|
if (existsSync(stagedScripts)) {
|
|
1208
1237
|
for (const sf of readdirSync(stagedScripts).filter((n) => n.endsWith('.sh'))) {
|
|
@@ -1345,12 +1374,25 @@ export function prunePluginCache() {
|
|
|
1345
1374
|
// it is not among the newest 3 — so keep-latest-3 rm -rf'd the tree the running hooks and
|
|
1346
1375
|
// MCP server import from. scripts/setup.sh step 8 carries the same guard for the same
|
|
1347
1376
|
// reason; the two prune the same directory and must agree.
|
|
1377
|
+
//
|
|
1378
|
+
// R10 P2-9: CLAUDE_PLUGIN_ROOT alone is not enough, because prune also runs from a
|
|
1379
|
+
// TERMINAL — `self-update` — where Claude Code never sets it. There the guard was
|
|
1380
|
+
// vacuous and keep-latest-3 deleted the recorded live version outright. detectInstallShape
|
|
1381
|
+
// now consults installed_plugins.json as well, so ask it rather than the env var, and
|
|
1382
|
+
// keep the env var as the first-hand answer inside a hook.
|
|
1348
1383
|
const runningRoot = process.env.CLAUDE_PLUGIN_ROOT;
|
|
1384
|
+
let recordedRoot = null;
|
|
1385
|
+
try {
|
|
1386
|
+
recordedRoot = detectInstallShape({ home: homedir() }).activePluginVersion?.root || null;
|
|
1387
|
+
} catch {
|
|
1388
|
+
/* best-effort — a failure here must not stop pruning entirely */
|
|
1389
|
+
}
|
|
1349
1390
|
const toRemove = entries.slice(PLUGIN_CACHE_KEEP);
|
|
1350
1391
|
let removed = 0;
|
|
1351
1392
|
for (const ver of toRemove) {
|
|
1352
1393
|
const dir = join(cacheBase, ver);
|
|
1353
1394
|
if (isSameDir(dir, runningRoot)) continue;
|
|
1395
|
+
if (isSameDir(dir, recordedRoot)) continue;
|
|
1354
1396
|
try {
|
|
1355
1397
|
rmSync(dir, { recursive: true, force: true });
|
|
1356
1398
|
removed++;
|
package/hook.mjs
CHANGED
|
@@ -165,10 +165,10 @@ async function loadCacheGuard() {
|
|
|
165
165
|
// Audit 2026-09-02 P1-8. `hook.mjs` is ONE entry point for seven events, so every static
|
|
166
166
|
// import is paid by every event — PostToolUse, the highest-frequency one, was loading 85
|
|
167
167
|
// modules (1.37 MB) to use a handful. The six modules below are loaded inside the handler
|
|
168
|
-
// that needs them instead:
|
|
169
|
-
//
|
|
170
|
-
//
|
|
171
|
-
//
|
|
168
|
+
// that needs them instead: patha-exclude-meter, hook-update, hook-optimize, adopt-cli,
|
|
169
|
+
// upgrade-banner. Measured marginal cost of the largest, same process: hook-update
|
|
170
|
+
// 9.4 ms, hook-optimize 5.9, the rest 0.5-2.4 each. (registry-recommend, 5.1 ms, was a
|
|
171
|
+
// sixth entry until the skill-registry subsystem was removed — see docs/audits/20260906-145304.md.)
|
|
172
172
|
//
|
|
173
173
|
// A failing dynamic import lands in the dispatcher's tail catch -> recordHookError, the
|
|
174
174
|
// same place a failing static import would have landed the whole process; each site keeps
|
|
@@ -240,10 +240,26 @@ if (process.env.CLAUDE_MEM_HOOK_RUNNING && !BG_EVENTS.has(event)) process.exit(0
|
|
|
240
240
|
// Uses flag-based approach to avoid calling file I/O inside signal handlers,
|
|
241
241
|
// which can deadlock if the signal fires during a main-thread file operation.
|
|
242
242
|
let _shutdownRequested = false;
|
|
243
|
+
// R10 P3-2: the salvage belongs to the INTERACTIVE hook only. It was installed for every
|
|
244
|
+
// invocation, background workers included — and the seven of them (llm-episode, llm-summary,
|
|
245
|
+
// auto-compress, llm-optimize, auto-maintain, enrich-save, update-check) do not own the
|
|
246
|
+
// live `ep-<project>.json` buffer. A `pkill node` or a machine shutdown therefore had every
|
|
247
|
+
// running worker flush AND DELETE the buffer an interactive session was still appending to,
|
|
248
|
+
// none of them holding the episode lock. Workers still exit promptly on a signal; they just
|
|
249
|
+
// do not touch a buffer that is not theirs.
|
|
250
|
+
const SALVAGES_EPISODE_ON_SIGNAL = !BG_EVENTS.has(event);
|
|
243
251
|
for (const sig of ['SIGTERM', 'SIGINT']) {
|
|
244
252
|
process.on(sig, () => {
|
|
245
253
|
if (_shutdownRequested) process.exit(0); // Double-signal = force exit
|
|
246
254
|
_shutdownRequested = true;
|
|
255
|
+
if (!SALVAGES_EPISODE_ON_SIGNAL) {
|
|
256
|
+
try {
|
|
257
|
+
flushHookStdout();
|
|
258
|
+
} catch {
|
|
259
|
+
/* never change the exit code for a receipt */
|
|
260
|
+
}
|
|
261
|
+
process.exit(0);
|
|
262
|
+
}
|
|
247
263
|
// Schedule flush on next tick to avoid re-entering file I/O
|
|
248
264
|
setTimeout(() => {
|
|
249
265
|
try {
|
|
@@ -642,23 +658,6 @@ async function handlePostToolUse() {
|
|
|
642
658
|
if (SKIP_TOOLS.has(tool_name)) return;
|
|
643
659
|
if (SKIP_PREFIXES.some((p) => tool_name.startsWith(p))) return;
|
|
644
660
|
|
|
645
|
-
// Shadow skill-adoption telemetry. mem_use is pre-filtered above, so the Skill tool is
|
|
646
|
-
// the only visible adoption signal (v1). Placed before the resp-length gate because a
|
|
647
|
-
// skill load's response shape varies. Never throws.
|
|
648
|
-
if (tool_name === 'Skill') {
|
|
649
|
-
const ti = typeof tool_input === 'string' ? tryParseJson(tool_input) : tool_input || {};
|
|
650
|
-
// hookData.session_id (CC UUID) pairs this adoption to the would-be reco from the
|
|
651
|
-
// UserPromptSubmit hook earlier in the same session (matched precision, B1).
|
|
652
|
-
// Lazy: only a `Skill` tool call needs this module, which is a small fraction of
|
|
653
|
-
// PostToolUse fires.
|
|
654
|
-
try {
|
|
655
|
-
const { recordSkillAdoption } = await import('./registry-recommend.mjs');
|
|
656
|
-
recordSkillAdoption('Skill', ti, inferProject(), hookData.session_id);
|
|
657
|
-
} catch {
|
|
658
|
-
/* telemetry only — never blocks capture */
|
|
659
|
-
}
|
|
660
|
-
}
|
|
661
|
-
|
|
662
661
|
const resp = normalizeToolResponse(tool_response);
|
|
663
662
|
if (!resp || resp.length < 10) return;
|
|
664
663
|
|
|
@@ -2254,14 +2253,7 @@ async function handleSessionStart() {
|
|
|
2254
2253
|
} catch {
|
|
2255
2254
|
/* best-effort */
|
|
2256
2255
|
}
|
|
2257
|
-
// Bound the
|
|
2258
|
-
try {
|
|
2259
|
-
const { gcOldShadowShards } = await import('./registry-recommend.mjs');
|
|
2260
|
-
gcOldShadowShards();
|
|
2261
|
-
} catch {
|
|
2262
|
-
/* best-effort, never blocks SessionStart */
|
|
2263
|
-
}
|
|
2264
|
-
// Same for the opt-in metrics sink, which lives under DB_DIR. Runs even when
|
|
2256
|
+
// Bound the opt-in metrics sink, which lives under DB_DIR. Runs even when
|
|
2265
2257
|
// metrics are disabled, so shards left by a since-toggled-off run still get pruned.
|
|
2266
2258
|
try {
|
|
2267
2259
|
gcOldMetricShards(DB_DIR);
|