claude-mem-lite 2.31.2 → 2.32.2

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.
@@ -0,0 +1,149 @@
1
+ // lib/task-reader.mjs — parse ~/.claude/tasks/<taskListId>/*.json for startup dashboard (T10a).
2
+ //
3
+ // Pure function over the filesystem. Filters to pending + in_progress tasks for a given project.
4
+ // Never throws — hooks must not break Claude Code. All I/O errors (ENOENT, permission denied,
5
+ // malformed JSON, races between readdir/stat) are silently skipped.
6
+ //
7
+ // Real schema observed in Claude Code ~/.claude/tasks/ (2026-04):
8
+ // - No `meta.json` files exist. Task dirs contain only `<taskId>.json` + hidden
9
+ // `.lock` / `.highwatermark` files.
10
+ // - Tasks use fields: `id`, `subject`, `activeForm`, `description`, `status`,
11
+ // `blocks`, `blockedBy`. Statuses: pending | in_progress | completed.
12
+ // - Project → taskListId mapping lives at `~/.claude/projects/<mangled>/<taskListId>/`
13
+ // where mangling = replace `/` with `-`.
14
+ //
15
+ // The v2.31 plan's fixture assumed a `meta.json`-based shape that is not how Claude Code
16
+ // actually organises tasks. This reader supports BOTH shapes so tests (which use meta.json
17
+ // per the plan) and runtime (which uses projectsRoot probing) both work:
18
+ //
19
+ // Priority 1 — fixture / future-proof: if `<dir>/meta.json` exists and contains
20
+ // `projectPath`, use it for project filtering.
21
+ // Priority 2 — real Claude Code: if `projectsRoot/<mangled>/<taskListId>/` exists,
22
+ // the task list belongs to `projectPath`.
23
+ // Priority 3 — no `projectPath` filter supplied: include all tasks.
24
+ //
25
+ // Normalized output uses `title` (falls back to `subject` → `'(untitled)'`) so T10c has
26
+ // a single contract regardless of source shape.
27
+
28
+ import { readdirSync, readFileSync, statSync } from 'fs';
29
+ import { join } from 'path';
30
+ import { homedir } from 'os';
31
+
32
+ const DEFAULT_TASKS_ROOT = join(homedir(), '.claude', 'tasks');
33
+ const DEFAULT_PROJECTS_ROOT = join(homedir(), '.claude', 'projects');
34
+ const ACTIVE_STATUSES = new Set(['pending', 'in_progress']);
35
+
36
+ /**
37
+ * Replace every non-alphanumeric character with `-`, mirroring Claude Code's
38
+ * `~/.claude/projects/<mangled>/` naming convention.
39
+ *
40
+ * Evidence from `~/.claude/projects/` listing (verified 2026-04):
41
+ * /mnt/data_ssd/dev/projects/mem → -mnt-data-ssd-dev-projects-mem (`/` and `_` → `-`)
42
+ * /home/sds/.claude/plugins/... → -home-sds--claude-plugins-... (leading `.` → `-`, so `/.` → `--`)
43
+ * /mnt/data/hdd/project / /agent → -mnt-data-hdd-project---agent (spaces/slashes → `-`)
44
+ *
45
+ * @param {string} p - Absolute project path.
46
+ * @returns {string} Mangled form suitable for `~/.claude/projects/<mangled>/`.
47
+ */
48
+ function manglePath(p) {
49
+ return String(p).replace(/[^a-zA-Z0-9]/g, '-');
50
+ }
51
+
52
+ /**
53
+ * Read active tasks (pending + in_progress) across all task lists that belong to a given
54
+ * project. Output is sorted by mtime DESC and capped at `maxTasks`.
55
+ *
56
+ * @param {object} [options]
57
+ * @param {string} [options.tasksRoot=~/.claude/tasks] - Override tasks root (testing).
58
+ * @param {string} [options.projectsRoot=~/.claude/projects] - Override projects root (testing).
59
+ * @param {string} [options.projectPath] - Absolute project path to filter by. When undefined,
60
+ * returns tasks from every task list encountered.
61
+ * @param {number} [options.maxTasks=20] - Cap on returned tasks.
62
+ * @returns {Array<{id:string,title:string,status:string,taskListId:string,mtime:number}>}
63
+ */
64
+ export function readProjectTasks({
65
+ tasksRoot = DEFAULT_TASKS_ROOT,
66
+ projectsRoot = DEFAULT_PROJECTS_ROOT,
67
+ projectPath,
68
+ maxTasks = 20,
69
+ } = {}) {
70
+ let listIds;
71
+ try {
72
+ listIds = readdirSync(tasksRoot);
73
+ } catch {
74
+ return [];
75
+ }
76
+
77
+ // Pre-compute the set of taskListIds registered under the project via projectsRoot
78
+ // (real Claude Code layout). Used as a fallback when meta.json is absent.
79
+ let projectListIds = null;
80
+ if (projectPath) {
81
+ try {
82
+ const mangled = manglePath(projectPath);
83
+ projectListIds = new Set(readdirSync(join(projectsRoot, mangled)));
84
+ } catch {
85
+ projectListIds = new Set();
86
+ }
87
+ }
88
+
89
+ const out = [];
90
+ outer: for (const id of listIds) {
91
+ const dir = join(tasksRoot, id);
92
+
93
+ // Project filter: meta.json (priority 1) → projectsRoot probe (priority 2) → all.
94
+ if (projectPath) {
95
+ let belongs;
96
+ try {
97
+ const meta = JSON.parse(readFileSync(join(dir, 'meta.json'), 'utf8'));
98
+ belongs = meta && meta.projectPath === projectPath;
99
+ } catch {
100
+ // No meta.json (or malformed) — fall back to real Claude Code layout.
101
+ belongs = projectListIds.has(id);
102
+ }
103
+ if (!belongs) continue;
104
+ }
105
+
106
+ let entries;
107
+ try {
108
+ entries = readdirSync(dir);
109
+ } catch {
110
+ continue;
111
+ }
112
+
113
+ for (const f of entries) {
114
+ // Skip non-task files: meta.json, hidden .lock / .highwatermark, non-JSON.
115
+ if (f === 'meta.json') continue;
116
+ if (f.startsWith('.')) continue;
117
+ if (!f.endsWith('.json')) continue;
118
+
119
+ const filePath = join(dir, f);
120
+ let task;
121
+ try {
122
+ task = JSON.parse(readFileSync(filePath, 'utf8'));
123
+ } catch {
124
+ continue;
125
+ }
126
+ if (!task || !ACTIVE_STATUSES.has(task.status)) continue;
127
+
128
+ let mtime;
129
+ try {
130
+ mtime = statSync(filePath).mtimeMs;
131
+ } catch {
132
+ // Race: file disappeared between readdir and stat. Skip silently.
133
+ continue;
134
+ }
135
+
136
+ out.push({
137
+ id: task.id || f.replace(/\.json$/, ''),
138
+ // Normalize: plan's `title` → real Claude Code's `subject` → fallback literal.
139
+ title: task.title || task.subject || '(untitled)',
140
+ status: task.status,
141
+ taskListId: id,
142
+ mtime,
143
+ });
144
+ if (out.length >= maxTasks) break outer;
145
+ }
146
+ }
147
+
148
+ return out.sort((a, b) => b.mtime - a.mtime);
149
+ }
package/mem-cli.mjs CHANGED
@@ -14,6 +14,7 @@ import { ensureRegistryDb, upsertResource } from './registry.mjs';
14
14
  import { searchResources } from './registry-retriever.mjs';
15
15
  import { optimizePreview, optimizeRun } from './hook-optimize.mjs';
16
16
  import { buildSessionContextLines } from './hook-context.mjs';
17
+ import { cmdAdopt, cmdUnadopt } from './adopt-cli.mjs';
17
18
  import { basename } from 'path';
18
19
  import { readFileSync } from 'fs';
19
20
 
@@ -2058,6 +2059,17 @@ Commands:
2058
2059
  --files (plural, comma-split) preferred; --file (singular) kept for back-compat.
2059
2060
  Use /lesson or /bug slash commands for faster capture (T8).
2060
2061
 
2062
+ adopt Inject claude-mem-lite sentinel line into this project's
2063
+ ~/.claude/projects/<encoded>/memory/MEMORY.md so Claude Code
2064
+ auto-loads it as user-memory (higher instruction authority).
2065
+ --all Adopt every project under ~/.claude/projects/*/memory/
2066
+ --force Overwrite a sentinel block that was manually edited
2067
+ --dry-run Print intended writes without touching disk
2068
+ --status List adopted projects + version
2069
+
2070
+ unadopt Precise removal of the sentinel block + plugin_claude_mem_lite.md.
2071
+ --all Unadopt every project
2072
+
2061
2073
  DB: ${DB_PATH}`);
2062
2074
  }
2063
2075
 
@@ -2336,6 +2348,11 @@ export async function run(argv) {
2336
2348
  return;
2337
2349
  }
2338
2350
 
2351
+ // adopt / unadopt do pure filesystem work on ~/.claude/projects/<encoded>/memory/ —
2352
+ // no DB needed. Route them before ensureDb() so an unbootable DB doesn't block.
2353
+ if (cmd === 'adopt') { cmdAdopt(cmdArgs); return; }
2354
+ if (cmd === 'unadopt') { cmdUnadopt(cmdArgs); return; }
2355
+
2339
2356
  let db;
2340
2357
  try {
2341
2358
  db = ensureDb();
package/memdir.mjs ADDED
@@ -0,0 +1,252 @@
1
+ // Phase B (Invited-Memory plan): memdir.mjs — primitives for the per-project
2
+ // Claude Code memdir at ~/.claude/projects/<encoded>/memory/.
3
+ //
4
+ // The public API lets a plugin install a single sentinel-wrapped line into
5
+ // MEMORY.md (auto-loaded by Claude Code into the system prompt) plus an
6
+ // on-demand `plugin_<slug>.md` detail file. Writes are idempotent, hash-guarded
7
+ // against manual user edits, and capped by a 180-line budget so the injection
8
+ // block never gets truncated by Claude Code's 200-line MEMORY.md cap.
9
+ //
10
+ // See docs/plans/2026-04-16-invited-memory-pattern.md for rationale.
11
+
12
+ import { readFileSync, writeFileSync, existsSync, renameSync, unlinkSync, mkdirSync } from 'fs';
13
+ import { join } from 'path';
14
+ import { homedir } from 'os';
15
+ import { createHash } from 'crypto';
16
+
17
+ const MEMORY_LINE_BUDGET = 180;
18
+ const SECTION_HEADER = '## 插件契约';
19
+
20
+ export class UserEditedError extends Error {
21
+ constructor(message) { super(message); this.name = 'UserEditedError'; }
22
+ }
23
+
24
+ export class BudgetExceededError extends Error {
25
+ constructor(message) { super(message); this.name = 'BudgetExceededError'; }
26
+ }
27
+
28
+ // ─── Path helpers ────────────────────────────────────────────────────────────
29
+
30
+ /**
31
+ * Mirror Claude Code's project-path encoding: every non-alphanumeric char
32
+ * is replaced with "-". Lossy by design — the encoded path is a directory
33
+ * slug, not a reversible encoding. Ground truth fixture:
34
+ * /mnt/data_ssd/dev/projects/mem → -mnt-data-ssd-dev-projects-mem
35
+ * Memory ref: #7687 ("Claude Code mangles EVERY non-alphanumeric char").
36
+ */
37
+ export function encodeProjectPath(absPath) {
38
+ return String(absPath).replace(/[^a-zA-Z0-9]/g, '-');
39
+ }
40
+
41
+ /**
42
+ * Absolute path to the project's memdir. Caller runs mkdir-p as needed.
43
+ */
44
+ export function memdirPath(projectCwd) {
45
+ return join(homedir(), '.claude', 'projects', encodeProjectPath(projectCwd), 'memory');
46
+ }
47
+
48
+ function memoryFile(memdir) { return join(memdir, 'MEMORY.md'); }
49
+
50
+ function slugSnake(slug) {
51
+ return String(slug).replace(/[^a-zA-Z0-9]/g, '_');
52
+ }
53
+
54
+ function docFile(memdir, slug) {
55
+ return join(memdir, `plugin_${slugSnake(slug)}.md`);
56
+ }
57
+
58
+ function stateFile(memdir, slug) {
59
+ return join(memdir, `.plugin_${slugSnake(slug)}_state.json`);
60
+ }
61
+
62
+ // ─── Sentinel rendering & parsing ────────────────────────────────────────────
63
+
64
+ function sentinelRegex(slug) {
65
+ // Escape regex metacharacters in the slug. In practice plugin slugs are
66
+ // [a-z0-9-], but guard against '.', '+' etc. from arbitrary other plugins.
67
+ const esc = slug.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
68
+ return new RegExp(
69
+ `<!-- ${esc}:begin (v\\d+) -->\\s*\\n([\\s\\S]*?)<!-- ${esc}:end -->`,
70
+ );
71
+ }
72
+
73
+ function renderSentinel(slug, version, contentLine) {
74
+ return [
75
+ `<!-- ${slug}:begin ${version} -->`,
76
+ SECTION_HEADER,
77
+ contentLine,
78
+ `<!-- ${slug}:end -->`,
79
+ ].join('\n');
80
+ }
81
+
82
+ function canonicalBody(contentLine) {
83
+ // Must match what sentinelRegex captures in match[2]: everything between
84
+ // "begin X -->\n" and "<!-- X:end -->". That's SECTION_HEADER + '\n' +
85
+ // contentLine + '\n'.
86
+ return `${SECTION_HEADER}\n${contentLine}\n`;
87
+ }
88
+
89
+ function sha256(s) { return createHash('sha256').update(s).digest('hex'); }
90
+
91
+ function atomicWrite(path, content) {
92
+ const tmp = `${path}.tmp-${process.pid}-${Date.now()}`;
93
+ writeFileSync(tmp, content);
94
+ renameSync(tmp, path);
95
+ }
96
+
97
+ function readState(memdir, slug) {
98
+ const p = stateFile(memdir, slug);
99
+ if (!existsSync(p)) return null;
100
+ try { return JSON.parse(readFileSync(p, 'utf8')); } catch { return null; }
101
+ }
102
+
103
+ function writeState(memdir, slug, state) {
104
+ atomicWrite(stateFile(memdir, slug), JSON.stringify(state, null, 2) + '\n');
105
+ }
106
+
107
+ function clearState(memdir, slug) {
108
+ const p = stateFile(memdir, slug);
109
+ if (existsSync(p)) try { unlinkSync(p); } catch { /* best-effort */ }
110
+ }
111
+
112
+ // ─── Public API ──────────────────────────────────────────────────────────────
113
+
114
+ /**
115
+ * Parse MEMORY.md for the plugin's sentinel block.
116
+ * @returns {{ exists: boolean, raw: string, lineCount: number,
117
+ * section: string|null, body: string|null, version: string|null }}
118
+ */
119
+ export function readMemoryIndex(memdir, slug) {
120
+ const path = memoryFile(memdir);
121
+ if (!existsSync(path)) {
122
+ return { exists: false, raw: '', lineCount: 0, section: null, body: null, version: null };
123
+ }
124
+ const raw = readFileSync(path, 'utf8');
125
+ const m = raw.match(sentinelRegex(slug));
126
+ const lineCount = raw.length === 0 ? 0 : raw.split('\n').length;
127
+ if (!m) return { exists: true, raw, lineCount, section: null, body: null, version: null };
128
+ return { exists: true, raw, lineCount, section: m[0], body: m[2], version: m[1] };
129
+ }
130
+
131
+ /**
132
+ * Insert-or-update the plugin's sentinel block in MEMORY.md, writing a
133
+ * state sidecar alongside it.
134
+ *
135
+ * Idempotent: rewriting identical inputs is a no-op (returns action='unchanged').
136
+ *
137
+ * @param {string} memdir Absolute path to the memory directory.
138
+ * @param {object} opts
139
+ * @param {string} opts.slug Plugin slug (e.g. 'claude-mem-lite').
140
+ * @param {string} opts.version Contract version, e.g. 'v1'.
141
+ * @param {string} opts.contentLine Single-line index entry (≤150 chars).
142
+ * @param {boolean} [opts.force=false] Override UserEditedError.
143
+ * @returns {{action: 'created'|'updated'|'unchanged'}}
144
+ *
145
+ * @throws {BudgetExceededError} when MEMORY.md has > 180 lines AND we'd be
146
+ * adding a new section (updates to an existing sentinel are always allowed).
147
+ * @throws {UserEditedError} when the sentinel exists but its body hash doesn't
148
+ * match the last hash we wrote (detected via the state sidecar). Also thrown
149
+ * when a sentinel exists without any state file — treated as foreign content.
150
+ */
151
+ export function writePluginSection(memdir, { slug, version, contentLine, force = false }) {
152
+ if (!existsSync(memdir)) mkdirSync(memdir, { recursive: true });
153
+ const path = memoryFile(memdir);
154
+ const raw = existsSync(path) ? readFileSync(path, 'utf8') : '';
155
+ const match = raw.match(sentinelRegex(slug));
156
+
157
+ const freshSection = renderSentinel(slug, version, contentLine);
158
+ const freshHash = sha256(canonicalBody(contentLine));
159
+
160
+ if (!match) {
161
+ // Insert: enforce the 180-line budget so we never get truncated at 200.
162
+ const existingLines = raw.length === 0 ? 0 : raw.split('\n').length;
163
+ if (existingLines > MEMORY_LINE_BUDGET) {
164
+ throw new BudgetExceededError(
165
+ `MEMORY.md has ${existingLines} lines (> ${MEMORY_LINE_BUDGET}); refuse to add new sentinel section for ${slug}.`,
166
+ );
167
+ }
168
+ // Assemble with one blank line before our section for readability.
169
+ let next;
170
+ if (raw.length === 0) {
171
+ next = freshSection + '\n';
172
+ } else if (raw.endsWith('\n\n')) {
173
+ next = raw + freshSection + '\n';
174
+ } else if (raw.endsWith('\n')) {
175
+ next = raw + '\n' + freshSection + '\n';
176
+ } else {
177
+ next = raw + '\n\n' + freshSection + '\n';
178
+ }
179
+ atomicWrite(path, next);
180
+ writeState(memdir, slug, { version, bodyHash: freshHash, writtenAt: new Date().toISOString() });
181
+ return { action: 'created' };
182
+ }
183
+
184
+ // Update path: hash-guard against user edits via state sidecar.
185
+ if (!force) {
186
+ const state = readState(memdir, slug);
187
+ if (!state) {
188
+ throw new UserEditedError(
189
+ `${slug} sentinel found without plugin state file — treating as foreign content. Pass force=true to re-adopt.`,
190
+ );
191
+ }
192
+ const currentHash = sha256(match[2]);
193
+ if (state.bodyHash !== currentHash) {
194
+ throw new UserEditedError(
195
+ `${slug} sentinel body was modified since last write (hash mismatch). Pass force=true to overwrite.`,
196
+ );
197
+ }
198
+ }
199
+
200
+ const next = raw.replace(match[0], freshSection);
201
+ const changed = next !== raw;
202
+ if (changed) atomicWrite(path, next);
203
+ writeState(memdir, slug, { version, bodyHash: freshHash, writtenAt: new Date().toISOString() });
204
+ return { action: changed ? 'updated' : 'unchanged' };
205
+ }
206
+
207
+ /**
208
+ * Remove the plugin's sentinel block plus its state sidecar. External content
209
+ * in MEMORY.md is preserved.
210
+ * @returns {{action: 'removed'|'absent'}}
211
+ */
212
+ export function removePluginSection(memdir, slug) {
213
+ clearState(memdir, slug);
214
+ const path = memoryFile(memdir);
215
+ if (!existsSync(path)) return { action: 'absent' };
216
+ const raw = readFileSync(path, 'utf8');
217
+ const match = raw.match(sentinelRegex(slug));
218
+ if (!match) return { action: 'absent' };
219
+
220
+ // Delete the match plus a trailing newline + a preceding blank line so we
221
+ // don't leave a stranded paragraph gap.
222
+ let start = match.index;
223
+ let end = match.index + match[0].length;
224
+ if (raw[end] === '\n') end++;
225
+ if (start > 0 && raw.slice(0, start).endsWith('\n\n')) start--;
226
+ const next = raw.slice(0, start) + raw.slice(end);
227
+ atomicWrite(path, next);
228
+ return { action: 'removed' };
229
+ }
230
+
231
+ /**
232
+ * Whether this memdir has our sentinel. Body edits don't demote the adoption —
233
+ * users who hand-tweak the contract line still count as adopted.
234
+ */
235
+ export function isAdopted(memdir, slug) {
236
+ if (!existsSync(memdir)) return false;
237
+ const { section } = readMemoryIndex(memdir, slug);
238
+ return section !== null;
239
+ }
240
+
241
+ // ─── Plugin detail doc IO ────────────────────────────────────────────────────
242
+
243
+ export function writePluginDoc(memdir, slug, markdown) {
244
+ if (!existsSync(memdir)) mkdirSync(memdir, { recursive: true });
245
+ atomicWrite(docFile(memdir, slug), markdown);
246
+ }
247
+
248
+ export function removePluginDoc(memdir, slug) {
249
+ const path = docFile(memdir, slug);
250
+ if (!existsSync(path)) return;
251
+ try { unlinkSync(path); } catch { /* best-effort */ }
252
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "claude-mem-lite",
3
- "version": "2.31.2",
3
+ "version": "2.32.2",
4
4
  "description": "Lightweight persistent memory system for Claude Code",
5
5
  "type": "module",
6
6
  "engines": {
@@ -35,7 +35,18 @@
35
35
  "hook-context.mjs",
36
36
  "hook-handoff.mjs",
37
37
  "hook-update.mjs",
38
+ "hook-optimize.mjs",
39
+ "plugin-cache-guard.mjs",
40
+ "memdir.mjs",
41
+ "adopt-content.mjs",
42
+ "adopt-cli.mjs",
38
43
  "haiku-client.mjs",
44
+ "lib/activity.mjs",
45
+ "lib/git-state.mjs",
46
+ "lib/task-reader.mjs",
47
+ "lib/plan-reader.mjs",
48
+ "lib/startup-dashboard.mjs",
49
+ "lib/doctor-benchmark.mjs",
39
50
  "registry.mjs",
40
51
  "registry-retriever.mjs",
41
52
  "registry-indexer.mjs",
@@ -66,6 +77,10 @@
66
77
  "commands/memory.md",
67
78
  "commands/update.md",
68
79
  "commands/tools.md",
80
+ "commands/adopt.md",
81
+ "commands/unadopt.md",
82
+ "commands/lesson.md",
83
+ "commands/bug.md",
69
84
  "hooks/hooks.json",
70
85
  "scripts/launch.mjs",
71
86
  "scripts/setup.sh",
@@ -85,6 +100,9 @@
85
100
  "better-sqlite3": "^12.6.2",
86
101
  "zod": "^4.3.6"
87
102
  },
103
+ "overrides": {
104
+ "hono": ">=4.12.14"
105
+ },
88
106
  "devDependencies": {
89
107
  "@eslint/js": "^10.0.1",
90
108
  "@vitest/coverage-v8": "^4.0.18",
@@ -0,0 +1,77 @@
1
+ // Plugin cache hook sentinel.
2
+ //
3
+ // Claude Code runtime reads plugin hooks from
4
+ // ~/.claude/plugins/cache/<marketplace>/<plugin>/<version>/hooks/hooks.json
5
+ // NOT from the marketplace source (~/.claude/plugins/marketplaces/<mp>/hooks/hooks.json).
6
+ //
7
+ // When install.mjs writes mem hooks directly into ~/.claude/settings.json, any stale
8
+ // cache hooks.json (e.g. left behind by a previous marketplace install or an auto-update
9
+ // that re-populates cache) causes double hook registration: one fires from settings.json,
10
+ // another from cache. This module detects and heals that state.
11
+ //
12
+ // Safe-by-default: clearPluginCacheHooks is only called when hasInstallManagedHooks()
13
+ // returns true, so plugin-only users (cache is the sole registration) are not affected.
14
+
15
+ import { readFileSync, writeFileSync, readdirSync, existsSync } from 'fs';
16
+ import { join } from 'path';
17
+ import { homedir } from 'os';
18
+
19
+ export const DEFAULT_MARKETPLACE = 'sdsrss';
20
+ export const DEFAULT_PLUGIN = 'claude-mem-lite';
21
+
22
+ function cacheBaseFor(opts) {
23
+ const home = opts?.home || homedir();
24
+ const mp = opts?.marketplace || DEFAULT_MARKETPLACE;
25
+ const plugin = opts?.plugin || DEFAULT_PLUGIN;
26
+ return join(home, '.claude', 'plugins', 'cache', mp, plugin);
27
+ }
28
+
29
+ export function scanPluginCacheHookPollution(opts) {
30
+ const base = cacheBaseFor(opts);
31
+ if (!existsSync(base)) return [];
32
+ const polluted = [];
33
+ for (const ver of readdirSync(base)) {
34
+ const p = join(base, ver, 'hooks', 'hooks.json');
35
+ if (!existsSync(p)) continue;
36
+ try {
37
+ const h = JSON.parse(readFileSync(p, 'utf8'));
38
+ if (h.hooks && Object.keys(h.hooks).length > 0) polluted.push(ver);
39
+ } catch { /* ignore unreadable cache entries */ }
40
+ }
41
+ return polluted.sort();
42
+ }
43
+
44
+ export function clearPluginCacheHooks(opts) {
45
+ const base = cacheBaseFor(opts);
46
+ if (!existsSync(base)) return [];
47
+ const plugin = opts?.plugin || DEFAULT_PLUGIN;
48
+ const reason = opts?.reason || 'Auto-cleared to prevent duplicate hook registration';
49
+ const cleared = [];
50
+ for (const ver of readdirSync(base)) {
51
+ const p = join(base, ver, 'hooks', 'hooks.json');
52
+ if (!existsSync(p)) continue;
53
+ try {
54
+ const h = JSON.parse(readFileSync(p, 'utf8'));
55
+ if (!h.hooks || Object.keys(h.hooks).length === 0) continue;
56
+ writeFileSync(p, JSON.stringify({
57
+ description: h.description || `${plugin} hooks`,
58
+ _note: `${reason} (cache ver: ${ver})`,
59
+ hooks: {},
60
+ }, null, 2) + '\n');
61
+ cleared.push(ver);
62
+ } catch { /* ignore unwritable cache entries */ }
63
+ }
64
+ return cleared.sort();
65
+ }
66
+
67
+ export function hasInstallManagedHooks(opts) {
68
+ const home = opts?.home || homedir();
69
+ const plugin = opts?.plugin || DEFAULT_PLUGIN;
70
+ const settingsPath = join(home, '.claude', 'settings.json');
71
+ if (!existsSync(settingsPath)) return false;
72
+ try {
73
+ const s = JSON.parse(readFileSync(settingsPath, 'utf8'));
74
+ const serialized = JSON.stringify(s.hooks || {});
75
+ return serialized.includes(`.${plugin}/`) || serialized.includes(`/${plugin}/`);
76
+ } catch { return false; }
77
+ }
@@ -167,6 +167,10 @@ function readStdin() {
167
167
 
168
168
  // ─── Format Output ──────────────────────────────────────────────────────────
169
169
 
170
+ // Phase A (v2.31.3+): drop lesson suffix when MEM_QUIET_HOOKS=1; users on invited-memory
171
+ // path can mem_get the ID for full detail.
172
+ const QUIET_HOOKS = process.env.MEM_QUIET_HOOKS === '1';
173
+
170
174
  function formatResults(rows) {
171
175
  if (!rows || rows.length === 0) return null;
172
176
 
@@ -174,7 +178,7 @@ function formatResults(rows) {
174
178
  for (const r of rows) {
175
179
  const icon = typeIcon(r.type);
176
180
  const title = truncate(r.title || '', 70);
177
- const lesson = r.lesson_learned ? ` — ${truncate(r.lesson_learned, 50)}` : '';
181
+ const lesson = !QUIET_HOOKS && r.lesson_learned ? ` — ${truncate(r.lesson_learned, 50)}` : '';
178
182
  lines.push(`#${r.id} ${icon} ${title}${lesson}`);
179
183
  }
180
184
  return lines.join('\n');
@@ -5,6 +5,54 @@ import { debugCatch, COMPRESSED_AUTO, COMPRESSED_PENDING_PURGE, OBS_BM25 } from
5
5
  import { BASE_STOP_WORDS } from './stop-words.mjs';
6
6
  import { porterStem } from './tfidf.mjs';
7
7
 
8
+ // ─── MCP Server Instructions Builder ───────────────────────────────────────
9
+ // Phase A (v2.31.3+): when quiet=true, drops WHEN-TO-USE proactive-trigger and
10
+ // Decision-rules sections; keeps the irreducible CLI/MCP tool list. Intended
11
+ // for users who adopted invited-memory (MEMORY.md sentinel carries the same
12
+ // triggers at higher authority). Default false preserves v2.31.2 behavior.
13
+
14
+ const INSTRUCTIONS_BASE = [
15
+ 'Long-term memory across sessions. Hooks auto-inject context; CLI preferred for explicit queries.',
16
+ '',
17
+ 'CLI (via Bash):',
18
+ ' claude-mem-lite search "query" — FTS5 full-text search',
19
+ ' claude-mem-lite search "err" --type bugfix — filter by type',
20
+ ' claude-mem-lite recall "file.mjs" — file-related memories',
21
+ ' claude-mem-lite recent 5 — latest observations',
22
+ ' claude-mem-lite get 42,43 — full details by ID',
23
+ ' claude-mem-lite timeline --anchor 42 — chronological context',
24
+ '',
25
+ 'MCP tools: mem_search, mem_recent, mem_save, mem_get, mem_recall, mem_timeline for programmatic access.',
26
+ 'mem_save: Save non-obvious insights (bugfix lessons, architecture decisions).',
27
+ 'Search tips: short keywords (2-3 words), filter with obs_type when relevant.',
28
+ ];
29
+
30
+ const INSTRUCTIONS_VERBOSE = [
31
+ '',
32
+ 'WHEN TO USE (proactive triggers during coding):',
33
+ ' • About to Edit/Write a file → mem_recall(file="path") FIRST — past bugfixes & lessons',
34
+ ' • Test failure or error → mem_search(query="error keywords", obs_type="bugfix")',
35
+ ' • Before refactoring → mem_search(query="module-name", obs_type="refactor") for past decisions',
36
+ ' • Starting new feature → mem_search(query="feature area") for prior art & patterns',
37
+ ' • After fixing a tricky bug → mem_save(type="bugfix", lesson_learned="root cause & fix")',
38
+ ' • After architecture decision → mem_save(type="decision", lesson_learned="rationale")',
39
+ ' • Hook-injected context mentions #ID → mem_get(ids=[ID]) for full details',
40
+ '',
41
+ 'Decision rules (use INSTEAD OF multi-step search):',
42
+ ' • "what happened recently?" → mem_recent (NOT search with empty query)',
43
+ ' • "what do we know about file.mjs?" → mem_recall (NOT grep + manual search)',
44
+ ' • "show me around observation #42" → mem_timeline (NOT mem_get + manual navigation)',
45
+ ' • "clean up old/duplicate memories" → mem_maintain (NOT manual mem_delete loop)',
46
+ ' • "is the search index healthy?" → mem_fts_check (NOT manual COUNT queries)',
47
+ ' • "overview of memory tiers" → mem_browse (NOT mem_search + manual grouping)',
48
+ ' • "export for backup" → mem_export (NOT manual SELECT queries)',
49
+ ];
50
+
51
+ export function buildServerInstructions(quiet = false) {
52
+ if (quiet) return INSTRUCTIONS_BASE.join('\n');
53
+ return [...INSTRUCTIONS_BASE, ...INSTRUCTIONS_VERBOSE].join('\n');
54
+ }
55
+
8
56
  // ─── Search Re-ranking Helpers ────────────────────────────────────────────
9
57
 
10
58
  /**