memtrace 0.3.34 → 0.3.36
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/bin/memtrace.js +7 -1
- package/hooks/posttool-mcp-telemetry.sh +56 -0
- package/hooks/userprompt-claude.sh +102 -0
- package/install.js +35 -1
- package/lib/claude-integration.js +447 -0
- package/lib/skill-metadata.js +303 -0
- package/lib/spawn-helper.js +63 -0
- package/lib/upgrade-skills.js +72 -0
- package/package.json +6 -4
- package/uninstall.js +107 -39
package/bin/memtrace.js
CHANGED
|
@@ -92,7 +92,13 @@ if (args[0] === "uninstall" || args[0] === "cleanup") {
|
|
|
92
92
|
let ran = false;
|
|
93
93
|
for (const p of candidates) {
|
|
94
94
|
try {
|
|
95
|
-
|
|
95
|
+
// Spawn the uninstall script as its own process so its top-level
|
|
96
|
+
// `if (require.main === module)` block fires. We deliberately
|
|
97
|
+
// avoid `require(p)` here: tests for the cleanup helpers
|
|
98
|
+
// import uninstall.js for its exports and would trigger the
|
|
99
|
+
// uninstall pipeline at import time if we relied on side effect.
|
|
100
|
+
const result = spawnSync(process.execPath, [p], { stdio: "inherit" });
|
|
101
|
+
if (result.error) continue;
|
|
96
102
|
ran = true;
|
|
97
103
|
break;
|
|
98
104
|
} catch (e) {
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
#!/usr/bin/env bash
|
|
2
|
+
#
|
|
3
|
+
# Memtrace PostToolUse hook for Claude Code — adoption telemetry.
|
|
4
|
+
#
|
|
5
|
+
# Fires after every tool call that matched the registered matcher
|
|
6
|
+
# (`mcp__memtrace__.*`). Logs the call locally to
|
|
7
|
+
# `~/.memtrace/adoption.jsonl` so we can measure whether v0.3.35's
|
|
8
|
+
# enforcement layers actually moved Memtrace tool-invocation rates.
|
|
9
|
+
#
|
|
10
|
+
# This is LOCAL adoption tracking — the file lives in the user's
|
|
11
|
+
# home directory, never leaves the machine. Aggregate counts may be
|
|
12
|
+
# included in opt-in telemetry pings if the user has enabled them
|
|
13
|
+
# (see PRIVACY.md).
|
|
14
|
+
#
|
|
15
|
+
# Hook never blocks. Always exit 0.
|
|
16
|
+
#
|
|
17
|
+
# Override:
|
|
18
|
+
# MEMTRACE_ADOPTION_LOG=off → unconditional no-op
|
|
19
|
+
#
|
|
20
|
+
set -euo pipefail
|
|
21
|
+
|
|
22
|
+
[[ "${MEMTRACE_ADOPTION_LOG:-on}" == "off" ]] && exit 0
|
|
23
|
+
|
|
24
|
+
# Read PostToolUse input from stdin. Shape:
|
|
25
|
+
# { "session_id", "cwd", "hook_event_name", "tool_name", "tool_input", ... }
|
|
26
|
+
input="$(cat)"
|
|
27
|
+
|
|
28
|
+
# Cheap exit if the JSON is malformed.
|
|
29
|
+
[[ -z "$input" ]] && exit 0
|
|
30
|
+
|
|
31
|
+
log_dir="${MEMTRACE_ADOPTION_DIR:-$HOME/.memtrace}"
|
|
32
|
+
log_file="$log_dir/adoption.jsonl"
|
|
33
|
+
mkdir -p "$log_dir" 2>/dev/null || exit 0
|
|
34
|
+
|
|
35
|
+
# Append a single-line JSON record. We strip `tool_input` because it
|
|
36
|
+
# can be large and may contain sensitive arguments. We keep the
|
|
37
|
+
# tool name + a high-resolution timestamp + session id only.
|
|
38
|
+
python3 - "$input" "$log_file" <<'PY' 2>/dev/null || true
|
|
39
|
+
import json, sys, os, time
|
|
40
|
+
input_str = sys.argv[1]
|
|
41
|
+
log_path = sys.argv[2]
|
|
42
|
+
try:
|
|
43
|
+
obj = json.loads(input_str)
|
|
44
|
+
except Exception:
|
|
45
|
+
sys.exit(0)
|
|
46
|
+
record = {
|
|
47
|
+
"ts": time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime()),
|
|
48
|
+
"session_id": obj.get("session_id", ""),
|
|
49
|
+
"tool_name": obj.get("tool_name", ""),
|
|
50
|
+
"hook_event_name": obj.get("hook_event_name", ""),
|
|
51
|
+
}
|
|
52
|
+
with open(log_path, "a", encoding="utf-8") as f:
|
|
53
|
+
f.write(json.dumps(record) + "\n")
|
|
54
|
+
PY
|
|
55
|
+
|
|
56
|
+
exit 0
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
#!/usr/bin/env bash
|
|
2
|
+
#
|
|
3
|
+
# Memtrace UserPromptSubmit hook for Claude Code.
|
|
4
|
+
#
|
|
5
|
+
# Fires once per user turn (NOT per tool call). Reads the user's
|
|
6
|
+
# prompt; if it looks like a code-discovery question and the
|
|
7
|
+
# Memtrace daemon is reachable, injects an `additionalContext`
|
|
8
|
+
# nudge so Claude considers Memtrace MCP tools BEFORE falling back
|
|
9
|
+
# to Read/Grep/Glob.
|
|
10
|
+
#
|
|
11
|
+
# Why UserPromptSubmit instead of PreToolUse-on-Read|Grep|Glob:
|
|
12
|
+
# - fires once per turn, not per tool call → no per-Read latency
|
|
13
|
+
# - gets the user's actual prompt → can decide based on intent,
|
|
14
|
+
# not on which file the model chose to grep
|
|
15
|
+
# - non-blocking → just adds context, never denies a tool call
|
|
16
|
+
#
|
|
17
|
+
# Exit codes:
|
|
18
|
+
# 0 : success (stdout is parsed for hook output)
|
|
19
|
+
# 2 : would block the prompt (we never want this)
|
|
20
|
+
#
|
|
21
|
+
# Hook output JSON shape (per code.claude.com/docs/en/hooks-guide.md):
|
|
22
|
+
# { "decision": "continue", "additionalContext": "..." }
|
|
23
|
+
#
|
|
24
|
+
# Override:
|
|
25
|
+
# MEMTRACE_HOOK_MODE=off → unconditional no-op
|
|
26
|
+
# MEMTRACE_HEALTH_URL=... → custom health endpoint (default 3030)
|
|
27
|
+
#
|
|
28
|
+
set -euo pipefail
|
|
29
|
+
|
|
30
|
+
mode="${MEMTRACE_HOOK_MODE:-advisory}"
|
|
31
|
+
[[ "$mode" == "off" ]] && exit 0
|
|
32
|
+
|
|
33
|
+
# ── Daemon liveness (portable: works on macOS/Linux/Windows-WSL) ──
|
|
34
|
+
#
|
|
35
|
+
# We use the Memtrace UI's status endpoint instead of `pgrep` so
|
|
36
|
+
# Windows + restricted-shell environments work the same way.
|
|
37
|
+
# 1-second timeout — must not slow Claude down.
|
|
38
|
+
health_url="${MEMTRACE_HEALTH_URL:-http://localhost:3030/api/health}"
|
|
39
|
+
if ! curl -sf --max-time 1 "$health_url" >/dev/null 2>&1; then
|
|
40
|
+
# Daemon unreachable: silent no-op. We never inject memtrace
|
|
41
|
+
# nudges when memtrace itself isn't running.
|
|
42
|
+
exit 0
|
|
43
|
+
fi
|
|
44
|
+
|
|
45
|
+
# ── Read prompt from stdin ──────────────────────────────────────────
|
|
46
|
+
input="$(cat)"
|
|
47
|
+
|
|
48
|
+
# Use python3 for JSON parsing — every macOS/Linux/Windows-with-Python
|
|
49
|
+
# has it; jq is more concise but less universal.
|
|
50
|
+
prompt="$(printf '%s' "$input" | python3 -c '
|
|
51
|
+
import json, sys
|
|
52
|
+
try:
|
|
53
|
+
obj = json.load(sys.stdin)
|
|
54
|
+
print(obj.get("prompt", ""), end="")
|
|
55
|
+
except Exception:
|
|
56
|
+
pass
|
|
57
|
+
' 2>/dev/null || true)"
|
|
58
|
+
|
|
59
|
+
# If the prompt is empty or unparseable, no-op.
|
|
60
|
+
[[ -z "$prompt" ]] && exit 0
|
|
61
|
+
|
|
62
|
+
# ── Match code-discovery intent ─────────────────────────────────────
|
|
63
|
+
#
|
|
64
|
+
# The match list is intentionally generous on the "ask Memtrace" side
|
|
65
|
+
# and is anchored against directive verbs and possessive phrases that
|
|
66
|
+
# a real user prompt looks like. The agent's planner also uses these
|
|
67
|
+
# (e.g. "trace through", "find the function that") so this catches
|
|
68
|
+
# both human-typed and agent-internal phrasings.
|
|
69
|
+
shopt -s nocasematch
|
|
70
|
+
should_nudge=0
|
|
71
|
+
case "$prompt" in
|
|
72
|
+
*"where is"*|*"where's"*|*"how does"*|*"how is"*) should_nudge=1 ;;
|
|
73
|
+
*"what calls"*|*"who calls"*|*"callers of"*|*"callees of"*) should_nudge=1 ;;
|
|
74
|
+
*"why does"*|*"why is"*|*"why was"*) should_nudge=1 ;;
|
|
75
|
+
*"find the function"*|*"find the class"*|*"find the type"*) should_nudge=1 ;;
|
|
76
|
+
*"trace through"*|*"trace this"*|*"trace the"*) should_nudge=1 ;;
|
|
77
|
+
*"investigate"*|*"debug"*|*"diagnose"*) should_nudge=1 ;;
|
|
78
|
+
*"explain this"*|*"explain the code"*|*"understand this"*) should_nudge=1 ;;
|
|
79
|
+
*"audit"*|*"review"*|*"refactor"*) should_nudge=1 ;;
|
|
80
|
+
*"fix bug"*|*"fix the bug"*|*"broken"*|*"failing"*) should_nudge=1 ;;
|
|
81
|
+
*"impact of"*|*"what breaks if"*|*"safe to remove"*|*"safe to rename"*) should_nudge=1 ;;
|
|
82
|
+
*"locate"*|*"look up"*|*"search for "*) should_nudge=1 ;;
|
|
83
|
+
*"recent changes"*|*"what changed"*|*"evolution of"*) should_nudge=1 ;;
|
|
84
|
+
*"call graph"*|*"dependency"*|*"depend on"*|*"dependencies of"*) should_nudge=1 ;;
|
|
85
|
+
esac
|
|
86
|
+
shopt -u nocasematch
|
|
87
|
+
|
|
88
|
+
[[ "$should_nudge" -eq 0 ]] && exit 0
|
|
89
|
+
|
|
90
|
+
# ── Emit the nudge ──────────────────────────────────────────────────
|
|
91
|
+
#
|
|
92
|
+
# Per Anthropic's hook docs, UserPromptSubmit accepts a top-level
|
|
93
|
+
# `decision: continue` (don't block) plus `additionalContext` (which
|
|
94
|
+
# is injected as a system-style reminder Claude reads alongside the
|
|
95
|
+
# user's prompt). The wording is concise on purpose — this is added
|
|
96
|
+
# to context for every matching prompt, so token cost is real.
|
|
97
|
+
cat <<'EOF'
|
|
98
|
+
{
|
|
99
|
+
"decision": "continue",
|
|
100
|
+
"additionalContext": "Memtrace is active for this repository. For this code-discovery question, prefer the Memtrace MCP tools FIRST — `mcp__memtrace__find_code` for natural-language search, `mcp__memtrace__find_symbol` for exact lookup, `mcp__memtrace__get_symbol_context` for callers/callees, `mcp__memtrace__get_impact` for blast radius. They return exact file:start_line:end_line in one round-trip. Fall back to Read/Grep/Glob only for: (a) config files (.env, package.json, README, raw JSON/YAML/TOML), (b) file inventory questions, (c) paths confirmed outside any indexed repo, (d) reading exact lines you already have from a Memtrace result."
|
|
101
|
+
}
|
|
102
|
+
EOF
|
package/install.js
CHANGED
|
@@ -6,6 +6,7 @@ const path = require("path");
|
|
|
6
6
|
const fs = require("fs");
|
|
7
7
|
const { spawnSync } = require("child_process");
|
|
8
8
|
const { platformBinary, spawnOptionsForPlatform } = require("./lib/spawn-helper");
|
|
9
|
+
const { installToFs } = require("./lib/claude-integration");
|
|
9
10
|
|
|
10
11
|
// ── Platform binary resolution (preserved from legacy) ───────────────────────
|
|
11
12
|
|
|
@@ -128,7 +129,40 @@ if (require.main === module) {
|
|
|
128
129
|
console.warn("memtrace: run 'memtrace install' manually after the package is built.");
|
|
129
130
|
}
|
|
130
131
|
|
|
131
|
-
// 3.
|
|
132
|
+
// 3. Claude Code adoption layer (v0.3.35+):
|
|
133
|
+
// - write ~/.claude/MEMTRACE.md (sidecar directive we own)
|
|
134
|
+
// - add a breadcrumb block to ~/.claude/CLAUDE.md if it exists
|
|
135
|
+
// - register UserPromptSubmit + PostToolUse hooks in
|
|
136
|
+
// ~/.claude/settings.json under `_managed_by: "memtrace"`
|
|
137
|
+
//
|
|
138
|
+
// This layer is what makes Claude Code consistently reach for
|
|
139
|
+
// Memtrace MCP tools FIRST instead of falling back to Read/Grep
|
|
140
|
+
// on indexed repos. Heuristic skill auto-invocation is too soft
|
|
141
|
+
// on its own — see lib/claude-integration.js for the why.
|
|
142
|
+
try {
|
|
143
|
+
const hooksDir = path.join(__dirname, "hooks");
|
|
144
|
+
// Make hook scripts executable on Unix (npm tarballs lose +x on
|
|
145
|
+
// some pack/unpack paths; doing this defensively).
|
|
146
|
+
if (os.platform() !== "win32" && fs.existsSync(hooksDir)) {
|
|
147
|
+
for (const entry of fs.readdirSync(hooksDir)) {
|
|
148
|
+
if (entry.endsWith(".sh")) {
|
|
149
|
+
try { fs.chmodSync(path.join(hooksDir, entry), 0o755); }
|
|
150
|
+
catch { /* best-effort */ }
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
const claudeDir = path.join(os.homedir(), ".claude");
|
|
155
|
+
const summary = installToFs({ claudeDir, hooksDir });
|
|
156
|
+
if (summary.wrote.length > 0) {
|
|
157
|
+
console.log(
|
|
158
|
+
`memtrace: configured Claude Code integration (${summary.wrote.length} file(s) updated)`
|
|
159
|
+
);
|
|
160
|
+
}
|
|
161
|
+
} catch (e) {
|
|
162
|
+
console.warn(`memtrace: Claude Code integration setup skipped: ${e.message}`);
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
// 4. Persist uninstall script to ~/.memtrace/ (survives `npm uninstall -g`)
|
|
132
166
|
try {
|
|
133
167
|
const memtraceDir = path.join(os.homedir(), ".memtrace");
|
|
134
168
|
fs.mkdirSync(memtraceDir, { recursive: true });
|
|
@@ -0,0 +1,447 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
|
|
3
|
+
// Claude Code integration for Memtrace.
|
|
4
|
+
//
|
|
5
|
+
// Manages three pieces of user-level Claude Code configuration:
|
|
6
|
+
//
|
|
7
|
+
// 1. ~/.claude/MEMTRACE.md — durable directive that Claude
|
|
8
|
+
// reads as ambient context. Owned
|
|
9
|
+
// entirely by us. We control the
|
|
10
|
+
// full file content; uninstall
|
|
11
|
+
// removes it cleanly.
|
|
12
|
+
//
|
|
13
|
+
// 2. ~/.claude/CLAUDE.md — user-authored. We DO NOT
|
|
14
|
+
// overwrite. We append a single
|
|
15
|
+
// sentinel-bracketed breadcrumb
|
|
16
|
+
// line pointing at MEMTRACE.md,
|
|
17
|
+
// only if the file already exists.
|
|
18
|
+
// User edits outside our markers
|
|
19
|
+
// are preserved on uninstall.
|
|
20
|
+
//
|
|
21
|
+
// 3. ~/.claude/settings.json — JSON. We add hook entries tagged
|
|
22
|
+
// with `_managed_by: "memtrace"`.
|
|
23
|
+
// All other entries pass through
|
|
24
|
+
// untouched. Uninstall walks the
|
|
25
|
+
// hooks arrays and drops only
|
|
26
|
+
// entries with our tag.
|
|
27
|
+
//
|
|
28
|
+
// Pure functions where possible — anything that touches the filesystem
|
|
29
|
+
// is wrapped at the bottom for the install.js / uninstall.js entry
|
|
30
|
+
// points. Pure parts are unit + property tested in
|
|
31
|
+
// `test/claude-integration.test.js`.
|
|
32
|
+
|
|
33
|
+
const fs = require("fs");
|
|
34
|
+
const path = require("path");
|
|
35
|
+
const os = require("os");
|
|
36
|
+
|
|
37
|
+
// ── Constants ─────────────────────────────────────────────────────────
|
|
38
|
+
|
|
39
|
+
const BLOCK_BEGIN = "<!-- BEGIN MEMTRACE BLOCK · managed by `memtrace install` · remove with `memtrace uninstall` -->";
|
|
40
|
+
const BLOCK_END = "<!-- END MEMTRACE BLOCK -->";
|
|
41
|
+
const BLOCK_VERSION = "0.3.35";
|
|
42
|
+
|
|
43
|
+
const MEMTRACE_MD_HEADER = `<!-- This file is managed by \`memtrace install\`.
|
|
44
|
+
User edits here will be overwritten on next install.
|
|
45
|
+
For your own Claude Code directives, edit CLAUDE.md instead. -->
|
|
46
|
+
`;
|
|
47
|
+
|
|
48
|
+
const MEMTRACE_MD_BODY = `# Memtrace is active on this machine
|
|
49
|
+
|
|
50
|
+
For ANY code-discovery question — finding symbols, callers, impacts,
|
|
51
|
+
evolution, "where is", "how does", "what calls", "why does",
|
|
52
|
+
"trace through", "find the function that…" — invoke
|
|
53
|
+
\`mcp__memtrace__find_symbol\` or \`mcp__memtrace__find_code\` FIRST.
|
|
54
|
+
Memtrace returns exact \`file:start_line:end_line\` in one call.
|
|
55
|
+
|
|
56
|
+
After a search hit, follow up with the graph tools that file/grep can't
|
|
57
|
+
match: \`mcp__memtrace__get_symbol_context\` for callers/callees,
|
|
58
|
+
\`mcp__memtrace__get_impact\` for blast radius,
|
|
59
|
+
\`mcp__memtrace__get_evolution\` for history. Read source ONLY at the
|
|
60
|
+
exact spans Memtrace returned, with a few lines of context.
|
|
61
|
+
|
|
62
|
+
\`Read\` / \`Grep\` / \`Glob\` remain correct ONLY for:
|
|
63
|
+
|
|
64
|
+
- Config files (\`.env\`, \`package.json\`, README, raw JSON/YAML/TOML)
|
|
65
|
+
- File-inventory questions ("how many \`*.test.ts\` files exist")
|
|
66
|
+
- Files confirmed outside every indexed repo
|
|
67
|
+
- Reading exact lines you already have from a Memtrace result
|
|
68
|
+
|
|
69
|
+
If Memtrace returns 0 results, broaden the query or call
|
|
70
|
+
\`mcp__memtrace__list_indexed_repositories\` to confirm scope. Zero
|
|
71
|
+
results, missing language stats, or partial-looking output are NOT
|
|
72
|
+
permission to silently fall back to \`Grep\`.
|
|
73
|
+
|
|
74
|
+
This file is generated by \`memtrace install\` and removed by
|
|
75
|
+
\`memtrace uninstall\`. Edit CLAUDE.md for your own Claude Code rules
|
|
76
|
+
— that file is not touched (we only append a single breadcrumb line
|
|
77
|
+
pointing here, inside our marker block).
|
|
78
|
+
`;
|
|
79
|
+
|
|
80
|
+
const CLAUDE_MD_BREADCRUMB_LINES = [
|
|
81
|
+
BLOCK_BEGIN,
|
|
82
|
+
`<!-- block-version: ${BLOCK_VERSION} -->`,
|
|
83
|
+
"> 🧠 Memtrace is installed on this machine. **For code-discovery questions, call \\`mcp__memtrace__find_symbol\\` or \\`mcp__memtrace__find_code\\` BEFORE \\`Read\\` / \\`Grep\\` / \\`Glob\\`** — see [`MEMTRACE.md`](./MEMTRACE.md) for the full rule.",
|
|
84
|
+
BLOCK_END,
|
|
85
|
+
];
|
|
86
|
+
const CLAUDE_MD_BREADCRUMB = CLAUDE_MD_BREADCRUMB_LINES.join("\n");
|
|
87
|
+
|
|
88
|
+
const HOOK_MANAGED_TAG = "memtrace";
|
|
89
|
+
|
|
90
|
+
// ── Pure functions: MEMTRACE.md content ───────────────────────────────
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* Build the canonical MEMTRACE.md body. Pure.
|
|
94
|
+
* @returns {string}
|
|
95
|
+
*/
|
|
96
|
+
function buildMemtraceMdContent() {
|
|
97
|
+
return MEMTRACE_MD_HEADER + "\n" + MEMTRACE_MD_BODY;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
// ── Pure functions: CLAUDE.md sentinel-block management ───────────────
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* Add or refresh the Memtrace breadcrumb block in an existing
|
|
104
|
+
* CLAUDE.md. Pure — takes current content, returns new content.
|
|
105
|
+
*
|
|
106
|
+
* Strict-symmetry design: the bytes WE own are exactly
|
|
107
|
+
* `CLAUDE_MD_BREADCRUMB + "\n"`
|
|
108
|
+
* and we always append them at the very end of the file (or
|
|
109
|
+
* replace an existing block in place with the same shape). We
|
|
110
|
+
* never add a leading separator; the user's content is left
|
|
111
|
+
* byte-identical above us. This makes `apply` and `remove` exact
|
|
112
|
+
* inverses for any user content (idempotency + round-trip
|
|
113
|
+
* byte-identity hold).
|
|
114
|
+
*
|
|
115
|
+
* - No existing block: append `CLAUDE_MD_BREADCRUMB + "\n"`.
|
|
116
|
+
* - Existing block: replace the inclusive span
|
|
117
|
+
* BLOCK_BEGIN..BLOCK_END(+optional \n)
|
|
118
|
+
* with `CLAUDE_MD_BREADCRUMB + "\n"`.
|
|
119
|
+
*
|
|
120
|
+
* @param {string} existing
|
|
121
|
+
* @returns {string}
|
|
122
|
+
*/
|
|
123
|
+
function applyClaudeMdBreadcrumb(existing) {
|
|
124
|
+
const beginIdx = existing.indexOf(BLOCK_BEGIN);
|
|
125
|
+
if (beginIdx === -1) {
|
|
126
|
+
return existing + CLAUDE_MD_BREADCRUMB + "\n";
|
|
127
|
+
}
|
|
128
|
+
const endIdx = existing.indexOf(BLOCK_END, beginIdx);
|
|
129
|
+
if (endIdx === -1) {
|
|
130
|
+
throw new Error(
|
|
131
|
+
"CLAUDE.md contains a BEGIN MEMTRACE BLOCK marker without a matching END marker. " +
|
|
132
|
+
"Repair the file manually or delete the partial block before retrying."
|
|
133
|
+
);
|
|
134
|
+
}
|
|
135
|
+
const blockEnd = endIdx + BLOCK_END.length;
|
|
136
|
+
const tailNewline = existing.charAt(blockEnd) === "\n" ? 1 : 0;
|
|
137
|
+
return (
|
|
138
|
+
existing.slice(0, beginIdx) +
|
|
139
|
+
CLAUDE_MD_BREADCRUMB + "\n" +
|
|
140
|
+
existing.slice(blockEnd + tailNewline)
|
|
141
|
+
);
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/**
|
|
145
|
+
* Remove the Memtrace breadcrumb block from CLAUDE.md content.
|
|
146
|
+
* Pure. Idempotent. Strict inverse of `applyClaudeMdBreadcrumb`:
|
|
147
|
+
* removes exactly the bytes apply added, leaving everything else
|
|
148
|
+
* byte-identical.
|
|
149
|
+
*
|
|
150
|
+
* @param {string} existing
|
|
151
|
+
* @returns {string}
|
|
152
|
+
*/
|
|
153
|
+
function removeClaudeMdBreadcrumb(existing) {
|
|
154
|
+
const beginIdx = existing.indexOf(BLOCK_BEGIN);
|
|
155
|
+
if (beginIdx === -1) return existing;
|
|
156
|
+
const endIdx = existing.indexOf(BLOCK_END, beginIdx);
|
|
157
|
+
if (endIdx === -1) {
|
|
158
|
+
throw new Error(
|
|
159
|
+
"CLAUDE.md contains a BEGIN MEMTRACE BLOCK marker without a matching END marker. " +
|
|
160
|
+
"Repair the file manually before retrying."
|
|
161
|
+
);
|
|
162
|
+
}
|
|
163
|
+
const blockEnd = endIdx + BLOCK_END.length;
|
|
164
|
+
const tailNewline = existing.charAt(blockEnd) === "\n" ? 1 : 0;
|
|
165
|
+
return existing.slice(0, beginIdx) + existing.slice(blockEnd + tailNewline);
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
// ── Pure functions: settings.json hook merge ──────────────────────────
|
|
169
|
+
|
|
170
|
+
/**
|
|
171
|
+
* Add Memtrace's hook entries to a parsed settings.json object.
|
|
172
|
+
* Pure — does NOT touch the filesystem; works on objects.
|
|
173
|
+
*
|
|
174
|
+
* The added hook entries carry `_managed_by: "memtrace"` so the
|
|
175
|
+
* uninstall path can identify and remove only our entries.
|
|
176
|
+
*
|
|
177
|
+
* @param {object} settings parsed contents of settings.json (may be {})
|
|
178
|
+
* @param {string} hooksDir absolute directory containing the hook scripts
|
|
179
|
+
* @returns {object} new settings object (deep-copied)
|
|
180
|
+
*/
|
|
181
|
+
function applySettingsHooks(settings, hooksDir) {
|
|
182
|
+
const out = JSON.parse(JSON.stringify(settings || {}));
|
|
183
|
+
out.hooks = out.hooks || {};
|
|
184
|
+
|
|
185
|
+
const userPromptHook = {
|
|
186
|
+
type: "command",
|
|
187
|
+
command: path.join(hooksDir, "userprompt-claude.sh"),
|
|
188
|
+
_managed_by: HOOK_MANAGED_TAG,
|
|
189
|
+
_hook_kind: "userprompt-advisory",
|
|
190
|
+
};
|
|
191
|
+
const postToolHook = {
|
|
192
|
+
type: "command",
|
|
193
|
+
command: path.join(hooksDir, "posttool-mcp-telemetry.sh"),
|
|
194
|
+
_managed_by: HOOK_MANAGED_TAG,
|
|
195
|
+
_hook_kind: "posttool-mcp-telemetry",
|
|
196
|
+
};
|
|
197
|
+
|
|
198
|
+
// UserPromptSubmit — single matcher-less entry.
|
|
199
|
+
out.hooks.UserPromptSubmit = mergeHookList(
|
|
200
|
+
out.hooks.UserPromptSubmit || [],
|
|
201
|
+
{ hooks: [userPromptHook] },
|
|
202
|
+
userPromptHook,
|
|
203
|
+
);
|
|
204
|
+
|
|
205
|
+
// PostToolUse — matcher narrowed to our MCP tools.
|
|
206
|
+
out.hooks.PostToolUse = mergeHookList(
|
|
207
|
+
out.hooks.PostToolUse || [],
|
|
208
|
+
{ matcher: "mcp__memtrace__.*", hooks: [postToolHook] },
|
|
209
|
+
postToolHook,
|
|
210
|
+
);
|
|
211
|
+
|
|
212
|
+
return out;
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
/**
|
|
216
|
+
* Merge our hook into an existing hooks array. If a memtrace-managed
|
|
217
|
+
* entry already exists in any of the matchers, we update it in place
|
|
218
|
+
* (keep the surrounding matcher block) instead of duplicating.
|
|
219
|
+
*
|
|
220
|
+
* @param {Array} existing
|
|
221
|
+
* @param {object} ourEntry shape `{ matcher?, hooks: [...] }`
|
|
222
|
+
* @param {object} ourHook the inner hook object (with _managed_by)
|
|
223
|
+
* @returns {Array}
|
|
224
|
+
*/
|
|
225
|
+
function mergeHookList(existing, ourEntry, ourHook) {
|
|
226
|
+
const out = existing.map((block) => ({ ...block }));
|
|
227
|
+
let foundManagedBlock = -1;
|
|
228
|
+
for (let i = 0; i < out.length; i++) {
|
|
229
|
+
const block = out[i];
|
|
230
|
+
if (!block.hooks) continue;
|
|
231
|
+
const hasOurs = block.hooks.some(
|
|
232
|
+
(h) => h && h._managed_by === HOOK_MANAGED_TAG && h._hook_kind === ourHook._hook_kind,
|
|
233
|
+
);
|
|
234
|
+
if (hasOurs) {
|
|
235
|
+
foundManagedBlock = i;
|
|
236
|
+
break;
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
if (foundManagedBlock === -1) {
|
|
240
|
+
// Add our block as a new entry.
|
|
241
|
+
out.push(ourEntry);
|
|
242
|
+
return out;
|
|
243
|
+
}
|
|
244
|
+
// Replace just our hook entries inside the existing block, keep
|
|
245
|
+
// any user-added hooks in the same matcher untouched.
|
|
246
|
+
const block = { ...out[foundManagedBlock] };
|
|
247
|
+
block.hooks = block.hooks.filter(
|
|
248
|
+
(h) => !(h && h._managed_by === HOOK_MANAGED_TAG && h._hook_kind === ourHook._hook_kind),
|
|
249
|
+
);
|
|
250
|
+
block.hooks.push(ourHook);
|
|
251
|
+
out[foundManagedBlock] = block;
|
|
252
|
+
return out;
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
/**
|
|
256
|
+
* Remove all memtrace-managed hook entries from a parsed settings.json.
|
|
257
|
+
* Pure. Walks every hooks event type, drops entries tagged
|
|
258
|
+
* `_managed_by: "memtrace"`, and prunes empty containers.
|
|
259
|
+
*
|
|
260
|
+
* @param {object} settings
|
|
261
|
+
* @returns {object}
|
|
262
|
+
*/
|
|
263
|
+
function removeSettingsHooks(settings) {
|
|
264
|
+
const out = JSON.parse(JSON.stringify(settings || {}));
|
|
265
|
+
if (!out.hooks) return out;
|
|
266
|
+
|
|
267
|
+
for (const eventName of Object.keys(out.hooks)) {
|
|
268
|
+
const blocks = out.hooks[eventName];
|
|
269
|
+
if (!Array.isArray(blocks)) continue;
|
|
270
|
+
const cleaned = [];
|
|
271
|
+
for (const block of blocks) {
|
|
272
|
+
if (!block || !Array.isArray(block.hooks)) {
|
|
273
|
+
cleaned.push(block);
|
|
274
|
+
continue;
|
|
275
|
+
}
|
|
276
|
+
const filteredHooks = block.hooks.filter(
|
|
277
|
+
(h) => !(h && h._managed_by === HOOK_MANAGED_TAG),
|
|
278
|
+
);
|
|
279
|
+
if (filteredHooks.length === 0) {
|
|
280
|
+
// Block was entirely ours — drop it.
|
|
281
|
+
continue;
|
|
282
|
+
}
|
|
283
|
+
cleaned.push({ ...block, hooks: filteredHooks });
|
|
284
|
+
}
|
|
285
|
+
if (cleaned.length === 0) {
|
|
286
|
+
delete out.hooks[eventName];
|
|
287
|
+
} else {
|
|
288
|
+
out.hooks[eventName] = cleaned;
|
|
289
|
+
}
|
|
290
|
+
}
|
|
291
|
+
if (out.hooks && Object.keys(out.hooks).length === 0) {
|
|
292
|
+
delete out.hooks;
|
|
293
|
+
}
|
|
294
|
+
return out;
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
// ── Side-effecting wrappers for the installer entry point ─────────────
|
|
298
|
+
|
|
299
|
+
/**
|
|
300
|
+
* Install Memtrace's Claude Code integration: write MEMTRACE.md,
|
|
301
|
+
* breadcrumb CLAUDE.md if it exists, register hooks in settings.json.
|
|
302
|
+
*
|
|
303
|
+
* @param {object} [opts]
|
|
304
|
+
* @param {string} [opts.claudeDir] default `~/.claude`
|
|
305
|
+
* @param {string} [opts.hooksDir] absolute dir where hook scripts live
|
|
306
|
+
* @returns {object} summary `{ wrote: [...], skipped: [...] }`
|
|
307
|
+
*/
|
|
308
|
+
function installToFs(opts = {}) {
|
|
309
|
+
const claudeDir = opts.claudeDir || path.join(os.homedir(), ".claude");
|
|
310
|
+
const hooksDir = opts.hooksDir;
|
|
311
|
+
if (!hooksDir) {
|
|
312
|
+
throw new Error("installToFs: opts.hooksDir is required (absolute path to hooks/)");
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
const summary = { wrote: [], skipped: [] };
|
|
316
|
+
|
|
317
|
+
fs.mkdirSync(claudeDir, { recursive: true });
|
|
318
|
+
|
|
319
|
+
// 1. Write MEMTRACE.md (always overwrite — we own this file).
|
|
320
|
+
const memtraceMdPath = path.join(claudeDir, "MEMTRACE.md");
|
|
321
|
+
fs.writeFileSync(memtraceMdPath, buildMemtraceMdContent());
|
|
322
|
+
summary.wrote.push(memtraceMdPath);
|
|
323
|
+
|
|
324
|
+
// 2. Breadcrumb in CLAUDE.md (only if file exists; we don't create it).
|
|
325
|
+
const claudeMdPath = path.join(claudeDir, "CLAUDE.md");
|
|
326
|
+
if (fs.existsSync(claudeMdPath)) {
|
|
327
|
+
const existing = fs.readFileSync(claudeMdPath, "utf8");
|
|
328
|
+
const updated = applyClaudeMdBreadcrumb(existing);
|
|
329
|
+
if (updated !== existing) {
|
|
330
|
+
fs.writeFileSync(claudeMdPath, updated);
|
|
331
|
+
summary.wrote.push(claudeMdPath);
|
|
332
|
+
} else {
|
|
333
|
+
summary.skipped.push(claudeMdPath + " (already up to date)");
|
|
334
|
+
}
|
|
335
|
+
} else {
|
|
336
|
+
summary.skipped.push(claudeMdPath + " (does not exist; not creating)");
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
// 3. Register hooks in settings.json.
|
|
340
|
+
const settingsPath = path.join(claudeDir, "settings.json");
|
|
341
|
+
let settings = {};
|
|
342
|
+
if (fs.existsSync(settingsPath)) {
|
|
343
|
+
try {
|
|
344
|
+
settings = JSON.parse(fs.readFileSync(settingsPath, "utf8"));
|
|
345
|
+
} catch (e) {
|
|
346
|
+
throw new Error(
|
|
347
|
+
`Failed to parse ${settingsPath}: ${e.message}. ` +
|
|
348
|
+
"Repair manually before retrying."
|
|
349
|
+
);
|
|
350
|
+
}
|
|
351
|
+
}
|
|
352
|
+
const updated = applySettingsHooks(settings, hooksDir);
|
|
353
|
+
fs.writeFileSync(settingsPath, JSON.stringify(updated, null, 2) + "\n");
|
|
354
|
+
summary.wrote.push(settingsPath);
|
|
355
|
+
|
|
356
|
+
return summary;
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
/**
|
|
360
|
+
* Reverse of installToFs. Removes MEMTRACE.md, removes our
|
|
361
|
+
* breadcrumb from CLAUDE.md, removes our hooks from settings.json.
|
|
362
|
+
* Leaves all user-authored content intact.
|
|
363
|
+
*
|
|
364
|
+
* @param {object} [opts]
|
|
365
|
+
* @param {string} [opts.claudeDir] default `~/.claude`
|
|
366
|
+
* @returns {object} summary `{ removed: [...], skipped: [...] }`
|
|
367
|
+
*/
|
|
368
|
+
function uninstallFromFs(opts = {}) {
|
|
369
|
+
const claudeDir = opts.claudeDir || path.join(os.homedir(), ".claude");
|
|
370
|
+
const summary = { removed: [], skipped: [] };
|
|
371
|
+
|
|
372
|
+
// 1. Remove MEMTRACE.md.
|
|
373
|
+
const memtraceMdPath = path.join(claudeDir, "MEMTRACE.md");
|
|
374
|
+
if (fs.existsSync(memtraceMdPath)) {
|
|
375
|
+
fs.unlinkSync(memtraceMdPath);
|
|
376
|
+
summary.removed.push(memtraceMdPath);
|
|
377
|
+
} else {
|
|
378
|
+
summary.skipped.push(memtraceMdPath + " (not present)");
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
// 2. Strip breadcrumb from CLAUDE.md.
|
|
382
|
+
const claudeMdPath = path.join(claudeDir, "CLAUDE.md");
|
|
383
|
+
if (fs.existsSync(claudeMdPath)) {
|
|
384
|
+
const existing = fs.readFileSync(claudeMdPath, "utf8");
|
|
385
|
+
const updated = removeClaudeMdBreadcrumb(existing);
|
|
386
|
+
if (updated === "") {
|
|
387
|
+
// Our breadcrumb was the only content — remove the file.
|
|
388
|
+
fs.unlinkSync(claudeMdPath);
|
|
389
|
+
summary.removed.push(claudeMdPath + " (file was empty after cleanup)");
|
|
390
|
+
} else if (updated !== existing) {
|
|
391
|
+
fs.writeFileSync(claudeMdPath, updated);
|
|
392
|
+
summary.removed.push(claudeMdPath + " (breadcrumb removed)");
|
|
393
|
+
} else {
|
|
394
|
+
summary.skipped.push(claudeMdPath + " (no memtrace breadcrumb found)");
|
|
395
|
+
}
|
|
396
|
+
} else {
|
|
397
|
+
summary.skipped.push(claudeMdPath + " (not present)");
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
// 3. Strip our hooks from settings.json.
|
|
401
|
+
const settingsPath = path.join(claudeDir, "settings.json");
|
|
402
|
+
if (fs.existsSync(settingsPath)) {
|
|
403
|
+
try {
|
|
404
|
+
const settings = JSON.parse(fs.readFileSync(settingsPath, "utf8"));
|
|
405
|
+
const updated = removeSettingsHooks(settings);
|
|
406
|
+
const before = JSON.stringify(settings);
|
|
407
|
+
const after = JSON.stringify(updated);
|
|
408
|
+
if (before !== after) {
|
|
409
|
+
if (Object.keys(updated).length === 0) {
|
|
410
|
+
fs.unlinkSync(settingsPath);
|
|
411
|
+
summary.removed.push(settingsPath + " (file was empty after cleanup)");
|
|
412
|
+
} else {
|
|
413
|
+
fs.writeFileSync(settingsPath, JSON.stringify(updated, null, 2) + "\n");
|
|
414
|
+
summary.removed.push(settingsPath + " (memtrace hook entries removed)");
|
|
415
|
+
}
|
|
416
|
+
} else {
|
|
417
|
+
summary.skipped.push(settingsPath + " (no memtrace hook entries found)");
|
|
418
|
+
}
|
|
419
|
+
} catch (e) {
|
|
420
|
+
summary.skipped.push(settingsPath + ` (parse error: ${e.message})`);
|
|
421
|
+
}
|
|
422
|
+
} else {
|
|
423
|
+
summary.skipped.push(settingsPath + " (not present)");
|
|
424
|
+
}
|
|
425
|
+
|
|
426
|
+
return summary;
|
|
427
|
+
}
|
|
428
|
+
|
|
429
|
+
module.exports = {
|
|
430
|
+
// Constants — exported for tests
|
|
431
|
+
BLOCK_BEGIN,
|
|
432
|
+
BLOCK_END,
|
|
433
|
+
BLOCK_VERSION,
|
|
434
|
+
HOOK_MANAGED_TAG,
|
|
435
|
+
CLAUDE_MD_BREADCRUMB,
|
|
436
|
+
|
|
437
|
+
// Pure functions
|
|
438
|
+
buildMemtraceMdContent,
|
|
439
|
+
applyClaudeMdBreadcrumb,
|
|
440
|
+
removeClaudeMdBreadcrumb,
|
|
441
|
+
applySettingsHooks,
|
|
442
|
+
removeSettingsHooks,
|
|
443
|
+
|
|
444
|
+
// Side-effecting entry points
|
|
445
|
+
installToFs,
|
|
446
|
+
uninstallFromFs,
|
|
447
|
+
};
|