claude-mem-lite 2.30.1 → 2.31.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.
@@ -10,7 +10,7 @@
10
10
  "plugins": [
11
11
  {
12
12
  "name": "claude-mem-lite",
13
- "version": "2.30.1",
13
+ "version": "2.31.1",
14
14
  "source": "./",
15
15
  "description": "Lightweight persistent memory system for Claude Code — FTS5 search, episode batching, error-triggered recall"
16
16
  }
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "claude-mem-lite",
3
- "version": "2.30.1",
3
+ "version": "2.31.1",
4
4
  "description": "Lightweight persistent memory system for Claude Code — FTS5 search, episode batching, error-triggered recall",
5
5
  "author": {
6
6
  "name": "sdsrss"
package/cli.mjs CHANGED
@@ -1,5 +1,5 @@
1
1
  #!/usr/bin/env node
2
- const CLI_COMMANDS = new Set(['search', 'recent', 'recall', 'get', 'timeline', 'save', 'stats', 'context', 'browse', 'delete', 'update', 'export', 'compress', 'maintain', 'optimize', 'fts-check', 'registry', 'import', 'enrich', 'help']);
2
+ const CLI_COMMANDS = new Set(['search', 'recent', 'recall', 'get', 'timeline', 'save', 'stats', 'context', 'browse', 'delete', 'update', 'export', 'compress', 'maintain', 'optimize', 'fts-check', 'registry', 'import', 'enrich', 'activity', 'help']);
3
3
  const INSTALL_COMMANDS = new Set(['install', 'uninstall', 'status', 'doctor', 'cleanup', 'cleanup-hooks', 'self-update', 'release']);
4
4
 
5
5
  const cmd = process.argv[2];
@@ -13,6 +13,11 @@ if (cmd === '--version' || cmd === '-v') {
13
13
  } else if (cmd === '--help' || cmd === '-h') {
14
14
  const { run } = await import('./mem-cli.mjs');
15
15
  await run(['help']);
16
+ } else if (cmd === 'doctor' && process.argv.slice(3).includes('--benchmark')) {
17
+ // doctor --benchmark is a baseline-capture tool, routed through mem-cli (DB layer);
18
+ // plain `doctor` continues to run the install health-check below.
19
+ const { run } = await import('./mem-cli.mjs');
20
+ await run(process.argv.slice(2));
16
21
  } else if (CLI_COMMANDS.has(cmd)) {
17
22
  const { run } = await import('./mem-cli.mjs');
18
23
  await run(process.argv.slice(2));
package/hook-handoff.mjs CHANGED
@@ -6,6 +6,11 @@ import { truncate, extractMatchKeywords, tokenizeHandoff, isSpecificTerm, LOW_SI
6
6
  import {
7
7
  HANDOFF_EXPIRY_CLEAR, HANDOFF_EXPIRY_EXIT, HANDOFF_MATCH_THRESHOLD, CONTINUE_KEYWORDS,
8
8
  } from './hook-shared.mjs';
9
+ // T10d: import the whole module (not a named export) so tests can spy on
10
+ // gitStateModule.readGitState via vi.spyOn. Named-import bindings are
11
+ // immutable in ESM and cannot be mocked after the fact.
12
+ import * as gitStateModule from './lib/git-state.mjs';
13
+ import * as taskReaderModule from './lib/task-reader.mjs';
9
14
 
10
15
  /**
11
16
  * Build and save a handoff snapshot to session_handoffs table.
@@ -51,6 +56,24 @@ export function buildAndSaveHandoff(db, sessionId, project, type, episodeSnapsho
51
56
  .filter(d => { if (seenDescs.has(d)) return false; seenDescs.add(d); return true; });
52
57
  if (pendingDescs.length > 0) unfinished = pendingDescs.join('; ');
53
58
  }
59
+
60
+ // T10d: TaskList-sourced Unfinished. When no episode pending entries exist,
61
+ // prefer the structured signal from ~/.claude/tasks/<list>/*.json over the
62
+ // narrative-only fallback — a user-maintained task list is a stronger signal
63
+ // than a session with no recent tool activity. When the episode already has
64
+ // pending entries, those stay (they're fresher than the task file).
65
+ if (!unfinished) {
66
+ try {
67
+ const tasks = taskReaderModule.readProjectTasks({ projectPath: process.cwd() });
68
+ if (tasks.length > 0) {
69
+ unfinished = tasks
70
+ .slice(0, 5)
71
+ .map(t => `[${t.status}] ${t.title}`)
72
+ .join('\n');
73
+ }
74
+ } catch { /* task reader is best-effort; never block handoff */ }
75
+ }
76
+
54
77
  // Enrich unfinished with full session edit history from observation narratives.
55
78
  // Since handoff is UPSERT (max 2 rows per project), storing more data is free.
56
79
  // Always use \n---\n separator so extractUnfinishedSummary can distinguish
@@ -89,11 +112,18 @@ export function buildAndSaveHandoff(db, sessionId, project, type, episodeSnapsho
89
112
  const allText = [workingOn, ...completed.map(c => c.title).filter(Boolean), unfinished].join(' ');
90
113
  const keywords = extractMatchKeywords(allText, [...fileSet]);
91
114
 
115
+ // T10d: capture HEAD sha so detectContinuationIntent can anchor on it later.
116
+ // Best-effort — failures (non-git dir, missing binary, timeout) yield null.
117
+ let gitShaAtHandoff = null;
118
+ try {
119
+ gitShaAtHandoff = gitStateModule.readGitState({ cwd: process.cwd() }).headSha || null;
120
+ } catch { /* swallow — handoff must still persist */ }
121
+
92
122
  // UPSERT keyed on (project, type, session_id) — parallel sessions coexist.
93
123
  // Same session re-writing its own handoff (e.g. repeated /clear) updates in place.
94
124
  db.prepare(`
95
- INSERT INTO session_handoffs (project, type, session_id, working_on, completed, unfinished, key_files, key_decisions, match_keywords, created_at_epoch)
96
- VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
125
+ INSERT INTO session_handoffs (project, type, session_id, working_on, completed, unfinished, key_files, key_decisions, match_keywords, created_at_epoch, git_sha_at_handoff)
126
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
97
127
  ON CONFLICT(project, type, session_id) DO UPDATE SET
98
128
  working_on = excluded.working_on,
99
129
  completed = excluded.completed,
@@ -101,7 +131,8 @@ export function buildAndSaveHandoff(db, sessionId, project, type, episodeSnapsho
101
131
  key_files = excluded.key_files,
102
132
  key_decisions = excluded.key_decisions,
103
133
  match_keywords = excluded.match_keywords,
104
- created_at_epoch = excluded.created_at_epoch
134
+ created_at_epoch = excluded.created_at_epoch,
135
+ git_sha_at_handoff = excluded.git_sha_at_handoff
105
136
  `).run(
106
137
  project, type, sessionId,
107
138
  truncate(workingOn, 1000),
@@ -110,7 +141,8 @@ export function buildAndSaveHandoff(db, sessionId, project, type, episodeSnapsho
110
141
  JSON.stringify([...fileSet].slice(0, 20)),
111
142
  decisions.map(d => d.title).join('\n'),
112
143
  keywords,
113
- Date.now()
144
+ Date.now(),
145
+ gitShaAtHandoff,
114
146
  );
115
147
  }
116
148
 
@@ -137,6 +169,26 @@ export function detectContinuationIntent(db, promptText, project, currentCcSessi
137
169
  if (!promptText || typeof promptText !== 'string') return false;
138
170
  if (promptText.trim().length < 2) return false;
139
171
 
172
+ // T10d Stage -1: Git-commit anchor — if ANY handoff (any age) has a
173
+ // git_sha_at_handoff matching current HEAD, the working tree hasn't moved
174
+ // since that handoff, so assume continuation regardless of time / prompt.
175
+ //
176
+ // Trade-off: after weeks of no commits this fires aggressively. Users can
177
+ // reset by making a commit or by typing a long unrelated prompt (this
178
+ // anchor runs BEFORE the Stage 0 long-prompt guard, so that escape hatch
179
+ // does not apply here). This is an MVP choice — see plan 10d concern.
180
+ try {
181
+ const currentSha = gitStateModule.readGitState({ cwd: process.cwd() }).headSha;
182
+ if (currentSha) {
183
+ const anchor = db.prepare(`
184
+ SELECT 1 FROM session_handoffs
185
+ WHERE project = ? AND git_sha_at_handoff = ?
186
+ ORDER BY created_at_epoch DESC LIMIT 1
187
+ `).get(project, currentSha);
188
+ if (anchor) return true;
189
+ }
190
+ } catch { /* git/DB failure must not break the rest of the pipeline */ }
191
+
140
192
  // Stage 0: Non-expired 'clear' handoff — assume continuation unless long unrelated prompt.
141
193
  // Session scoping: with currentCcSessionId, only your OWN clear handoff qualifies.
142
194
  const clearHandoff = currentCcSessionId
package/hook-llm.mjs CHANGED
@@ -15,6 +15,11 @@ import {
15
15
  RUNTIME_DIR, DEDUP_WINDOW_MS, RELATED_OBS_WINDOW_MS,
16
16
  sessionFile, getSessionId, openDb, callLLM, sleep,
17
17
  } from './hook-shared.mjs';
18
+ import { EVENT_TYPES, saveEvent } from './lib/activity.mjs';
19
+
20
+ // T9: memdir-incompatible types live in the `events` table, not `observations`.
21
+ // Set lookup is O(1) — authoritative source is lib/activity.mjs::EVENT_TYPES.
22
+ const EVENT_TYPE_SET = new Set(EVENT_TYPES);
18
23
 
19
24
  // ─── Save Observation to DB ─────────────────────────────────────────────────
20
25
 
@@ -172,6 +177,96 @@ export function saveObservation(obs, projectOverride, sessionIdOverride, externa
172
177
  }
173
178
  }
174
179
 
180
+ // ─── obs → summary shape mapping ────────────────────────────────────────────
181
+ // handleLLMEpisode's internal `obs` uses camelCase/legacy names (lessonLearned,
182
+ // files, filesRead). persistHaikuSummary takes the plan's stable snake_case
183
+ // shape. Keep the mapping here so the dispatcher's public signature stays
184
+ // clean for external callers that already use the plan shape.
185
+ function obsToSummary(obs) {
186
+ return {
187
+ type: obs.type,
188
+ title: obs.title,
189
+ subtitle: obs.subtitle,
190
+ narrative: obs.narrative,
191
+ concepts: obs.concepts,
192
+ facts: obs.facts,
193
+ files_modified: obs.files,
194
+ files_read: obs.filesRead,
195
+ importance: obs.importance,
196
+ lesson_learned: obs.lessonLearned,
197
+ search_aliases: obs.searchAliases,
198
+ };
199
+ }
200
+
201
+ // ─── T9: Haiku Summary Dispatcher (events vs observations routing) ──────────
202
+ //
203
+ // Routes memdir-incompatible types (the 8 values in EVENT_TYPES) to the
204
+ // activity `events` table, and keeps legacy/memdir-aligned types (e.g.
205
+ // `change`, which is the only non-event type hook-llm currently emits) on
206
+ // the existing observations path.
207
+ //
208
+ // Input shape (matches the v2.31 MVP plan's stable interface):
209
+ // summary: { type, title, lesson_learned?, narrative?, importance?, files_modified? }
210
+ // ctx: { project, session_id, preSavedObsId? }
211
+ //
212
+ // Returns { table: 'events'|'observations', id: number|null }. Callers inspect
213
+ // `table` to decide whether follow-up observations-only logic (linking, vector
214
+ // refresh) applies.
215
+ //
216
+ // NOTE: The foreground pre-save in hook.mjs:110/336 intentionally still writes
217
+ // to `observations` for immediate visibility. When the background worker
218
+ // processes the episode (handleLLMEpisode), it passes the pre-saved id in
219
+ // `ctx.preSavedObsId`; this dispatcher then deletes the pre-saved observations
220
+ // row and inserts a fresh event for event-typed summaries (upgrade-delete
221
+ // semantics). Observations-typed summaries reuse the pre-saved row directly
222
+ // (caller handles the UPDATE, since it needs the enriched FTS fields).
223
+ export function persistHaikuSummary(db, summary, ctx) {
224
+ if (EVENT_TYPE_SET.has(summary.type)) {
225
+ // Upgrade-delete: the foreground pre-save landed in `observations` with a
226
+ // rule-inferred type; now that Haiku has classified it as an event type,
227
+ // we must remove the stale observations row before inserting the event.
228
+ // Atomic via better-sqlite3 transaction: either both succeed or neither.
229
+ const insertEvent = () => saveEvent(db, {
230
+ project: ctx.project,
231
+ event_type: summary.type,
232
+ title: summary.title,
233
+ body: summary.lesson_learned || summary.narrative || null,
234
+ file_paths: (Array.isArray(summary.files_modified) && summary.files_modified.length > 0)
235
+ ? summary.files_modified
236
+ : null,
237
+ importance: summary.importance ?? 1,
238
+ created_at_epoch: Date.now(),
239
+ });
240
+
241
+ if (ctx.preSavedObsId) {
242
+ const id = db.transaction(() => {
243
+ db.prepare(`DELETE FROM observations WHERE id = ?`).run(ctx.preSavedObsId);
244
+ return insertEvent();
245
+ })();
246
+ return { table: 'events', id };
247
+ }
248
+
249
+ return { table: 'events', id: insertEvent() };
250
+ }
251
+
252
+ // Fallthrough: memdir-compatible / legacy types use the observations path.
253
+ // Map the Haiku/plan field names to saveObservation's expected shape.
254
+ const id = saveObservation({
255
+ type: summary.type,
256
+ title: summary.title,
257
+ subtitle: summary.subtitle || '',
258
+ narrative: summary.narrative || '',
259
+ concepts: summary.concepts || [],
260
+ facts: summary.facts || [],
261
+ files: summary.files_modified || [],
262
+ filesRead: summary.files_read || [],
263
+ importance: summary.importance ?? 1,
264
+ lessonLearned: summary.lesson_learned || null,
265
+ searchAliases: summary.search_aliases || null,
266
+ }, ctx.project, ctx.session_id, db);
267
+ return { table: 'observations', id };
268
+ }
269
+
175
270
  // ─── Related Observation Linking ─────────────────────────────────────────────
176
271
 
177
272
  function linkRelatedObservations(db, savedId, obs, episode) {
@@ -504,46 +599,72 @@ search_aliases: 2-6 alternative search terms someone might use to find this memo
504
599
 
505
600
  try {
506
601
  let savedId;
602
+ let savedTable;
507
603
 
508
604
  if (episode.savedId && obs) {
509
- // Upgrade pre-saved observation with LLM-enriched data
510
- const { conceptsText, factsText, textField } = buildFtsTextField(obs);
511
- const minhashSig = computeMinHash((obs.title || '') + ' ' + (obs.narrative || ''));
512
- db.prepare(`
513
- UPDATE observations SET type=?, title=?, subtitle=?, narrative=?, concepts=?, facts=?,
514
- text=?, importance=?, files_read=?, minhash_sig=?, lesson_learned=?, search_aliases=?
515
- WHERE id = ?
516
- `).run(
517
- obs.type, truncate(obs.title, 120), obs.subtitle || '',
518
- truncate(obs.narrative || '', 500),
519
- conceptsText, factsText, textField,
520
- obs.importance,
521
- JSON.stringify(obs.filesRead || []),
522
- minhashSig,
523
- obs.lessonLearned || null,
524
- obs.searchAliases || null,
525
- episode.savedId
526
- );
527
- savedId = episode.savedId;
528
- debugLog('DEBUG', 'llm-episode', `upgraded pre-saved obs #${savedId}`);
529
-
530
- // Update TF-IDF vector with enriched content
531
- try {
532
- const vocab = getVocabulary(db);
533
- if (vocab) {
534
- const vecText = [obs.title || '', obs.narrative || '', conceptsText].filter(Boolean).join(' ');
535
- const vec = computeVector(vecText, vocab);
536
- if (vec) {
537
- db.prepare('INSERT OR REPLACE INTO observation_vectors (observation_id, vector, vocab_version, created_at_epoch) VALUES (?, ?, ?, ?)')
538
- .run(savedId, Buffer.from(vec.buffer), vocab.version, Date.now());
605
+ if (EVENT_TYPE_SET.has(obs.type)) {
606
+ // Upgrade-delete: pre-saved observation's rule-inferred type was later
607
+ // classified by Haiku as an event type. Delete the stale observations
608
+ // row and insert into events instead. Dispatcher handles the atomic
609
+ // swap via transaction.
610
+ const result = persistHaikuSummary(db, obsToSummary(obs), {
611
+ project: episode.project,
612
+ session_id: episode.sessionId,
613
+ preSavedObsId: episode.savedId,
614
+ });
615
+ savedId = result.id;
616
+ savedTable = result.table;
617
+ debugLog('DEBUG', 'llm-episode', `upgrade-delete: obs #${episode.savedId} event #${savedId}`);
618
+ } else {
619
+ // Non-event type (e.g. `change`) — upgrade pre-saved observations row in place
620
+ // so the enriched FTS text field + minhash + vector are refreshed atomically.
621
+ const { conceptsText, factsText, textField } = buildFtsTextField(obs);
622
+ const minhashSig = computeMinHash((obs.title || '') + ' ' + (obs.narrative || ''));
623
+ db.prepare(`
624
+ UPDATE observations SET type=?, title=?, subtitle=?, narrative=?, concepts=?, facts=?,
625
+ text=?, importance=?, files_read=?, minhash_sig=?, lesson_learned=?, search_aliases=?
626
+ WHERE id = ?
627
+ `).run(
628
+ obs.type, truncate(obs.title, 120), obs.subtitle || '',
629
+ truncate(obs.narrative || '', 500),
630
+ conceptsText, factsText, textField,
631
+ obs.importance,
632
+ JSON.stringify(obs.filesRead || []),
633
+ minhashSig,
634
+ obs.lessonLearned || null,
635
+ obs.searchAliases || null,
636
+ episode.savedId
637
+ );
638
+ savedId = episode.savedId;
639
+ savedTable = 'observations';
640
+ debugLog('DEBUG', 'llm-episode', `upgraded pre-saved obs #${savedId}`);
641
+
642
+ // Update TF-IDF vector with enriched content
643
+ try {
644
+ const vocab = getVocabulary(db);
645
+ if (vocab) {
646
+ const vecText = [obs.title || '', obs.narrative || '', conceptsText].filter(Boolean).join(' ');
647
+ const vec = computeVector(vecText, vocab);
648
+ if (vec) {
649
+ db.prepare('INSERT OR REPLACE INTO observation_vectors (observation_id, vector, vocab_version, created_at_epoch) VALUES (?, ?, ?, ?)')
650
+ .run(savedId, Buffer.from(vec.buffer), vocab.version, Date.now());
651
+ }
539
652
  }
540
- }
541
- } catch (e) { debugCatch(e, 'handleLLMEpisode-vector'); }
653
+ } catch (e) { debugCatch(e, 'handleLLMEpisode-vector'); }
654
+ }
542
655
  } else {
543
- savedId = saveObservation(obs, episode.project, episode.sessionId, db);
656
+ // Clean insert (no pre-save) — dispatcher routes by type.
657
+ const result = persistHaikuSummary(db, obsToSummary(obs), {
658
+ project: episode.project,
659
+ session_id: episode.sessionId,
660
+ });
661
+ savedId = result.id;
662
+ savedTable = result.table;
544
663
  }
545
664
 
546
- if (savedId) {
665
+ // Related-observation linking only applies to rows in `observations` —
666
+ // the `events` table has its own lifecycle (supersede/accessed_count).
667
+ if (savedId && savedTable === 'observations') {
547
668
  try {
548
669
  linkRelatedObservations(db, savedId, obs, episode);
549
670
  } catch (e) { debugCatch(e, 'relatedObsLinking'); }
package/hook.mjs CHANGED
@@ -32,6 +32,7 @@ import { searchRelevantMemories } from './hook-memory.mjs';
32
32
  import { buildAndSaveHandoff, detectContinuationIntent, renderHandoffInjection, extractUnfinishedSummary } from './hook-handoff.mjs';
33
33
  import { checkForUpdate } from './hook-update.mjs';
34
34
  import { handleLLMOptimize } from './hook-optimize.mjs';
35
+ import { clearPluginCacheHooks, hasInstallManagedHooks } from './plugin-cache-guard.mjs';
35
36
  import { SKIP_TOOLS, SKIP_PREFIXES } from './skip-tools.mjs';
36
37
  import { getVocabulary } from './tfidf.mjs';
37
38
 
@@ -397,6 +398,21 @@ async function handleStop() {
397
398
  // ─── SessionStart Handler + CLAUDE.md Persistence (Tier 1 A, E) ─────────────
398
399
 
399
400
  async function handleSessionStart() {
401
+ // Plugin cache self-heal: Claude Code auto-updates the marketplace plugin can
402
+ // re-populate cache/<ver>/hooks/hooks.json, reintroducing duplicate hook
403
+ // registration alongside install.mjs-managed settings.json entries. Silently
404
+ // clear — gated by hasInstallManagedHooks to avoid breaking plugin-only users.
405
+ try {
406
+ if (hasInstallManagedHooks()) {
407
+ const cleared = clearPluginCacheHooks({
408
+ reason: 'Auto-healed by hook.mjs session-start — install.mjs-managed hooks active in settings.json',
409
+ });
410
+ if (cleared.length > 0) {
411
+ debugLog('DEBUG', 'session-start', `auto-healed stale plugin cache hooks.json in version(s): ${cleared.join(', ')}`);
412
+ }
413
+ }
414
+ } catch (e) { debugCatch(e, 'session-start-cache-heal'); }
415
+
400
416
  // Read CC real session_id from hook stdin — used to scope handoff rows so parallel
401
417
  // sessions for the same project don't clobber each other (see docs/bug.txt).
402
418
  let ccSessionId = null;
@@ -716,6 +732,23 @@ async function handleSessionStart() {
716
732
  } catch (e) { debugCatch(e, 'session-start-exit-fast-summary'); }
717
733
  }
718
734
 
735
+ // T10c: Startup dashboard — aggregate git/tasks/plans/handoff/events into a
736
+ // structured JSON hookSpecificOutput block. Emitted BEFORE the plain-text
737
+ // <claude-mem-context> so both surfaces coexist. Empty string → skip.
738
+ try {
739
+ const { buildDashboard } = await import('./lib/startup-dashboard.mjs');
740
+ const dashboardText = buildDashboard({ db, project, projectPath: process.cwd() });
741
+ if (dashboardText) {
742
+ process.stdout.write(JSON.stringify({
743
+ suppressOutput: true,
744
+ hookSpecificOutput: {
745
+ hookEventName: 'SessionStart',
746
+ additionalContext: dashboardText,
747
+ },
748
+ }) + '\n');
749
+ }
750
+ } catch (e) { debugCatch(e, 'session-start-dashboard'); }
751
+
719
752
  // Build the full context body via shared helper (also used by `mem-cli context`).
720
753
  // Queries session_summaries, key observations, clear handoff, and the
721
754
  // token-budgeted observation pool directly from the DB.
package/install.mjs CHANGED
@@ -26,6 +26,7 @@ const PLUGIN_KEY = `claude-mem-lite@${MARKETPLACE_KEY}`;
26
26
  const NPM_INSTALL_CMD = 'npm install --omit=dev --no-audit --no-fund';
27
27
 
28
28
  import { RESOURCE_METADATA } from './install-metadata.mjs';
29
+ import { scanPluginCacheHookPollution } from './plugin-cache-guard.mjs';
29
30
 
30
31
  /**
31
32
  * Derive invocation_name from resource name when metadata doesn't provide one.
@@ -205,12 +206,24 @@ async function install() {
205
206
  'cli.mjs', 'server.mjs', 'server-internals.mjs', 'tool-schemas.mjs',
206
207
  'hook.mjs', 'hook-shared.mjs', 'hook-llm.mjs', 'hook-memory.mjs', 'skip-tools.mjs',
207
208
  'hook-semaphore.mjs', 'hook-episode.mjs', 'hook-context.mjs', 'hook-handoff.mjs', 'hook-update.mjs', 'hook-optimize.mjs',
209
+ 'plugin-cache-guard.mjs',
208
210
  'haiku-client.mjs', 'utils.mjs', 'schema.mjs', 'package.json', 'package-lock.json', 'skill.md',
209
211
  'registry.mjs', 'registry-scanner.mjs', 'registry-indexer.mjs',
210
212
  'registry-retriever.mjs', 'resource-discovery.mjs',
211
213
  'install-metadata.mjs', 'mem-cli.mjs', 'tier.mjs', 'tfidf.mjs',
212
214
  'nlp.mjs', 'synonyms.mjs', 'scoring-sql.mjs', 'stop-words.mjs', 'project-utils.mjs',
213
215
  'secret-scrub.mjs', 'format-utils.mjs', 'hash-utils.mjs', 'bash-utils.mjs',
216
+ // v2.31 T9: hook-llm now statically imports lib/activity.mjs (events routing).
217
+ // SOURCE_FILES entries with a subdir prefix require install.mjs to mkdir the
218
+ // parent before symlink/copy — handled in the IS_DEV and else-branch loops.
219
+ 'lib/activity.mjs',
220
+ // v2.31 T10: startup dashboard aggregator + handoff task/git anchoring.
221
+ // task-reader + plan-reader + git-state are read by lib/startup-dashboard.mjs
222
+ // on SessionStart (hook.mjs) and by hook-handoff.mjs on /exit + /clear.
223
+ 'lib/task-reader.mjs',
224
+ 'lib/plan-reader.mjs',
225
+ 'lib/git-state.mjs',
226
+ 'lib/startup-dashboard.mjs',
214
227
  ];
215
228
 
216
229
  if (IS_DEV) {
@@ -220,6 +233,9 @@ async function install() {
220
233
  const target = join(PROJECT_DIR, f);
221
234
  const link = join(DATA_DIR, f);
222
235
  if (existsSync(target)) {
236
+ // Ensure parent dir exists for subdir entries (e.g. 'lib/activity.mjs')
237
+ const linkParent = dirname(link);
238
+ if (!existsSync(linkParent)) mkdirSync(linkParent, { recursive: true });
223
239
  // Remove existing file/symlink before creating
224
240
  if (existsSync(link)) try { unlinkSync(link); } catch {}
225
241
  symlinkSync(target, link);
@@ -252,7 +268,13 @@ async function install() {
252
268
  if (!existsSync(scriptsDir)) mkdirSync(scriptsDir, { recursive: true });
253
269
  for (const f of SOURCE_FILES) {
254
270
  const src = join(PROJECT_DIR, f);
255
- if (existsSync(src)) copyFileSync(src, join(DATA_DIR, f));
271
+ const dst = join(DATA_DIR, f);
272
+ if (existsSync(src)) {
273
+ // Ensure parent dir exists for subdir entries (e.g. 'lib/activity.mjs')
274
+ const dstParent = dirname(dst);
275
+ if (!existsSync(dstParent)) mkdirSync(dstParent, { recursive: true });
276
+ copyFileSync(src, dst);
277
+ }
256
278
  }
257
279
  // Copy scripts
258
280
  const postToolSrc = join(PROJECT_DIR, 'scripts', 'post-tool-use.sh');
@@ -381,18 +403,43 @@ async function install() {
381
403
  }
382
404
  } catch (e) { warn(`Marketplace hooks dedup: ${e.message}`); }
383
405
 
384
- // Sync launch.mjs to plugin cache — ensures MCP server loads dev code via symlink detection
406
+ // Sync launch.mjs to plugin cache — ensures MCP server loads dev code via symlink detection.
407
+ // ALSO clear cached hooks.json in every version dir — Claude Code runtime reads hooks from
408
+ // ~/.claude/plugins/cache/<mp>/<plugin>/<ver>/hooks/hooks.json, NOT from the marketplace source.
409
+ // Clearing only the marketplace source (above) leaves stale cache copies that double-register
410
+ // hooks alongside install.mjs-written settings.json entries.
385
411
  try {
386
412
  const cacheBase = join(homedir(), '.claude', 'plugins', 'cache', MARKETPLACE_KEY, 'claude-mem-lite');
387
413
  if (existsSync(cacheBase)) {
388
414
  const srcLaunch = join(PROJECT_DIR, 'scripts', 'launch.mjs');
415
+ let clearedHooks = 0;
389
416
  for (const ver of readdirSync(cacheBase)) {
390
- const dest = join(cacheBase, ver, 'scripts', 'launch.mjs');
391
- if (existsSync(join(cacheBase, ver, 'scripts'))) {
392
- copyFileSync(srcLaunch, dest);
417
+ const verDir = join(cacheBase, ver);
418
+
419
+ // Sync launch.mjs
420
+ if (existsSync(join(verDir, 'scripts'))) {
421
+ try { copyFileSync(srcLaunch, join(verDir, 'scripts', 'launch.mjs')); } catch {}
422
+ }
423
+
424
+ // Clear cached hooks.json (runtime reads here, not marketplace source)
425
+ const cachedHooksPath = join(verDir, 'hooks', 'hooks.json');
426
+ if (existsSync(cachedHooksPath)) {
427
+ try {
428
+ const h = JSON.parse(readFileSync(cachedHooksPath, 'utf8'));
429
+ if (h.hooks && Object.keys(h.hooks).length > 0) {
430
+ writeFileSync(cachedHooksPath, JSON.stringify({
431
+ description: h.description || 'claude-mem-lite hooks',
432
+ _note: `Hooks managed by install.mjs in settings.json — cache hooks.json cleared to prevent duplicate registration (cache ver: ${ver})`,
433
+ hooks: {}
434
+ }, null, 2) + '\n');
435
+ clearedHooks++;
436
+ }
437
+ } catch { /* silent — never block install on one bad cache entry */ }
393
438
  }
394
439
  }
395
- ok('Plugin cache: launch.mjs synced (dev mode MCP routing)');
440
+ const parts = ['launch.mjs synced (dev mode MCP routing)'];
441
+ if (clearedHooks > 0) parts.push(`${clearedHooks} stale hooks.json cleared`);
442
+ ok(`Plugin cache: ${parts.join('; ')}`);
396
443
  }
397
444
  } catch (e) { warn(`Plugin cache sync: ${e.message}`); }
398
445
  }
@@ -942,6 +989,18 @@ async function status() {
942
989
  fail('Hooks: not configured');
943
990
  }
944
991
 
992
+ // Plugin cache pollution: populated hooks.json in cache AND install.mjs-managed
993
+ // settings.json hooks → runtime registers both → duplicate firing.
994
+ const polluted = scanPluginCacheHookPollution();
995
+ if (polluted.length > 0 && hasHooks) {
996
+ fail(`Plugin cache: stale hooks.json in version(s) ${polluted.join(', ')} — duplicate firing alongside settings.json (run 'install' to auto-clear)`);
997
+ } else if (polluted.length > 0) {
998
+ // plugin-only mode (no settings.json hooks) — cache hooks.json is the sole source, expected
999
+ ok(`Plugin cache: ${polluted.length} version(s) with hooks.json (plugin-only mode)`);
1000
+ } else if (pluginEnabled || hasHooks) {
1001
+ ok('Plugin cache: no stale hooks.json (no duplicate firing)');
1002
+ }
1003
+
945
1004
  // Database
946
1005
  if (existsSync(DB_PATH)) {
947
1006
  try {