memory-pulse 0.1.5 → 0.2.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 (3) hide show
  1. package/README.md +37 -3
  2. package/package.json +1 -1
  3. package/server.mjs +252 -26
package/README.md CHANGED
@@ -65,15 +65,35 @@ Then tell your agent to remember things. Record a withdrawn number with
65
65
  `kind: "correction"` and it will outrank the history that contained it — at
66
66
  every brief size, in every session.
67
67
 
68
+ **Enforce corrections, don't just surface them.** Showing an agent a
69
+ correction is measurably not enough — agents re-violate corrections they were
70
+ just shown. `install-hook` also installs a PreToolUse guard: an Edit or Write
71
+ that writes back a withdrawn value is **blocked**, and the agent is told which
72
+ ledger line retired it and when. A comparison that names the replacement
73
+ ("was $49, now $29") passes; only a bare reintroduction is blocked. Record
74
+ corrections with the exact terms:
75
+
76
+ ```
77
+ remember({ cause: "pricing-shipped", effect: "price-corrected", kind: "correction",
78
+ note: "measured willingness to pay is $29", withdrawn: ["$49"], replacement: ["$29"] })
79
+ ```
80
+
68
81
  ### Commands
69
82
 
70
83
  ```
71
- npx memory-pulse brief # the re-entry brief (what the hook prints)
84
+ npx memory-pulse brief # the re-entry brief (what the SessionStart hook prints)
85
+ npx memory-pulse guard # PreToolUse hook: blocks edits that reintroduce withdrawn terms
86
+ npx memory-pulse report # correction re-violation scoreboard, computed locally
87
+ npx memory-pulse bench # instant measured metrics on YOUR ledger
72
88
  npx memory-pulse stats # your telemetry capsule, signature verified by the engine
73
89
  npx memory-pulse badge # README badge markdown from your own signed numbers
74
- npx memory-pulse install-hook # Claude Code SessionStart hook
90
+ npx memory-pulse install-hook # installs both hooks (idempotent); --project commits them to the repo
75
91
  ```
76
92
 
93
+ The plugin also ships a **skill** (`skills/memory-pulse/SKILL.md`) that teaches
94
+ the agent when to pulse, how to record corrections with withdrawn terms, and
95
+ to respect the guard.
96
+
77
97
  ## What runs where (the privacy contract)
78
98
 
79
99
  - Your ledger is a **local file**: `.memory-pulse/events.jsonl` in your
@@ -87,7 +107,21 @@ npx memory-pulse install-hook # Claude Code SessionStart hook
87
107
  (`.memory-pulse/telemetry.rain`): the engine advances it on each read call
88
108
  and hands it back — it never stores it. `stats` verifies the signature;
89
109
  `badge` turns it into a README badge. Delete the file and it restarts.
90
- - This client is the entire client, zero dependencies, read it in one sitting.
110
+ - **State persistence, no database.** After a read the engine hands back a
111
+ signed **memory key** (`.memory-pulse/memory.rain`, git-ignored). The next
112
+ read presents it and the engine resumes from it, ingesting only the events
113
+ recorded since — the answer is byte-identical to a full rebuild, and any
114
+ mismatch (edited history, a stepped ledger size, a bad signature) falls back
115
+ to a rebuild and says why. Lose the file and you lose nothing but one
116
+ rebuild. `MEMORY_PULSE_MEMORY_KEY=off` disables it.
117
+ - **Memory integrity.** A note that reads like an instruction ("ignore previous
118
+ instructions", "run this command", a fake system tag) is refused by
119
+ `remember` and, if one is already in a ledger, quarantined at read time and
120
+ reported — memory is never rendered into your agent's context as an
121
+ instruction. The signed capsule also raises a **drift alert** when a ledger
122
+ loses corrections, shrinks, or its usage shape jumps; the brief footer
123
+ shows it. Both checks are deterministic lists you can read, not a model.
124
+ - This client is the entire client: one file, zero dependencies, readable in one sitting.
91
125
 
92
126
  ## Pricing
93
127
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "memory-pulse",
3
- "version": "0.1.5",
3
+ "version": "0.2.0",
4
4
  "description": "Causal memory for coding agents that costs ~670 tokens, not your context window. Four MCP tools: re-enter a project, recall what caused what, record findings, run code against memory.",
5
5
  "type": "module",
6
6
  "bin": {
package/server.mjs CHANGED
@@ -20,11 +20,14 @@
20
20
  * MEMORY_PULSE_LEDGER override the ledger path (default: ./.memory-pulse/events.jsonl)
21
21
  */
22
22
  import readline from "node:readline";
23
+ import http from "node:http";
24
+ import https from "node:https";
25
+ import { gzipSync } from "node:zlib";
23
26
  import { existsSync, mkdirSync, readFileSync, appendFileSync, writeFileSync, realpathSync } from "node:fs";
24
27
  import { dirname, isAbsolute, join } from "node:path";
25
28
  import { fileURLToPath } from "node:url";
26
29
 
27
- const API = (process.env.MEMORY_PULSE_API ?? "https://memory-pulse.strategic-innovations.workers.dev").replace(/\/$/, "");
30
+ const API = (process.env.MEMORY_PULSE_API ?? "https://pulse.strategic-innovations.ai").replace(/\/$/, "");
28
31
  const KEY = process.env.MEMORY_PULSE_KEY ?? null;
29
32
 
30
33
  // ---------------------------------------------------------------- ledger ----
@@ -49,7 +52,31 @@ function readEvents() {
49
52
  return { path, events };
50
53
  }
51
54
 
52
- function appendEvent({ cause, effect, note, kind, tags, pinned }) {
55
+ // The engine's injection-through-memory list, mirrored so a refusal happens
56
+ // before the write. Keep in step with the engine; the engine is authoritative.
57
+ const INSTRUCTION_PATTERNS = [
58
+ ["override", /\b(ignore|disregard|forget)\b[^.\n]{0,40}\b(previous|prior|above|all|earlier)\b[^.\n]{0,20}\b(instructions?|rules?|prompts?)\b/i],
59
+ ["persona", /\byou are now\b|\bfrom now on,? you\b|\bact as (an?|the) (system|admin|developer)\b/i],
60
+ ["fake-role-tag", /<\/?\s*(system|assistant|tool|user|human|developer)\s*>|\[(system|assistant|tool)\s*:?\s*\]/i],
61
+ ["system-prompt", /\b(reveal|print|show|dump)\b[^.\n]{0,30}\b(system prompt|hidden prompt|your instructions)\b/i],
62
+ ["command-exec", /\b(run|execute|paste)\b[^.\n]{0,25}\b(this|the following)\b[^.\n]{0,15}\b(command|script|shell|code)\b/i],
63
+ ["shell-pipe", /\b(curl|wget)\b[^\n]{0,120}\|\s*(sudo\s+)?(sh|bash|zsh)\b/i],
64
+ ["destructive", /\b(rm\s+-rf\s+[\/~]|drop\s+table|truncate\s+table|format\s+c:)/i],
65
+ ["secret-exfil", /\b(send|post|upload|exfiltrat\w*|leak)\b[^.\n]{0,40}\b(api[\s_-]?keys?|secrets?|tokens?|passwords?|credentials?)\b/i],
66
+ ["hide-from-user", /\b(do not|don't|never)\b[^.\n]{0,20}\b(tell|show|mention|reveal)\b[^.\n]{0,20}\b(the )?(user|human|operator)\b/i],
67
+ ];
68
+ export function instructionLike(text) {
69
+ if (typeof text !== "string" || !text) return [];
70
+ return INSTRUCTION_PATTERNS.filter(([, re]) => re.test(text)).map(([id]) => id);
71
+ }
72
+
73
+ function appendEvent({ cause, effect, note, kind, tags, pinned, withdrawn, replacement }) {
74
+ // Instruction-like notes are refused at the write. The engine quarantines
75
+ // them at read time too (and reports it), but a note that would be rendered
76
+ // into every future session as an instruction should never reach the ledger.
77
+ // Same deterministic list as the engine; no model in the loop.
78
+ const hits = instructionLike(note);
79
+ if (hits.length) return { written: false, reason: "instruction-like note refused", patterns: hits, hint: "Record what happened, not what to do. Rephrase as a finding." };
53
80
  const { path, events } = readEvents();
54
81
  const dup = events.find((e) => e.cause === cause && e.effect === effect && (e.note ?? "") === (note ?? ""));
55
82
  if (dup) return { written: false, reason: "duplicate", t: dup.t, ledger: path };
@@ -62,6 +89,19 @@ function appendEvent({ cause, effect, note, kind, tags, pinned }) {
62
89
  if (note) event.note = note;
63
90
  if (Array.isArray(tags) && tags.length) event.tags = tags;
64
91
  if (pinned) event.pinned = true;
92
+ // Withdrawn terms are what the guard enforces: exact strings that must not
93
+ // be written again. Only explicit terms count — the guard never guesses.
94
+ if (Array.isArray(withdrawn)) {
95
+ const terms = withdrawn.map((w) => String(w).trim()).filter((w) => w.length >= 2);
96
+ if (terms.length) event.withdrawn = terms;
97
+ }
98
+ // Replacement terms let the guard tell a REINTRODUCTION ("price is $49")
99
+ // apart from a DISAVOWAL or comparison ("was $49, now $29"): an edit that
100
+ // carries a replacement alongside the withdrawn term is allowed.
101
+ if (Array.isArray(replacement)) {
102
+ const terms = replacement.map((w) => String(w).trim()).filter((w) => w.length >= 1);
103
+ if (terms.length) event.replacement = terms;
104
+ }
65
105
  appendFileSync(path, JSON.stringify(event) + "\n");
66
106
  // Echo the canonical stored event back. A shell-quoting accident once ate a
67
107
  // word from a note SILENTLY; the caller must be able to see what the ledger
@@ -86,31 +126,98 @@ function writeTelemetry(capsule) {
86
126
  }
87
127
  const projectName = () => process.env.MEMORY_PULSE_PROJECT || process.cwd().split(/[\\/]/).filter(Boolean).pop() || "project";
88
128
  const fmtK = (n) => (n >= 1_000_000 ? `${(n / 1_000_000).toFixed(1)}M` : n >= 1000 ? `${Math.round(n / 1000)}k` : String(n));
89
- function telemetryFooter(c) {
129
+ export function telemetryFooter(c) {
90
130
  const k = c?.counters;
91
131
  if (!k || !k.calls) return "";
92
- return `— memory-pulse · ${k.pulse} re-entries · ${k.correctionsSurfaced} corrections surfaced · ~${fmtK(k.tokensSavedEst)} tokens saved (est., signed)`;
132
+ const drift = c.drift?.reasons?.length ? ` · ⚠ drift: ${c.drift.reasons.join("; ")}` : "";
133
+ return `— memory-pulse · ${k.pulse} re-entries · ${k.correctionsSurfaced} corrections surfaced · ~${fmtK(k.tokensSavedEst)} tokens saved (est., signed)${drift}`;
134
+ }
135
+
136
+ // ------------------------------------------------------------ memory key ----
137
+ // State persistence without a database. After a read the engine hands back a
138
+ // signed memory key; presenting it on the next read resumes the memory and
139
+ // ingests only the events recorded since (measured: a full re-entry on an
140
+ // 825-event ledger went from 4.5 s to 1.5 s). It lives beside your ledger as
141
+ // memory.rain, it is yours, and a lost or stale key costs one rebuild — never
142
+ // data. It never enters the agent's context. MEMORY_PULSE_MEMORY_KEY=off disables it.
143
+ const memoryKeyPath = () => join(dirname(ledgerPath()), "memory.rain");
144
+ const memoryKeyOn = () => (process.env.MEMORY_PULSE_MEMORY_KEY || "on") !== "off";
145
+ function readMemoryKey() {
146
+ if (!memoryKeyOn()) return null;
147
+ try { return JSON.parse(readFileSync(memoryKeyPath(), "utf8")); } catch { return null; }
148
+ }
149
+ function writeMemoryKey(k) {
150
+ if (!memoryKeyOn() || !k || typeof k !== "object") return;
151
+ try {
152
+ const dir = dirname(memoryKeyPath());
153
+ mkdirSync(dir, { recursive: true });
154
+ writeFileSync(memoryKeyPath(), JSON.stringify(k));
155
+ // The ledger is the source of truth; the key is a rebuildable cache and
156
+ // has no business in version control.
157
+ const gi = join(dir, ".gitignore");
158
+ if (!existsSync(gi)) writeFileSync(gi, "memory.rain\n");
159
+ } catch { /* a read-only checkout must not break a read call */ }
93
160
  }
94
161
 
95
162
  // ------------------------------------------------------------------- api ----
163
+ // Transport: Node's own http(s) on a FRESH HTTP/1.1 connection per call.
164
+ // The global fetch pools an HTTP/2 session that the edge retires after a
165
+ // few large requests, and undici then throws ERR_HTTP2_INVALID_SESSION on
166
+ // reuse instead of reconnecting — `bench` on an 822-event ledger lost 11 of
167
+ // 12 sequential calls to it, and a retry reused the same dead session. No
168
+ // pooling, no session, no dependency.
169
+ function postJson(url, headers, payload) {
170
+ const u = new URL(url);
171
+ const mod = u.protocol === "http:" ? http : https;
172
+ // Ledgers compress 5-10x (notes are prose); anything past 4 KB goes up
173
+ // gzipped. The engine inflates it; a small body is not worth the header.
174
+ const gz = Buffer.byteLength(payload) >= 4096;
175
+ const body = gz ? gzipSync(payload) : Buffer.from(payload);
176
+ return new Promise((resolve, reject) => {
177
+ const req = mod.request(u, {
178
+ method: "POST", agent: false,
179
+ headers: { ...headers, ...(gz ? { "content-encoding": "gzip" } : {}), "content-length": body.length, connection: "close" },
180
+ }, (res) => {
181
+ let data = "";
182
+ res.setEncoding("utf8");
183
+ res.on("data", (c) => { data += c; });
184
+ res.on("end", () => resolve({ ok: res.statusCode >= 200 && res.statusCode < 300, status: res.statusCode, json: async () => JSON.parse(data) }));
185
+ });
186
+ req.on("error", reject);
187
+ req.end(body);
188
+ });
189
+ }
190
+
191
+ // Transient transport failures on a fresh connection (a TLS record hiccup, a
192
+ // reset) get exactly one retry — a new connection each time, so the retry
193
+ // means something. Anything else surfaces immediately with its real cause.
194
+ const TRANSIENT = /ERR_SSL|ECONNRESET|EPIPE|ETIMEDOUT|ECONNREFUSED|EAI_AGAIN/;
195
+ async function postJsonRetry(url, headers, payload) {
196
+ try { return await postJson(url, headers, payload); }
197
+ catch (first) {
198
+ if (!TRANSIENT.test(String(first?.code || first?.message || ""))) throw first;
199
+ return postJson(url, headers, payload);
200
+ }
201
+ }
202
+
96
203
  async function callApi(route, body) {
97
204
  const prior = readTelemetry();
98
- body = { ...body, project: projectName(), ...(prior ? { telemetry: prior } : {}) };
205
+ const mk = readMemoryKey();
206
+ const wantKey = !mk && memoryKeyOn() && Array.isArray(body.events) && body.events.length >= 500;
207
+ body = { ...body, project: projectName(), ...(prior ? { telemetry: prior } : {}), ...(mk ? { key: mk } : wantKey ? { wantKey: true } : {}) };
99
208
  let res;
100
209
  try {
101
- res = await fetch(`${API}${route}`, {
102
- method: "POST",
103
- headers: { "content-type": "application/json", ...(KEY ? { "x-mp-key": KEY } : {}) },
104
- body: JSON.stringify(body),
105
- });
106
- } catch {
210
+ res = await postJsonRetry(`${API}${route}`, { "content-type": "application/json", ...(KEY ? { "x-mp-key": KEY } : {}) }, JSON.stringify(body));
211
+ } catch (err) {
212
+ const why = err?.cause?.code || err?.code || err?.message || String(err);
107
213
  throw new Error(
108
- "memory-pulse API unreachable. `remember` still works (it writes locally); " +
214
+ `memory-pulse API unreachable (${why}). \`remember\` still works (it writes locally); ` +
109
215
  "pulse/recall/execute need the network. Check connectivity or MEMORY_PULSE_API.",
110
216
  );
111
217
  }
112
218
  const out = await res.json().catch(() => ({}));
113
219
  if (res.ok && out.telemetry) { writeTelemetry(out.telemetry); }
220
+ if (res.ok && out.key) { writeMemoryKey(out.key); delete out.key; }
114
221
  if (!res.ok) {
115
222
  let msg = out.error ?? `API error ${res.status}`;
116
223
  if (out.upgrade) msg += ` — upgrade: ${out.upgrade}`;
@@ -140,8 +247,8 @@ export const TOOLS = [
140
247
  name: "recall",
141
248
  description:
142
249
  "Query the causal graph: what an event caused (effects), what caused it (causes), a multi-hop " +
143
- "chain (pulse), or when a link was strongest (when). Returns nothing rather than guessing " +
144
- "when the answer is not confident enough.",
250
+ "chain (pulse), or when a link was strongest (when). Hits carry a confidence; `exact` lists the " +
251
+ "recorded links verbatim, so a weak read never hides a correction. Returns nothing rather than guessing.",
145
252
  inputSchema: {
146
253
  type: "object",
147
254
  properties: {
@@ -166,6 +273,8 @@ export const TOOLS = [
166
273
  effect: { type: "string" },
167
274
  note: { type: "string", description: "What was measured, and how." },
168
275
  kind: { type: "string", enum: ["event", "correction"], description: "Default event." },
276
+ withdrawn: { type: "array", items: { type: "string" }, description: "For corrections: the exact strings that were withdrawn (a number, a name, a claim). The guard hook blocks an edit that writes them back." },
277
+ replacement: { type: "array", items: { type: "string" }, description: "For corrections: the corrected value(s). An edit containing both a withdrawn term and a replacement (a comparison or disavowal) is allowed through the guard." },
169
278
  tags: { type: "array", items: { type: "string" } },
170
279
  pinned: { type: "boolean", description: "Never decays out of the brief." },
171
280
  },
@@ -216,7 +325,7 @@ async function dispatch(msg) {
216
325
  return ok(id, {
217
326
  protocolVersion: SUPPORTED.includes(wanted) ? wanted : SUPPORTED[0],
218
327
  capabilities: { tools: {} },
219
- serverInfo: { name: "memory-pulse", version: "0.1.5" },
328
+ serverInfo: { name: "memory-pulse", version: "0.2.0" },
220
329
  });
221
330
  }
222
331
  if (method === "notifications/initialized" || method === "initialized") return;
@@ -244,6 +353,9 @@ async function cliBrief() {
244
353
  try {
245
354
  const out = await callApi("/v1/pulse", { events, tier: process.env.MEMORY_PULSE_BRIEF_TIER || "brief" });
246
355
  if (out.text) process.stdout.write(out.text + "\n");
356
+ if (Array.isArray(out.quarantined) && out.quarantined.length) {
357
+ process.stdout.write(`⚠ ${out.quarantined.length} note(s) quarantined — instruction-like content was not rendered (t=${out.quarantined.map((q) => q.t).join(", ")})\n`);
358
+ }
247
359
  const foot = telemetryFooter(out.telemetry);
248
360
  if (foot) process.stdout.write(foot + "\n");
249
361
  } catch (e) {
@@ -267,7 +379,7 @@ async function cliStats() {
267
379
  console.log(` tokens saved (est.) ~${k.tokensSavedEst.toLocaleString()}`);
268
380
  if (c.reset) console.log(` note: ${c.reset}`);
269
381
  try {
270
- const res = await fetch(`${API}/v1/verify-telemetry`, { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ telemetry: c }) });
382
+ const res = await postJson(`${API}/v1/verify-telemetry`, { "content-type": "application/json" }, JSON.stringify({ telemetry: c }));
271
383
  const v = await res.json();
272
384
  console.log(v.valid ? " signature ✓ verified by the engine (keyless check anyone can repeat)" : ` signature ✗ ${v.reason ?? "invalid"}`);
273
385
  } catch { console.log(" signature ? engine unreachable"); }
@@ -281,27 +393,138 @@ function cliBadge() {
281
393
  console.log(`[![memory-pulse](https://img.shields.io/badge/memory--pulse-${label}-6366f1)](https://pulse.strategic-innovations.ai)`);
282
394
  }
283
395
 
396
+ // ---------------------------------------------------------------- guard ----
397
+ // `npx memory-pulse guard` — a Claude Code PreToolUse hook for Edit/Write.
398
+ // Surfacing a correction is not enough (agents re-violate corrections they
399
+ // were just shown); this ENFORCES it: an edit that writes back a withdrawn
400
+ // term is blocked (exit 2) and the agent is told which ledger line retired
401
+ // it and when. Deterministic, offline, only explicit `withdrawn` terms count.
402
+ const violationsPath = () => join(dirname(ledgerPath()), "violations.jsonl");
403
+ function textOfToolInput(input) {
404
+ if (!input || typeof input !== "object") return "";
405
+ const parts = [];
406
+ if (typeof input.new_string === "string") parts.push(input.new_string);
407
+ if (typeof input.content === "string") parts.push(input.content);
408
+ if (Array.isArray(input.edits)) for (const e of input.edits) if (typeof e?.new_string === "string") parts.push(e.new_string);
409
+ return parts.join("\n");
410
+ }
411
+ export function findViolations(events, text, filePath = "") {
412
+ const hits = [];
413
+ if (!text) return hits;
414
+ // The ledger and its sidecars are where corrections are RECORDED; guarding
415
+ // them would block the act of correcting.
416
+ if (/(^|[\\/])\.memory-pulse([\\/]|$)/.test(filePath)) return hits;
417
+ for (const e of events) {
418
+ if (e.kind !== "correction" || !Array.isArray(e.withdrawn)) continue;
419
+ // A comparison or disavowal names the old value next to the new one;
420
+ // only a bare reintroduction is blocked. (Adversarial review, 2026-08-31:
421
+ // a guard that fires on "the $49 figure is withdrawn" livelocks the agent.)
422
+ const disavowed = Array.isArray(e.replacement) && e.replacement.some((r) => r && text.includes(r));
423
+ if (disavowed) continue;
424
+ for (const term of e.withdrawn) if (term && text.includes(term)) hits.push({ term, t: e.t, cause: e.cause, effect: e.effect, note: e.note ?? "", replacement: e.replacement ?? [] });
425
+ }
426
+ return hits;
427
+ }
428
+ async function cliGuard() {
429
+ let raw = "";
430
+ for await (const chunk of process.stdin) raw += chunk;
431
+ let payload; try { payload = JSON.parse(raw); } catch { return; } // not a hook call: allow
432
+ const text = textOfToolInput(payload.tool_input);
433
+ const { events } = readEvents();
434
+ const file = payload.tool_input?.file_path ?? "";
435
+ const hits = findViolations(events, text, file);
436
+ if (!hits.length) return;
437
+ try { appendFileSync(violationsPath(), JSON.stringify({ at: new Date().toISOString(), file, hits: hits.map((h) => ({ term: h.term, t: h.t })) }) + "\n"); } catch { /* reporting is best effort */ }
438
+ const lines = hits.map((h) => ` • "${h.term}" was withdrawn at ledger t${h.t} (${h.cause} -> ${h.effect})${h.note ? `: ${h.note}` : ""}${h.replacement?.length ? ` — use ${h.replacement.join(" / ")}` : ""}`);
439
+ process.stderr.write(`memory-pulse guard: this edit reintroduces a withdrawn value.\n${lines.join("\n")}\nUse the corrected value (mentioning both old and new in a comparison is fine), or record a new correction if the old one is wrong.\n`);
440
+ process.exit(2);
441
+ }
442
+
443
+ // `npx memory-pulse report` — correction re-violation scoreboard, computed
444
+ // on your machine from files you own. Nothing is sent anywhere.
445
+ function cliReport() {
446
+ const { events } = readEvents();
447
+ const corrections = events.filter((e) => e.kind === "correction");
448
+ let blocks = [];
449
+ try { blocks = readFileSync(violationsPath(), "utf8").split("\n").filter(Boolean).map((l) => JSON.parse(l)); } catch { /* none yet */ }
450
+ const byT = new Map();
451
+ for (const b of blocks) for (const h of b.hits) byT.set(h.t, (byT.get(h.t) ?? 0) + 1);
452
+ console.log(`memory-pulse report — ${corrections.length} corrections recorded, ${corrections.filter((c) => c.withdrawn?.length).length} with enforceable withdrawn terms`);
453
+ console.log(` edits blocked by the guard: ${blocks.reduce((n, b) => n + b.hits.length, 0)}${blocks.length ? ` (last: ${blocks[blocks.length - 1].at.slice(0, 10)})` : ""}`);
454
+ const top = [...byT.entries()].sort((a, b) => b[1] - a[1]).slice(0, 5);
455
+ for (const [t, n] of top) { const c = corrections.find((x) => x.t === t); console.log(` ${n}× t${t} ${c ? `${c.cause} -> ${c.effect}` : ""} — withdrawn: ${c?.withdrawn?.join(", ")}`); }
456
+ const unenforced = corrections.filter((c) => !c.withdrawn?.length);
457
+ if (unenforced.length) console.log(` ${unenforced.length} correction(s) have no withdrawn terms and cannot be enforced — add them with remember(withdrawn: [...])`);
458
+ }
459
+
460
+ // `npx memory-pulse bench` — instant measured metrics on YOUR ledger: how much
461
+ // re-entry saves, whether every correction surfaces first, whether the guard
462
+ // would block each withdrawn term, and recall self-consistency on your own
463
+ // causal links. Numbers, not adjectives.
464
+ async function cliBench() {
465
+ const { events } = readEvents();
466
+ if (!events.length) { console.log("no ledger — nothing to measure yet"); return; }
467
+ const corrections = events.filter((e) => e.kind === "correction");
468
+ const dump = JSON.stringify(events).length;
469
+ const pulse = await callApi("/v1/pulse", { events, tier: "brief" }).catch((e) => ({ error: e.message }));
470
+ console.log(`memory-pulse bench — ${events.length} events, ${corrections.length} corrections`);
471
+ if (pulse.error) { console.log(` brief: unavailable (${pulse.error})`); }
472
+ else {
473
+ const lines = pulse.text.split("\n");
474
+ const first = lines.findIndex((l) => l.startsWith("CORRECTIONS"));
475
+ const headerN = Number((lines[first] ?? "").match(/CORRECTIONS \((\d+)\)/)?.[1] ?? 0);
476
+ const listed = corrections.filter((c) => pulse.text.includes(`${c.cause} -> ${c.effect}`)).length;
477
+ console.log(` re-entry brief: ${pulse.chars.toLocaleString()} chars vs ${dump.toLocaleString()} char dump (${pulse.savedVsFullDump ?? "no saving on a ledger this small"})`);
478
+ console.log(` corrections: ${headerN}/${corrections.length} counted in the block${first === 0 ? " (block is first)" : first > 0 ? ` (block at line ${first + 1})` : " (NO BLOCK — check this)"}, ${listed} listed at this tier${headerN > listed ? ` (${headerN - listed} elided — a bigger tier lists them all)` : ""}`);
479
+ }
480
+ const enforceable = corrections.filter((c) => c.withdrawn?.length);
481
+ const guardHits = enforceable.filter((c) => findViolations(events, c.withdrawn.join(" ")).length > 0).length;
482
+ console.log(` guard: ${guardHits}/${enforceable.length} withdrawn-term sets would be blocked if rewritten${corrections.length > enforceable.length ? ` (${corrections.length - enforceable.length} corrections lack withdrawn terms)` : ""}`);
483
+ const sample = events.filter((e) => e.kind !== "correction").slice(-12);
484
+ let ok = 0, tried = 0, errors = 0, lastErr = "";
485
+ for (const e of sample) {
486
+ tried++;
487
+ // An API error is NOT a miss. Counting failures as misses would let a
488
+ // broken network read as a broken memory — report them separately.
489
+ let r;
490
+ try { r = await callApi("/v1/recall", { events, op: "effects", subject: e.cause, topk: 3 }); }
491
+ catch (err) { errors++; lastErr = String(err?.message ?? err); continue; }
492
+ if (r?.result?.hits?.some((h) => h.entity === e.effect)) ok++;
493
+ }
494
+ if (tried) console.log(` recall self-consistency: ${ok}/${tried - errors} recent links recovered (top-5 effects of the cause include the recorded effect)${errors ? ` — ${errors} call(s) errored: ${lastErr.slice(0, 80)}` : ""}`);
495
+ console.log(` telemetry: ${readTelemetry()?.counters ? "signed capsule present — run `stats`" : "none yet"}`);
496
+ }
497
+
284
498
  // `npx memory-pulse install-hook` — make re-entry automatic: a Claude Code
285
499
  // SessionStart hook that runs the brief. Idempotent; merges, never clobbers.
286
500
  function cliInstallHook() {
287
- const home = process.env.MEMORY_PULSE_SETTINGS_DIR || join(process.env.HOME || "", ".claude");
501
+ // --project writes to <repo>/.claude/settings.json so the hooks TRAVEL WITH
502
+ // THE REPO: a teammate who clones is guarded without installing anything.
503
+ // (Adversarial review, 2026-08-31: a user-scope hook does not spread.)
504
+ const project = process.argv.includes("--project");
505
+ const home = process.env.MEMORY_PULSE_SETTINGS_DIR || (project ? join(process.cwd(), ".claude") : join(process.env.HOME || "", ".claude"));
288
506
  const file = join(home, "settings.json");
289
507
  let settings = {};
290
508
  if (existsSync(file)) {
291
509
  try { settings = JSON.parse(readFileSync(file, "utf8")); }
292
510
  catch { console.error(`refusing to touch ${file}: it is not valid JSON`); process.exit(1); }
293
511
  }
294
- const CMD = "npx -y memory-pulse brief";
295
512
  settings.hooks = settings.hooks || {};
296
- const list = (settings.hooks.SessionStart = settings.hooks.SessionStart || []);
297
- const present = JSON.stringify(list).includes(CMD);
298
- if (present) { console.log("hook already installed nothing to do"); return; }
299
- list.push({ hooks: [{ type: "command", command: CMD }] });
513
+ let changed = 0;
514
+ const BRIEF = "npx -y memory-pulse brief";
515
+ const start = (settings.hooks.SessionStart = settings.hooks.SessionStart || []);
516
+ if (!JSON.stringify(start).includes(BRIEF)) { start.push({ hooks: [{ type: "command", command: BRIEF }] }); changed++; }
517
+ const GUARD = "npx -y memory-pulse guard";
518
+ const pre = (settings.hooks.PreToolUse = settings.hooks.PreToolUse || []);
519
+ if (!JSON.stringify(pre).includes(GUARD)) { pre.push({ matcher: "Edit|Write|MultiEdit", hooks: [{ type: "command", command: GUARD }] }); changed++; }
520
+ if (!changed) { console.log("hooks already installed — nothing to do"); return; }
300
521
  mkdirSync(home, { recursive: true });
301
522
  writeFileSync(file, JSON.stringify(settings, null, 2) + "\n");
302
- console.log(`installed SessionStart hook in ${file}`);
303
- console.log("Every Claude Code session now re-enters through the ledger automatically.");
304
- console.log("Remove it any time by deleting the memory-pulse entry from hooks.SessionStart.");
523
+ console.log(`installed ${changed} hook(s) in ${file}`);
524
+ console.log("SessionStart: every session re-enters through the ledger automatically.");
525
+ console.log("PreToolUse (Edit/Write): an edit that writes back a withdrawn value is blocked and explained.");
526
+ console.log(project ? "Project-scoped: commit .claude/settings.json and every clone is guarded." : "Tip: `install-hook --project` writes the hooks into this repo so teammates inherit them.");
527
+ console.log("Remove either by deleting the memory-pulse entries from hooks.");
305
528
  }
306
529
 
307
530
  // Importable for tests; the transport runs only when this file is the entry
@@ -317,9 +540,12 @@ if (isMain) {
317
540
  const sub = process.argv[2];
318
541
  if (sub === "brief") { await cliBrief(); process.exit(0); }
319
542
  if (sub === "install-hook") { cliInstallHook(); process.exit(0); }
543
+ if (sub === "guard") { await cliGuard(); process.exit(0); }
544
+ if (sub === "report") { cliReport(); process.exit(0); }
545
+ if (sub === "bench") { await cliBench(); process.exit(0); }
320
546
  if (sub === "stats") { await cliStats(); process.exit(0); }
321
547
  if (sub === "badge") { cliBadge(); process.exit(0); }
322
- if (sub && sub !== "serve") { console.error(`unknown command: ${sub} (try: brief, stats, badge, install-hook)`); process.exit(1); }
548
+ if (sub && sub !== "serve") { console.error(`unknown command: ${sub} (try: brief, guard, report, bench, stats, badge, install-hook)`); process.exit(1); }
323
549
  process.stderr.write(`memory-pulse: ledger ${ledgerPath()} — api ${API}\n`);
324
550
  const rl = readline.createInterface({ input: process.stdin, terminal: false });
325
551
  // In-flight calls are drained before exit. Exiting the moment stdin closes