claude-mem-lite 4.0.3 → 5.0.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.
Files changed (49) hide show
  1. package/.claude-plugin/marketplace.json +1 -1
  2. package/.claude-plugin/plugin.json +1 -1
  3. package/README.md +0 -49
  4. package/README.zh-CN.md +0 -48
  5. package/adopt-content.mjs +0 -1
  6. package/cli/common.mjs +0 -17
  7. package/cli.mjs +19 -3
  8. package/commands/update.md +4 -11
  9. package/format-utils.mjs +0 -21
  10. package/hook-context.mjs +6 -1
  11. package/hook-handoff.mjs +9 -0
  12. package/hook-update.mjs +37 -12
  13. package/hook.mjs +5 -29
  14. package/hooks/hooks.json +0 -10
  15. package/install.mjs +24 -666
  16. package/lib/doctor-drift.mjs +0 -1
  17. package/lib/fast-summary.mjs +11 -0
  18. package/lib/frontmatter.mjs +1 -2
  19. package/lib/hook-prune.mjs +133 -0
  20. package/lib/hook-stdin.mjs +1 -1
  21. package/lib/hook-telemetry.mjs +1 -1
  22. package/lib/metrics.mjs +2 -2
  23. package/lib/shard-gc.mjs +11 -7
  24. package/mem-cli.mjs +2 -423
  25. package/nlp.mjs +1 -1
  26. package/npm-shrinkwrap.json +2 -2
  27. package/package.json +3 -15
  28. package/schema.mjs +0 -1
  29. package/scripts/hook-launcher.mjs +2 -2
  30. package/scripts/prompt-search-utils.mjs +0 -30
  31. package/scripts/user-prompt-search.js +3 -125
  32. package/server.mjs +11 -449
  33. package/source-files.mjs +11 -28
  34. package/synonyms.mjs +2 -1
  35. package/tool-schemas.mjs +0 -80
  36. package/utils.mjs +5 -13
  37. package/commands/tools.md +0 -67
  38. package/install-metadata.mjs +0 -2193
  39. package/lib/registry-core.mjs +0 -264
  40. package/registry/preinstalled.json +0 -2419
  41. package/registry-enricher.mjs +0 -124
  42. package/registry-github.mjs +0 -86
  43. package/registry-importer.mjs +0 -569
  44. package/registry-recommend.mjs +0 -503
  45. package/registry-retriever.mjs +0 -611
  46. package/registry-scanner.mjs +0 -261
  47. package/registry.mjs +0 -665
  48. package/resource-discovery.mjs +0 -199
  49. package/scripts/pre-skill-bridge.js +0 -146
package/install.mjs CHANGED
@@ -10,7 +10,6 @@ import {
10
10
  mkdirSync,
11
11
  mkdtempSync,
12
12
  copyFileSync,
13
- cpSync,
14
13
  renameSync,
15
14
  symlinkSync,
16
15
  unlinkSync,
@@ -18,7 +17,7 @@ import {
18
17
  statSync,
19
18
  lstatSync,
20
19
  } from 'fs';
21
- import { join, resolve, dirname, isAbsolute, basename } from 'path';
20
+ import { join, resolve, dirname, basename } from 'path';
22
21
  import { homedir, tmpdir } from 'os';
23
22
  import { fileURLToPath, pathToFileURL } from 'url';
24
23
  import { createRequire } from 'node:module';
@@ -58,7 +57,6 @@ const HOOK_PATH = join(INSTALL_DIR, 'hook.mjs');
58
57
  import { MARKETPLACE_KEY, PLUGIN_KEY, isPluginExplicitlyDisabled } from './lib/plugin-key.mjs';
59
58
  const NPM_INSTALL_CMD = 'npm install --omit=dev --no-audit --no-fund';
60
59
 
61
- import { RESOURCE_METADATA } from './install-metadata.mjs';
62
60
  import {
63
61
  scanPluginCacheHookPollution,
64
62
  hasInstallManagedHooks,
@@ -75,6 +73,7 @@ import { clearNativeBindingBreakage, readNativeBindingBreakage } from './lib/nat
75
73
  import { sweepStaleTestFixtures } from './lib/tmp-fixture-sweep.mjs';
76
74
  import { acquireLock } from './lib/proc-lock.mjs';
77
75
  import { atomicWriteFileSync } from './lib/atomic-write.mjs';
76
+ import { isMemHook, launcherEntryPath } from './lib/hook-prune.mjs';
78
77
 
79
78
  // Re-export for backward compatibility — tests/install-hook-scripts.test.mjs
80
79
  // and any external consumers still import HOOK_SCRIPT_FILES from install.mjs.
@@ -126,177 +125,6 @@ export function migrateLegacyClaudeMemData(oldDir, newDir, opts = {}) {
126
125
  return { action: 'backed-up', backupPath };
127
126
  }
128
127
 
129
- /**
130
- * Derive invocation_name from resource name when metadata doesn't provide one.
131
- * Rules:
132
- * "parent/child" → "parent:child" (plugin:resource format)
133
- * "simple-name" → "simple-name" (standalone resource)
134
- * @param {string} name Resource name
135
- * @returns {string} Derived invocation name
136
- */
137
- function deriveInvocationName(name) {
138
- if (name.includes('/')) return name.replace('/', ':');
139
- return name;
140
- }
141
-
142
- /**
143
- * Apply curated metadata to existing resource DB entries.
144
- * Fixes existing installs that have generic name-echo metadata.
145
- * Also syncs keywords, tech_stack, use_cases and auto-derives invocation_name.
146
- * @param {Database} rdb Registry database handle
147
- */
148
- function reindexKnownResources(rdb) {
149
- const update = rdb.prepare(`
150
- UPDATE resources SET
151
- intent_tags = ?, domain_tags = ?,
152
- capability_summary = ?, trigger_patterns = ?,
153
- invocation_name = CASE WHEN ? != '' THEN ? ELSE invocation_name END,
154
- recommendation_mode = CASE WHEN ? != '' THEN ? ELSE recommendation_mode END,
155
- keywords = CASE WHEN ? != '' THEN ? ELSE keywords END,
156
- tech_stack = CASE WHEN ? != '' THEN ? ELSE tech_stack END,
157
- use_cases = CASE WHEN ? != '' THEN ? ELSE use_cases END,
158
- updated_at = datetime('now')
159
- WHERE type = ? AND name = ?
160
- `);
161
-
162
- rdb.transaction(() => {
163
- for (const [key, meta] of Object.entries(RESOURCE_METADATA)) {
164
- const sep = key.indexOf(':');
165
- if (sep < 0) continue; // skip malformed keys without type:name separator
166
- const type = key.slice(0, sep);
167
- const name = key.slice(sep + 1);
168
- const invName = meta.invocation_name || deriveInvocationName(name);
169
- const recMode = meta.recommendation_mode || '';
170
- const kw = meta.keywords || '';
171
- const ts = meta.tech_stack || '';
172
- const uc = meta.use_cases || '';
173
- update.run(
174
- meta.intent_tags,
175
- meta.domain_tags,
176
- meta.capability_summary,
177
- meta.trigger_patterns,
178
- invName,
179
- invName,
180
- recMode,
181
- recMode,
182
- kw,
183
- kw,
184
- ts,
185
- ts,
186
- uc,
187
- uc,
188
- type,
189
- name,
190
- );
191
- }
192
- })();
193
- }
194
-
195
- /**
196
- * Register plugin resources that have no local files (virtual resources).
197
- * These are skills/agents from other installed plugins that the dispatch
198
- * system should know about for intelligent recommendation.
199
- * Only inserts entries that don't already exist in the resources table.
200
- * @param {Database} rdb Registry database handle
201
- */
202
- function registerVirtualResources(rdb) {
203
- const insert = rdb.prepare(`
204
- INSERT OR IGNORE INTO resources (name, type, status, source, local_path, invocation_name,
205
- intent_tags, domain_tags, capability_summary, trigger_patterns,
206
- keywords, tech_stack, use_cases, recommendation_mode,
207
- created_at, updated_at)
208
- VALUES (?, ?, 'active', 'preinstalled', '', ?, ?, ?, ?, ?, ?, ?, ?, ?, datetime('now'), datetime('now'))
209
- `);
210
-
211
- // Backfill FTS5 fields for existing resources that have empty keywords/tech_stack/use_cases
212
- const updateFts = rdb.prepare(`
213
- UPDATE resources SET
214
- keywords = CASE WHEN (keywords IS NULL OR keywords = '') AND ?1 != '' THEN ?1 ELSE keywords END,
215
- tech_stack = CASE WHEN (tech_stack IS NULL OR tech_stack = '') AND ?2 != '' THEN ?2 ELSE tech_stack END,
216
- use_cases = CASE WHEN (use_cases IS NULL OR use_cases = '') AND ?3 != '' THEN ?3 ELSE use_cases END,
217
- updated_at = datetime('now')
218
- WHERE type = ?4 AND name = ?5
219
- AND ((keywords IS NULL OR keywords = '') OR (tech_stack IS NULL OR tech_stack = '') OR (use_cases IS NULL OR use_cases = ''))
220
- `);
221
-
222
- let count = 0;
223
- rdb.transaction(() => {
224
- for (const [key, meta] of Object.entries(RESOURCE_METADATA)) {
225
- const sep = key.indexOf(':');
226
- if (sep < 0) continue;
227
- const type = key.slice(0, sep);
228
- const name = key.slice(sep + 1);
229
- const { changes } = insert.run(
230
- name,
231
- type,
232
- meta.invocation_name || deriveInvocationName(name),
233
- meta.intent_tags || name.replace(/-/g, ' '),
234
- meta.domain_tags || '',
235
- meta.capability_summary || `${type}: ${name.replace(/-/g, ' ')}`,
236
- meta.trigger_patterns || `when user needs ${name.replace(/-/g, ' ')}`,
237
- meta.keywords || '',
238
- meta.tech_stack || '',
239
- meta.use_cases || '',
240
- meta.recommendation_mode || 'proactive',
241
- );
242
- count += changes;
243
-
244
- // Backfill FTS5 fields for existing resources.
245
- // ?N numbered placeholders REQUIRE object-form binding in better-sqlite3 —
246
- // positional .run(v1, v2, …) always throws "Too many parameter values"
247
- // regardless of arg count. Pre-fix this swallow-warned on every install
248
- // (masked by install.mjs:785 import failure before the v2.84.2 path fix).
249
- if (changes === 0) {
250
- updateFts.run({
251
- 1: meta.keywords || '',
252
- 2: meta.tech_stack || '',
253
- 3: meta.use_cases || '',
254
- 4: type,
255
- 5: name,
256
- });
257
- }
258
- }
259
-
260
- // Backfill keywords from preinstalled tags for resources still missing keywords
261
- try {
262
- const backfill = rdb.prepare(`
263
- UPDATE resources SET keywords = (
264
- SELECT GROUP_CONCAT(json_each.value, ',')
265
- FROM preinstalled p, json_each(p.tags)
266
- WHERE p.type = resources.type AND p.name = resources.name
267
- )
268
- WHERE (keywords IS NULL OR keywords = '')
269
- AND EXISTS (
270
- SELECT 1 FROM preinstalled p
271
- WHERE p.type = resources.type AND p.name = resources.name
272
- AND p.tags != '[]' AND p.tags IS NOT NULL
273
- )
274
- `);
275
- backfill.run();
276
- } catch {}
277
-
278
- // Backfill invocation_name for resources that still have it empty
279
- // Derive from name: "parent/child" → "parent:child", otherwise use name as-is
280
- try {
281
- const emptyInvoc = rdb
282
- .prepare(
283
- `
284
- SELECT id, name FROM resources
285
- WHERE status = 'active' AND (invocation_name IS NULL OR invocation_name = '')
286
- `,
287
- )
288
- .all();
289
- if (emptyInvoc.length > 0) {
290
- const setInvoc = rdb.prepare('UPDATE resources SET invocation_name = ? WHERE id = ?');
291
- for (const r of emptyInvoc) {
292
- setInvoc.run(deriveInvocationName(r.name), r.id);
293
- }
294
- }
295
- } catch {}
296
- })();
297
- return count;
298
- }
299
-
300
128
  let cmd = process.argv[2];
301
129
  let flags = new Set(process.argv.slice(3));
302
130
 
@@ -307,51 +135,6 @@ function ok(msg) {
307
135
  console.log(` ✓ ${msg}`);
308
136
  }
309
137
 
310
- /**
311
- * Recursive on-disk size of `dir`, in bytes. Bounded by `maxEntries` so a
312
- * surprise-large tree can never turn a progress line into a long stat storm —
313
- * returns `{ bytes, truncated }` and callers render truncated sums as "≥ N MB".
314
- *
315
- * @param {string} dir Directory to measure (missing dir → 0 bytes).
316
- * @param {number} [maxEntries=50000] Stat budget.
317
- * @returns {{bytes: number, truncated: boolean}}
318
- */
319
- function dirSizeBytes(dir, maxEntries = 50000) {
320
- let bytes = 0,
321
- seen = 0,
322
- truncated = false;
323
- const stack = [dir];
324
- while (stack.length > 0) {
325
- const cur = stack.pop();
326
- let entries;
327
- try {
328
- entries = readdirSync(cur, { withFileTypes: true });
329
- } catch {
330
- continue;
331
- }
332
- for (const e of entries) {
333
- if (++seen > maxEntries) {
334
- truncated = true;
335
- return { bytes, truncated };
336
- }
337
- const p = join(cur, e.name);
338
- if (e.isDirectory()) stack.push(p);
339
- else if (e.isFile()) {
340
- try {
341
- bytes += statSync(p).size;
342
- } catch {
343
- /* raced away */
344
- }
345
- }
346
- }
347
- }
348
- return { bytes, truncated };
349
- }
350
-
351
- /** Render a byte count as a short human string ("148 MB"). */
352
- function fmtMB(bytes, truncated = false) {
353
- return `${truncated ? '≥' : ''}${Math.round(bytes / 1048576)} MB`;
354
- }
355
138
  function warn(msg) {
356
139
  console.log(` ⚠ ${msg}`);
357
140
  }
@@ -494,50 +277,6 @@ function isDevInstall() {
494
277
  }
495
278
  }
496
279
 
497
- // Decide what to check out of a registry repo. We only ever copy the manifest's
498
- // `entry.path` subdirs into managed/skills|agents, so the working tree never
499
- // needs the rest of the repo — a partial+sparse clone fetches just those subtrees
500
- // (e.g. davila7/claude-code-templates: 197MB whole repo → a few MB of 3 paths).
501
- // Returns { full, paths }: `full` forces a normal checkout when any entry maps to
502
- // the repo root ('.') — sparse buys nothing there. Unsafe paths ('..'/absolute)
503
- // are dropped here exactly as the copy loop drops them, so they never reach
504
- // `sparse-checkout set`. Pure + exported for unit testing.
505
- export function planRepoSparsePaths(entries) {
506
- let needsFull = false;
507
- const paths = [];
508
- for (const e of entries || []) {
509
- const p = e && e.path;
510
- if (!p || p === '.' || p === './') {
511
- needsFull = true;
512
- continue;
513
- }
514
- if (isAbsolute(p) || String(p).includes('..')) continue; // unsafe — skipped at copy too
515
- const norm = String(p).replace(/^\.\//, '').replace(/\/+$/, '');
516
- if (norm && !paths.includes(norm)) paths.push(norm);
517
- }
518
- // No usable sparse paths (all root or all unsafe) → a full checkout is the only
519
- // thing that can produce content; sparse would be an empty, pointless tree.
520
- return { full: needsFull || paths.length === 0, paths };
521
- }
522
-
523
- // True only for a clone this code produced (partial-clone promisor + sparse-checkout
524
- // both on). A legacy full clone returns false → caller re-clones it slim. Detection
525
- // erring false only costs a one-time re-clone (the dir is a rebuildable cache), so
526
- // over-eager migration is safe; under-eager just keeps a fat clone one more cycle.
527
- function isPartialSparseClone(clonePath) {
528
- const cfg = (key) => {
529
- try {
530
- return execFileSync('git', ['-C', clonePath, 'config', '--get', key], {
531
- encoding: 'utf8',
532
- stdio: 'pipe',
533
- }).trim();
534
- } catch {
535
- return '';
536
- } // missing key → git exits non-zero → treat as unset
537
- };
538
- return cfg('remote.origin.promisor') === 'true' && cfg('core.sparseCheckout') === 'true';
539
- }
540
-
541
280
  // ─── Install ────────────────────────────────────────────────────────────────
542
281
 
543
282
  // Dynamic-import helpers, resolved against the installed copy at INSTALL_DIR
@@ -616,11 +355,6 @@ function installSourceFiles(IS_DEV) {
616
355
  execFileSync('chmod', ['+x', join(scriptsDir, 'post-tool-use.sh')], { stdio: 'pipe' });
617
356
  } catch {}
618
357
  // commands/ is intentionally NOT copied — see dev-mode branch above.
619
- // Copy registry manifest
620
- const registryDir = join(DATA_DIR, 'registry');
621
- if (!existsSync(registryDir)) mkdirSync(registryDir, { recursive: true });
622
- const manifestSrc = join(PROJECT_DIR, 'registry', 'preinstalled.json');
623
- if (existsSync(manifestSrc)) copyFileSync(manifestSrc, join(registryDir, 'preinstalled.json'));
624
358
  ok('Source files copied to ~/.claude-mem-lite/');
625
359
 
626
360
  // v2.48 P1-4: prune stale top-level .mjs + 0-byte .db files left behind by
@@ -1049,17 +783,6 @@ function configureHooks() {
1049
783
  ],
1050
784
  };
1051
785
 
1052
- const memPreSkillBridge = {
1053
- matcher: 'Skill',
1054
- hooks: [
1055
- {
1056
- type: 'command',
1057
- command: nodeHook('scripts/pre-skill-bridge.js'),
1058
- timeout: 3,
1059
- },
1060
- ],
1061
- };
1062
-
1063
786
  // P0 subagent dispatch-time injection (default off — CLAUDE_MEM_SUBAGENT_INJECT).
1064
787
  // Fires on the Agent/Task dispatch so a subagent (otherwise memory-blind — #8848)
1065
788
  // can receive one relevant lesson via updatedInput. Parity with hooks/hooks.json.
@@ -1079,13 +802,13 @@ function configureHooks() {
1079
802
  };
1080
803
 
1081
804
  // Filter out existing mem hooks, then append fresh ones
1082
- // PreToolUse has three separate matchers, so we register all three
805
+ // PreToolUse has two separate matchers, so we register both
1083
806
  // Event set MUST stay equal to hooks/hooks.json's (minus scripts/setup.sh, which
1084
807
  // bootstraps the plugin cache and has no settings.json counterpart) —
1085
808
  // tests/audit-silent-20260814.test.mjs diffs a real `install --dev` run's
1086
809
  // settings.json against the shipped manifest and reds on any new divergence.
1087
810
  const hookConfigs = {
1088
- PreToolUse: [memPreToolRecall, memPreSkillBridge, memPreAgentInject],
811
+ PreToolUse: [memPreToolRecall, memPreAgentInject],
1089
812
  PostToolUse: [memPostToolUse, memPostToolRecall],
1090
813
  PostToolUseFailure: [memPostToolFailure],
1091
814
  PreCompact: [memPreCompact],
@@ -1143,339 +866,6 @@ function backupLegacyClaudeMemData() {
1143
866
  }
1144
867
  }
1145
868
 
1146
- async function installPreinstalledResources() {
1147
- // 6. Install pre-installed resources (skills + agents)
1148
- if (process.env.CLAUDE_MEM_SKIP_REPOS) {
1149
- ok('Skill/agent registry: skipped (CLAUDE_MEM_SKIP_REPOS)');
1150
- } else
1151
- try {
1152
- const manifestPath = join(INSTALL_DIR, 'registry', 'preinstalled.json');
1153
- if (!existsSync(manifestPath)) {
1154
- // For git-clone mode, check PROJECT_DIR
1155
- const altPath = join(PROJECT_DIR, 'registry', 'preinstalled.json');
1156
- if (existsSync(altPath)) {
1157
- const registryDir = join(INSTALL_DIR, 'registry');
1158
- if (!existsSync(registryDir)) mkdirSync(registryDir, { recursive: true });
1159
- copyFileSync(altPath, manifestPath);
1160
- }
1161
- }
1162
-
1163
- if (existsSync(manifestPath)) {
1164
- const manifest = JSON.parse(readFileSync(manifestPath, 'utf8'));
1165
- const resources = manifest.resources || [];
1166
-
1167
- if (resources.length > 0) {
1168
- const managedDir = join(MEM_DATA_DIR, 'managed');
1169
-
1170
- // 6a. Git shallow clone unique repos
1171
- const repos = new Map();
1172
- for (const r of resources) {
1173
- if (!repos.has(r.repo)) repos.set(r.repo, []);
1174
- repos.get(r.repo).push(r);
1175
- }
1176
-
1177
- // Disclose the cost BEFORE spending it. This step is the single largest
1178
- // thing `install` does — N shallow git clones over the network, ~150 MB on
1179
- // disk for the default manifest — and it used to announce itself only after
1180
- // the fact ("Repos: 15 cloned"). A first-time user on a metered link or a
1181
- // small disk had no warning and no visible way out; the opt-out existed but
1182
- // lived only in an env var no output ever mentioned.
1183
- // Count only repos not already on disk — a re-run/update clones nothing, and
1184
- // announcing "cloning 15 repos" every time would be false.
1185
- const repoDirName = (repoUrl) =>
1186
- repoUrl
1187
- .split('/')
1188
- .slice(-2)
1189
- .join('-')
1190
- .replace(/[^a-zA-Z0-9._-]/g, '_');
1191
- const pendingClones = [...repos.keys()].filter(
1192
- (repoUrl) => !existsSync(join(managedDir, 'repos', repoDirName(repoUrl))),
1193
- ).length;
1194
- if (pendingClones > 0) {
1195
- log(`Skill/agent registry: cloning ${pendingClones} repo(s) — network + ~150 MB on disk.`);
1196
- log(' Skip with CLAUDE_MEM_SKIP_REPOS=1 (memory features work without it).');
1197
- }
1198
-
1199
- let cloned = 0,
1200
- updated = 0;
1201
- const deadRepos = new Set(); // repos that no longer exist (404)
1202
-
1203
- const isRepoNotFound = (err) => {
1204
- const msg = (err?.stderr ? err.stderr.toString() : '') + (err?.message || '');
1205
- return /repository.*not found|404/i.test(msg);
1206
- };
1207
-
1208
- for (const [repoUrl, entries] of repos) {
1209
- const repoName = repoUrl
1210
- .split('/')
1211
- .slice(-2)
1212
- .join('-')
1213
- .replace(/[^a-zA-Z0-9._-]/g, '_');
1214
- const clonePath = join(managedDir, 'repos', repoName);
1215
- let repoReady = false;
1216
-
1217
- const plan = planRepoSparsePaths(entries);
1218
- const cloneUrl = `${repoUrl.replace(/\.git$/, '')}.git`;
1219
- // Clone only what we extract: a partial (blob:none) + sparse clone fetches
1220
- // just the manifest subpaths' subtrees instead of the whole repo. Falls
1221
- // back to a plain shallow clone if partial-clone/sparse-checkout is
1222
- // unsupported (old git/server) — identical to the prior behavior.
1223
- const cloneSlim = () => {
1224
- if (plan.full) {
1225
- execFileSync('git', ['clone', '--depth', '1', cloneUrl, clonePath], {
1226
- stdio: 'pipe',
1227
- timeout: 30000,
1228
- });
1229
- return;
1230
- }
1231
- try {
1232
- execFileSync(
1233
- 'git',
1234
- ['clone', '--depth', '1', '--filter=blob:none', '--no-checkout', cloneUrl, clonePath],
1235
- { stdio: 'pipe', timeout: 30000 },
1236
- );
1237
- execFileSync('git', ['-C', clonePath, 'sparse-checkout', 'set', '--no-cone', ...plan.paths], {
1238
- stdio: 'pipe',
1239
- timeout: 30000,
1240
- });
1241
- execFileSync('git', ['-C', clonePath, 'checkout'], { stdio: 'pipe', timeout: 30000 });
1242
- } catch {
1243
- try {
1244
- rmSync(clonePath, { recursive: true, force: true });
1245
- } catch {}
1246
- execFileSync('git', ['clone', '--depth', '1', cloneUrl, clonePath], {
1247
- stdio: 'pipe',
1248
- timeout: 30000,
1249
- });
1250
- }
1251
- };
1252
-
1253
- // Migrate a legacy full clone: drop it so the fresh-clone path below
1254
- // rebuilds it slim. managed/repos is a rebuildable cache, so this loses
1255
- // nothing and reclaims the bulk of its footprint on the next install run.
1256
- if (!plan.full && existsSync(clonePath) && !isPartialSparseClone(clonePath)) {
1257
- try {
1258
- rmSync(clonePath, { recursive: true, force: true });
1259
- } catch {}
1260
- }
1261
-
1262
- if (!existsSync(clonePath)) {
1263
- // Fresh clone (also the rebuild path for a just-migrated legacy clone)
1264
- try {
1265
- mkdirSync(join(managedDir, 'repos'), { recursive: true });
1266
- cloneSlim();
1267
- cloned++;
1268
- repoReady = true;
1269
- } catch (err) {
1270
- if (isRepoNotFound(err)) {
1271
- deadRepos.add(repoUrl);
1272
- warn(` Repo not found (removed?): ${repoUrl}`);
1273
- } else {
1274
- warn(` Clone failed: ${repoUrl}`);
1275
- }
1276
- continue;
1277
- }
1278
- } else {
1279
- // Update existing: fetch latest and fast-forward
1280
- try {
1281
- // Re-assert the sparse set so a newer manifest that adds a subpath to
1282
- // an already-slim clone checks it out (idempotent; no-op for full clones).
1283
- if (!plan.full && isPartialSparseClone(clonePath)) {
1284
- try {
1285
- execFileSync(
1286
- 'git',
1287
- ['-C', clonePath, 'sparse-checkout', 'set', '--no-cone', ...plan.paths],
1288
- { stdio: 'pipe', timeout: 30000 },
1289
- );
1290
- } catch {}
1291
- }
1292
- const localHash = execFileSync('git', ['-C', clonePath, 'rev-parse', 'HEAD'], {
1293
- encoding: 'utf8',
1294
- stdio: 'pipe',
1295
- }).trim();
1296
- execFileSync('git', ['-C', clonePath, 'fetch', '--depth', '1', 'origin'], {
1297
- stdio: 'pipe',
1298
- timeout: 30000,
1299
- });
1300
- const remoteHash = execFileSync('git', ['-C', clonePath, 'rev-parse', 'FETCH_HEAD'], {
1301
- encoding: 'utf8',
1302
- stdio: 'pipe',
1303
- }).trim();
1304
- if (localHash !== remoteHash) {
1305
- execFileSync('git', ['-C', clonePath, 'reset', '--hard', 'FETCH_HEAD'], { stdio: 'pipe' });
1306
- updated++;
1307
- repoReady = true; // needs re-copy
1308
- }
1309
- } catch (err) {
1310
- if (isRepoNotFound(err)) {
1311
- deadRepos.add(repoUrl);
1312
- warn(` Repo not found (removed?): ${repoUrl} — cleaning up`);
1313
- // Remove local clone
1314
- try {
1315
- rmSync(clonePath, { recursive: true, force: true });
1316
- } catch {}
1317
- // Remove extracted resources
1318
- for (const entry of entries) {
1319
- const destDir = join(managedDir, entry.type === 'skill' ? 'skills' : 'agents');
1320
- const destPath = join(destDir, entry.name);
1321
- try {
1322
- if (existsSync(destPath)) rmSync(destPath, { recursive: true, force: true });
1323
- } catch {}
1324
- }
1325
- continue;
1326
- }
1327
- // Transient failure — use existing clone as-is
1328
- }
1329
- }
1330
-
1331
- // Copy resources to managed/skills/ or managed/agents/
1332
- // Re-copy if repo was freshly cloned or updated
1333
- mkdirSync(join(managedDir, 'skills'), { recursive: true });
1334
- mkdirSync(join(managedDir, 'agents'), { recursive: true });
1335
- for (const entry of entries) {
1336
- // Path traversal guard: reject entries with '..' or absolute paths
1337
- if (
1338
- entry.path.includes('..') ||
1339
- entry.name.includes('..') ||
1340
- isAbsolute(entry.path) ||
1341
- isAbsolute(entry.name)
1342
- )
1343
- continue;
1344
- const srcPath = entry.path === '.' ? clonePath : join(clonePath, entry.path);
1345
- const destDir = join(managedDir, entry.type === 'skill' ? 'skills' : 'agents');
1346
- const destPath = join(destDir, entry.name);
1347
- if (existsSync(srcPath) && (repoReady || !existsSync(destPath))) {
1348
- try {
1349
- if (existsSync(destPath)) rmSync(destPath, { recursive: true, force: true });
1350
- cpSync(srcPath, destPath, { recursive: true });
1351
- } catch {}
1352
- }
1353
- }
1354
- }
1355
- const managedSize = dirSizeBytes(managedDir);
1356
- ok(
1357
- `Repos: ${cloned} cloned, ${updated} updated, ${repos.size - deadRepos.size} active` +
1358
- (deadRepos.size > 0 ? `, ${deadRepos.size} dead removed` : '') +
1359
- ` (${fmtMB(managedSize.bytes, managedSize.truncated)} in ${managedDir})`,
1360
- );
1361
-
1362
- // 6b. Init registry DB and record preinstalled entries
1363
- const { ensureRegistryDb } = await importFromInstall('registry.mjs');
1364
- const regDbPath = join(MEM_DATA_DIR, 'resource-registry.db');
1365
- const rdb = ensureRegistryDb(regDbPath);
1366
-
1367
- const insertPre = rdb.prepare(`
1368
- INSERT OR REPLACE INTO preinstalled (name, type, repo_url, repo_path, tags, enabled)
1369
- VALUES (?, ?, ?, ?, ?, 1)
1370
- `);
1371
- const activeResources =
1372
- deadRepos.size > 0 ? resources.filter((r) => !deadRepos.has(r.repo)) : resources;
1373
- for (const r of activeResources) {
1374
- insertPre.run(r.name, r.type, r.repo, r.path, JSON.stringify(r.tags || []));
1375
- }
1376
-
1377
- // Clean up DB entries for dead repos
1378
- if (deadRepos.size > 0) {
1379
- const delPre = rdb.prepare('DELETE FROM preinstalled WHERE repo_url = ?');
1380
- const delRes = rdb.prepare('DELETE FROM resources WHERE repo_url = ?');
1381
- for (const deadUrl of deadRepos) {
1382
- try {
1383
- delPre.run(deadUrl);
1384
- } catch {}
1385
- try {
1386
- delRes.run(deadUrl);
1387
- } catch {}
1388
- }
1389
- }
1390
- ok(
1391
- `Registry DB initialized (${activeResources.length} preinstalled entries` +
1392
- (deadRepos.size > 0 ? `, ${deadRepos.size} dead repos purged` : '') +
1393
- ')',
1394
- );
1395
-
1396
- // 6c. Fetch GitHub stars (best-effort, unauthenticated)
1397
- log(' Fetching GitHub stars...');
1398
- const starCache = new Map();
1399
- for (const [repoUrl] of repos) {
1400
- if (deadRepos.has(repoUrl)) continue;
1401
- const match = repoUrl.match(/github\.com\/([^/]+)\/([^/]+)/);
1402
- if (match) {
1403
- try {
1404
- const apiUrl = `https://api.github.com/repos/${match[1]}/${match[2]}`;
1405
- const res = execFileSync('curl', ['-sf', apiUrl], { encoding: 'utf8', timeout: 10000 });
1406
- const data = JSON.parse(res);
1407
- if (typeof data.stargazers_count === 'number') {
1408
- starCache.set(repoUrl, data.stargazers_count);
1409
- }
1410
- } catch {}
1411
- }
1412
- }
1413
- if (starCache.size > 0) ok(`Stars fetched (${starCache.size}/${repos.size} repos)`);
1414
-
1415
- // 6d. Scan and index resources (fallback-only, Haiku indexing deferred to first run)
1416
- log(' Scanning resources...');
1417
- const { scanAllResources, diffResources } = await importFromInstall('registry-scanner.mjs');
1418
- const scanned = scanAllResources({ dataDir: MEM_DATA_DIR });
1419
-
1420
- // Attach star counts and repo URLs
1421
- for (const s of scanned) {
1422
- const entry = resources.find((r) => r.name === s.name && r.type === s.type);
1423
- if (entry) {
1424
- s.repoUrl = entry.repo;
1425
- s.repoStars = starCache.get(entry.repo) || 0;
1426
- }
1427
- }
1428
-
1429
- const { toIndex } = diffResources(rdb, scanned);
1430
- if (toIndex.length > 0) {
1431
- // Use fallback indexing at install time (no Haiku calls)
1432
- // Full Haiku indexing happens on first SessionStart
1433
- const { upsertResource } = await importFromInstall('registry.mjs');
1434
- for (const res of toIndex) {
1435
- try {
1436
- const metaKey = `${res.type}:${res.name}`;
1437
- const meta = RESOURCE_METADATA[metaKey];
1438
- upsertResource(rdb, {
1439
- name: res.name,
1440
- type: res.type,
1441
- status: 'active',
1442
- source: res.source,
1443
- repo_url: res.repoUrl || null,
1444
- repo_stars: res.repoStars || 0,
1445
- local_path: res.localPath,
1446
- file_hash: res.fileHash,
1447
- invocation_name: meta?.invocation_name || deriveInvocationName(res.name),
1448
- intent_tags: meta?.intent_tags || res.name.replace(/-/g, ' '),
1449
- domain_tags: meta?.domain_tags || '',
1450
- trigger_patterns:
1451
- meta?.trigger_patterns || `when user needs ${res.name.replace(/-/g, ' ')}`,
1452
- capability_summary:
1453
- meta?.capability_summary || `${res.type}: ${res.name.replace(/-/g, ' ')}`,
1454
- });
1455
- } catch {}
1456
- }
1457
- ok(`Resources registered: ${toIndex.length} indexed`);
1458
- }
1459
-
1460
- // Apply curated metadata to all known resources (fixes existing installs)
1461
- reindexKnownResources(rdb);
1462
- ok('Resource metadata curated (FTS5 reindexed)');
1463
-
1464
- // Register plugin resources (skills/agents from other plugins, no local files)
1465
- const virtualCount = registerVirtualResources(rdb);
1466
- if (virtualCount > 0) ok(`Plugin resources registered: ${virtualCount} virtual entries`);
1467
-
1468
- rdb.close();
1469
- }
1470
- } else {
1471
- log(' No preinstalled manifest found, skipping');
1472
- }
1473
- } catch (e) {
1474
- warn('Resource setup: ' + e.message);
1475
- log(' Skills/agents will be indexed on first use');
1476
- }
1477
- }
1478
-
1479
869
  function verifyDatabase() {
1480
870
  // 7. Verify database
1481
871
  if (existsSync(DB_PATH)) {
@@ -1557,7 +947,6 @@ async function install() {
1557
947
  const managedHooks = configureHooks();
1558
948
  dedupePluginCacheAndHooks({ managedHooks });
1559
949
  backupLegacyClaudeMemData();
1560
- await installPreinstalledResources();
1561
950
  verifyDatabase();
1562
951
  await dogfoodAutoAdopt();
1563
952
  disableOldClaudeMemPlugin();
@@ -2480,29 +1869,6 @@ async function doctor() {
2480
1869
  }
2481
1870
  }
2482
1871
 
2483
- // Env flags that are ACCEPTED but do nothing. A flag a user set and believes is
2484
- // in effect is a silent lie the rest of doctor cannot see: every other check here
2485
- // asks whether the install is healthy, and this install is perfectly healthy while
2486
- // behaving as though the flag were unset (audit 2026-08-22 P2-5).
2487
- // Dynamically imported, and the mode table is read from the module that owns it:
2488
- // a static import would drag the registry retriever's dependency chain into a tool
2489
- // whose whole job is to run when the tree is broken, and a local copy of the list
2490
- // is the drift shape this repo keeps paying for.
2491
- try {
2492
- const { getRequestedRecommendMode, RECOMMEND_MODE_UNIMPLEMENTED } =
2493
- await import('./registry-recommend.mjs');
2494
- const requested = getRequestedRecommendMode();
2495
- if (RECOMMEND_MODE_UNIMPLEMENTED.has(requested)) {
2496
- dwarn(
2497
- `CLAUDE_MEM_RECOMMEND_MODE=${requested}: accepted but NOT implemented — ` +
2498
- 'live skill-recommendation injection is Phase 2 and does not exist. The engine ' +
2499
- 'is running in shadow (logs only, injects nothing). Set shadow or off.',
2500
- );
2501
- }
2502
- } catch (e) {
2503
- dwarn('Env flags: check failed — ' + e.message);
2504
- }
2505
-
2506
1872
  // Plugin cache versions
2507
1873
  const pluginCacheBase = join(homedir(), '.claude', 'plugins', 'cache', MARKETPLACE_KEY, 'claude-mem-lite');
2508
1874
  if (existsSync(pluginCacheBase)) {
@@ -2551,30 +1917,6 @@ async function doctor() {
2551
1917
 
2552
1918
  // ─── Settings helpers ───────────────────────────────────────────────────────
2553
1919
 
2554
- // Identify a settings hook as one of OURS (to replace on install / strip on uninstall).
2555
- // Must be tight: the old `hook.mjs` + event-word test matched a user's OWN generic hook
2556
- // (`node ~/.config/hook.mjs session-start`) and install/uninstall silently deleted it.
2557
- // The launcher marker (`hook-launcher.mjs`, which every Node hook routes through since v2.84)
2558
- // replaces that clause; the product-name substring (our install-dir / legacy-direct hooks)
2559
- // and the bash prefilter round out the real markers.
2560
- export function isMemHook(cfg) {
2561
- if (!cfg.hooks) return false;
2562
- return cfg.hooks.some((h) => {
2563
- const cmd = h.command || '';
2564
- return (
2565
- cmd.includes('claude-mem-lite') ||
2566
- cmd.includes('hook-launcher.mjs') ||
2567
- cmd.includes('scripts/post-tool-use.sh') ||
2568
- // Same reason post-tool-use.sh is named here: a bash prefilter routes through
2569
- // NO launcher, and the product-name clause only fires when the install dir
2570
- // happens to contain it — which CLAUDE_MEM_DIR can relocate. Without this line
2571
- // an Agent|Task hook in a relocated install survives uninstall and duplicates
2572
- // on reinstall (audit 2026-08-22 P2-5 added the second prefilter).
2573
- cmd.includes('scripts/pre-agent-inject.sh')
2574
- );
2575
- });
2576
- }
2577
-
2578
1920
  function hasMemHooksConfigured(settings) {
2579
1921
  if (!settings?.hooks) return false;
2580
1922
  return Object.values(settings.hooks).some(
@@ -2616,7 +1958,7 @@ function looksLikeHookPath(p) {
2616
1958
  return HOOK_PATH_EXTS.some((ext) => p.endsWith(ext));
2617
1959
  }
2618
1960
 
2619
- export function collectOrphanHookPaths(settings) {
1961
+ export function collectOrphanHookPaths(settings, installDir = INSTALL_DIR) {
2620
1962
  if (!settings?.hooks) return [];
2621
1963
  const out = [];
2622
1964
  for (const configs of Object.values(settings.hooks)) {
@@ -2626,6 +1968,14 @@ export function collectOrphanHookPaths(settings) {
2626
1968
  for (const h of cfg.hooks || []) {
2627
1969
  const cmd = h.command || '';
2628
1970
  if (cmd.includes('${CLAUDE_PLUGIN_ROOT}')) continue;
1971
+ // The launcher's entry argument, which is unquoted and so invisible to the
1972
+ // quoted-token scan below. Recorded IN ADDITION to that scan, never instead of
1973
+ // it: a `continue` here shadowed a missing `scripts/hook-launcher.mjs` — the
1974
+ // half-applied-update shape where every hook fire dies with
1975
+ // ERR_MODULE_NOT_FOUND — and doctor went back to printing "Orphan hooks: none".
1976
+ // Both files have to exist for the hook to run, so both are reportable.
1977
+ const entry = launcherEntryPath(cmd, installDir);
1978
+ if (entry && !existsSync(entry) && !out.includes(entry)) out.push(entry);
2629
1979
  // v2.80: scan ALL quoted tokens (was: only the first), prefer ones
2630
1980
  // that look like a hook path. Fixes a footgun where a wrapper command
2631
1981
  // like `bash -c "some inline" "/real/path.sh"` would pick "some inline"
@@ -2661,11 +2011,11 @@ export function collectOrphanHookPaths(settings) {
2661
2011
  * - 0-byte `.db` files that are NOT in the protected-db allow-list
2662
2012
  *
2663
2013
  * Protections (never touched):
2664
- * - subdirectories (managed/, runtime/, scripts/, lib/, cli/, commands/, server/, node_modules/, .claude-plugin/, registry/, etc.)
2014
+ * - subdirectories (runtime/, scripts/, lib/, cli/, commands/, server/, node_modules/, .claude-plugin/, etc.)
2665
2015
  * - non-empty `.db` files — real data risk, always preserved
2666
2016
  * - WAL/SHM (`*-wal`, `*-shm`) transients
2667
2017
  * - files not ending in `.mjs` or `.db`
2668
- * - the two canonical DBs (`claude-mem-lite.db`, `resource-registry.db`) even when 0-byte (fresh-install transient state)
2018
+ * - the canonical DB (`claude-mem-lite.db`) even when 0-byte (fresh-install transient state)
2669
2019
  *
2670
2020
  * @param {string} dataDir Absolute path, typically `~/.claude-mem-lite`
2671
2021
  * @param {string[]} sourceFiles SOURCE_FILES manifest
@@ -2677,7 +2027,15 @@ export function pruneStaleInstallFiles(dataDir, sourceFiles) {
2677
2027
  // like 'lib/activity.mjs' — those belong to a subdir and prune never touches
2678
2028
  // subdirs anyway. For top-level entries ('server.mjs'), basename === entry.
2679
2029
  const topLevelAllowed = new Set(sourceFiles.filter((f) => !f.includes('/')).map((f) => f));
2680
- const PROTECTED_DBS = new Set(['claude-mem-lite.db', 'resource-registry.db']);
2030
+ // `resource-registry.db` came off this list with the registry itself. My first pass kept
2031
+ // it on a wrong premise — "a user upgrading from <=4.x might lose data" — but the prune
2032
+ // below gates on `st.size === 0`, so a registry DB with anything in it was never
2033
+ // reachable here in the first place. The only file the entry saved was a ZERO-BYTE one,
2034
+ // which holds no user data and is exactly the stale artifact this function exists to
2035
+ // clear. Nothing creates the file any more, so the "fresh-install transient state"
2036
+ // rationale that keeps `claude-mem-lite.db` here does not transfer. (R9 review F8a
2037
+ // corrected the premise; the CHANGELOG still tells users where the real file lives.)
2038
+ const PROTECTED_DBS = new Set(['claude-mem-lite.db']);
2681
2039
  const removed = [];
2682
2040
  let entries;
2683
2041
  try {