memory-pulse 0.1.5 → 0.1.9

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 +30 -3
  2. package/package.json +1 -1
  3. package/server.mjs +217 -25
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,14 @@ 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
+ - **Memory integrity.** A note that reads like an instruction ("ignore previous
111
+ instructions", "run this command", a fake system tag) is refused by
112
+ `remember` and, if one is already in a ledger, quarantined at read time and
113
+ reported — memory is never rendered into your agent's context as an
114
+ instruction. The signed capsule also raises a **drift alert** when a ledger
115
+ loses corrections, shrinks, or its usage shape jumps; the brief footer
116
+ shows it. Both checks are deterministic lists you can read, not a model.
117
+ - This client is the entire client: one file, zero dependencies, readable in one sitting.
91
118
 
92
119
  ## Pricing
93
120
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "memory-pulse",
3
- "version": "0.1.5",
3
+ "version": "0.1.9",
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,13 @@
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";
23
25
  import { existsSync, mkdirSync, readFileSync, appendFileSync, writeFileSync, realpathSync } from "node:fs";
24
26
  import { dirname, isAbsolute, join } from "node:path";
25
27
  import { fileURLToPath } from "node:url";
26
28
 
27
- const API = (process.env.MEMORY_PULSE_API ?? "https://memory-pulse.strategic-innovations.workers.dev").replace(/\/$/, "");
29
+ const API = (process.env.MEMORY_PULSE_API ?? "https://pulse.strategic-innovations.ai").replace(/\/$/, "");
28
30
  const KEY = process.env.MEMORY_PULSE_KEY ?? null;
29
31
 
30
32
  // ---------------------------------------------------------------- ledger ----
@@ -49,7 +51,31 @@ function readEvents() {
49
51
  return { path, events };
50
52
  }
51
53
 
52
- function appendEvent({ cause, effect, note, kind, tags, pinned }) {
54
+ // The engine's injection-through-memory list, mirrored so a refusal happens
55
+ // before the write. Keep in step with the engine; the engine is authoritative.
56
+ const INSTRUCTION_PATTERNS = [
57
+ ["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],
58
+ ["persona", /\byou are now\b|\bfrom now on,? you\b|\bact as (an?|the) (system|admin|developer)\b/i],
59
+ ["fake-role-tag", /<\/?\s*(system|assistant|tool|user|human|developer)\s*>|\[(system|assistant|tool)\s*:?\s*\]/i],
60
+ ["system-prompt", /\b(reveal|print|show|dump)\b[^.\n]{0,30}\b(system prompt|hidden prompt|your instructions)\b/i],
61
+ ["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],
62
+ ["shell-pipe", /\b(curl|wget)\b[^\n]{0,120}\|\s*(sudo\s+)?(sh|bash|zsh)\b/i],
63
+ ["destructive", /\b(rm\s+-rf\s+[\/~]|drop\s+table|truncate\s+table|format\s+c:)/i],
64
+ ["secret-exfil", /\b(send|post|upload|exfiltrat\w*|leak)\b[^.\n]{0,40}\b(api[\s_-]?keys?|secrets?|tokens?|passwords?|credentials?)\b/i],
65
+ ["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],
66
+ ];
67
+ export function instructionLike(text) {
68
+ if (typeof text !== "string" || !text) return [];
69
+ return INSTRUCTION_PATTERNS.filter(([, re]) => re.test(text)).map(([id]) => id);
70
+ }
71
+
72
+ function appendEvent({ cause, effect, note, kind, tags, pinned, withdrawn, replacement }) {
73
+ // Instruction-like notes are refused at the write. The engine quarantines
74
+ // them at read time too (and reports it), but a note that would be rendered
75
+ // into every future session as an instruction should never reach the ledger.
76
+ // Same deterministic list as the engine; no model in the loop.
77
+ const hits = instructionLike(note);
78
+ 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
79
  const { path, events } = readEvents();
54
80
  const dup = events.find((e) => e.cause === cause && e.effect === effect && (e.note ?? "") === (note ?? ""));
55
81
  if (dup) return { written: false, reason: "duplicate", t: dup.t, ledger: path };
@@ -62,6 +88,19 @@ function appendEvent({ cause, effect, note, kind, tags, pinned }) {
62
88
  if (note) event.note = note;
63
89
  if (Array.isArray(tags) && tags.length) event.tags = tags;
64
90
  if (pinned) event.pinned = true;
91
+ // Withdrawn terms are what the guard enforces: exact strings that must not
92
+ // be written again. Only explicit terms count — the guard never guesses.
93
+ if (Array.isArray(withdrawn)) {
94
+ const terms = withdrawn.map((w) => String(w).trim()).filter((w) => w.length >= 2);
95
+ if (terms.length) event.withdrawn = terms;
96
+ }
97
+ // Replacement terms let the guard tell a REINTRODUCTION ("price is $49")
98
+ // apart from a DISAVOWAL or comparison ("was $49, now $29"): an edit that
99
+ // carries a replacement alongside the withdrawn term is allowed.
100
+ if (Array.isArray(replacement)) {
101
+ const terms = replacement.map((w) => String(w).trim()).filter((w) => w.length >= 1);
102
+ if (terms.length) event.replacement = terms;
103
+ }
65
104
  appendFileSync(path, JSON.stringify(event) + "\n");
66
105
  // Echo the canonical stored event back. A shell-quoting accident once ate a
67
106
  // word from a note SILENTLY; the caller must be able to see what the ledger
@@ -86,26 +125,60 @@ function writeTelemetry(capsule) {
86
125
  }
87
126
  const projectName = () => process.env.MEMORY_PULSE_PROJECT || process.cwd().split(/[\\/]/).filter(Boolean).pop() || "project";
88
127
  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) {
128
+ export function telemetryFooter(c) {
90
129
  const k = c?.counters;
91
130
  if (!k || !k.calls) return "";
92
- return `— memory-pulse · ${k.pulse} re-entries · ${k.correctionsSurfaced} corrections surfaced · ~${fmtK(k.tokensSavedEst)} tokens saved (est., signed)`;
131
+ const drift = c.drift?.reasons?.length ? ` · ⚠ drift: ${c.drift.reasons.join("; ")}` : "";
132
+ return `— memory-pulse · ${k.pulse} re-entries · ${k.correctionsSurfaced} corrections surfaced · ~${fmtK(k.tokensSavedEst)} tokens saved (est., signed)${drift}`;
93
133
  }
94
134
 
95
135
  // ------------------------------------------------------------------- api ----
136
+ // Transport: Node's own http(s) on a FRESH HTTP/1.1 connection per call.
137
+ // The global fetch pools an HTTP/2 session that the edge retires after a
138
+ // few large requests, and undici then throws ERR_HTTP2_INVALID_SESSION on
139
+ // reuse instead of reconnecting — `bench` on an 822-event ledger lost 11 of
140
+ // 12 sequential calls to it, and a retry reused the same dead session. No
141
+ // pooling, no session, no dependency.
142
+ function postJson(url, headers, payload) {
143
+ const u = new URL(url);
144
+ const mod = u.protocol === "http:" ? http : https;
145
+ return new Promise((resolve, reject) => {
146
+ const req = mod.request(u, {
147
+ method: "POST", agent: false,
148
+ headers: { ...headers, "content-length": Buffer.byteLength(payload), connection: "close" },
149
+ }, (res) => {
150
+ let data = "";
151
+ res.setEncoding("utf8");
152
+ res.on("data", (c) => { data += c; });
153
+ res.on("end", () => resolve({ ok: res.statusCode >= 200 && res.statusCode < 300, status: res.statusCode, json: async () => JSON.parse(data) }));
154
+ });
155
+ req.on("error", reject);
156
+ req.end(payload);
157
+ });
158
+ }
159
+
160
+ // Transient transport failures on a fresh connection (a TLS record hiccup, a
161
+ // reset) get exactly one retry — a new connection each time, so the retry
162
+ // means something. Anything else surfaces immediately with its real cause.
163
+ const TRANSIENT = /ERR_SSL|ECONNRESET|EPIPE|ETIMEDOUT|ECONNREFUSED|EAI_AGAIN/;
164
+ async function postJsonRetry(url, headers, payload) {
165
+ try { return await postJson(url, headers, payload); }
166
+ catch (first) {
167
+ if (!TRANSIENT.test(String(first?.code || first?.message || ""))) throw first;
168
+ return postJson(url, headers, payload);
169
+ }
170
+ }
171
+
96
172
  async function callApi(route, body) {
97
173
  const prior = readTelemetry();
98
174
  body = { ...body, project: projectName(), ...(prior ? { telemetry: prior } : {}) };
99
175
  let res;
100
176
  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 {
177
+ res = await postJsonRetry(`${API}${route}`, { "content-type": "application/json", ...(KEY ? { "x-mp-key": KEY } : {}) }, JSON.stringify(body));
178
+ } catch (err) {
179
+ const why = err?.cause?.code || err?.code || err?.message || String(err);
107
180
  throw new Error(
108
- "memory-pulse API unreachable. `remember` still works (it writes locally); " +
181
+ `memory-pulse API unreachable (${why}). \`remember\` still works (it writes locally); ` +
109
182
  "pulse/recall/execute need the network. Check connectivity or MEMORY_PULSE_API.",
110
183
  );
111
184
  }
@@ -140,8 +213,8 @@ export const TOOLS = [
140
213
  name: "recall",
141
214
  description:
142
215
  "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.",
216
+ "chain (pulse), or when a link was strongest (when). Hits carry a confidence; `exact` lists the " +
217
+ "recorded links verbatim, so a weak read never hides a correction. Returns nothing rather than guessing.",
145
218
  inputSchema: {
146
219
  type: "object",
147
220
  properties: {
@@ -166,6 +239,8 @@ export const TOOLS = [
166
239
  effect: { type: "string" },
167
240
  note: { type: "string", description: "What was measured, and how." },
168
241
  kind: { type: "string", enum: ["event", "correction"], description: "Default event." },
242
+ 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." },
243
+ 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
244
  tags: { type: "array", items: { type: "string" } },
170
245
  pinned: { type: "boolean", description: "Never decays out of the brief." },
171
246
  },
@@ -216,7 +291,7 @@ async function dispatch(msg) {
216
291
  return ok(id, {
217
292
  protocolVersion: SUPPORTED.includes(wanted) ? wanted : SUPPORTED[0],
218
293
  capabilities: { tools: {} },
219
- serverInfo: { name: "memory-pulse", version: "0.1.5" },
294
+ serverInfo: { name: "memory-pulse", version: "0.1.9" },
220
295
  });
221
296
  }
222
297
  if (method === "notifications/initialized" || method === "initialized") return;
@@ -244,6 +319,9 @@ async function cliBrief() {
244
319
  try {
245
320
  const out = await callApi("/v1/pulse", { events, tier: process.env.MEMORY_PULSE_BRIEF_TIER || "brief" });
246
321
  if (out.text) process.stdout.write(out.text + "\n");
322
+ if (Array.isArray(out.quarantined) && out.quarantined.length) {
323
+ process.stdout.write(`⚠ ${out.quarantined.length} note(s) quarantined — instruction-like content was not rendered (t=${out.quarantined.map((q) => q.t).join(", ")})\n`);
324
+ }
247
325
  const foot = telemetryFooter(out.telemetry);
248
326
  if (foot) process.stdout.write(foot + "\n");
249
327
  } catch (e) {
@@ -267,7 +345,7 @@ async function cliStats() {
267
345
  console.log(` tokens saved (est.) ~${k.tokensSavedEst.toLocaleString()}`);
268
346
  if (c.reset) console.log(` note: ${c.reset}`);
269
347
  try {
270
- const res = await fetch(`${API}/v1/verify-telemetry`, { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ telemetry: c }) });
348
+ const res = await postJson(`${API}/v1/verify-telemetry`, { "content-type": "application/json" }, JSON.stringify({ telemetry: c }));
271
349
  const v = await res.json();
272
350
  console.log(v.valid ? " signature ✓ verified by the engine (keyless check anyone can repeat)" : ` signature ✗ ${v.reason ?? "invalid"}`);
273
351
  } catch { console.log(" signature ? engine unreachable"); }
@@ -281,27 +359,138 @@ function cliBadge() {
281
359
  console.log(`[![memory-pulse](https://img.shields.io/badge/memory--pulse-${label}-6366f1)](https://pulse.strategic-innovations.ai)`);
282
360
  }
283
361
 
362
+ // ---------------------------------------------------------------- guard ----
363
+ // `npx memory-pulse guard` — a Claude Code PreToolUse hook for Edit/Write.
364
+ // Surfacing a correction is not enough (agents re-violate corrections they
365
+ // were just shown); this ENFORCES it: an edit that writes back a withdrawn
366
+ // term is blocked (exit 2) and the agent is told which ledger line retired
367
+ // it and when. Deterministic, offline, only explicit `withdrawn` terms count.
368
+ const violationsPath = () => join(dirname(ledgerPath()), "violations.jsonl");
369
+ function textOfToolInput(input) {
370
+ if (!input || typeof input !== "object") return "";
371
+ const parts = [];
372
+ if (typeof input.new_string === "string") parts.push(input.new_string);
373
+ if (typeof input.content === "string") parts.push(input.content);
374
+ if (Array.isArray(input.edits)) for (const e of input.edits) if (typeof e?.new_string === "string") parts.push(e.new_string);
375
+ return parts.join("\n");
376
+ }
377
+ export function findViolations(events, text, filePath = "") {
378
+ const hits = [];
379
+ if (!text) return hits;
380
+ // The ledger and its sidecars are where corrections are RECORDED; guarding
381
+ // them would block the act of correcting.
382
+ if (/(^|[\\/])\.memory-pulse([\\/]|$)/.test(filePath)) return hits;
383
+ for (const e of events) {
384
+ if (e.kind !== "correction" || !Array.isArray(e.withdrawn)) continue;
385
+ // A comparison or disavowal names the old value next to the new one;
386
+ // only a bare reintroduction is blocked. (Adversarial review, 2026-08-31:
387
+ // a guard that fires on "the $49 figure is withdrawn" livelocks the agent.)
388
+ const disavowed = Array.isArray(e.replacement) && e.replacement.some((r) => r && text.includes(r));
389
+ if (disavowed) continue;
390
+ 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 ?? [] });
391
+ }
392
+ return hits;
393
+ }
394
+ async function cliGuard() {
395
+ let raw = "";
396
+ for await (const chunk of process.stdin) raw += chunk;
397
+ let payload; try { payload = JSON.parse(raw); } catch { return; } // not a hook call: allow
398
+ const text = textOfToolInput(payload.tool_input);
399
+ const { events } = readEvents();
400
+ const file = payload.tool_input?.file_path ?? "";
401
+ const hits = findViolations(events, text, file);
402
+ if (!hits.length) return;
403
+ 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 */ }
404
+ 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(" / ")}` : ""}`);
405
+ 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`);
406
+ process.exit(2);
407
+ }
408
+
409
+ // `npx memory-pulse report` — correction re-violation scoreboard, computed
410
+ // on your machine from files you own. Nothing is sent anywhere.
411
+ function cliReport() {
412
+ const { events } = readEvents();
413
+ const corrections = events.filter((e) => e.kind === "correction");
414
+ let blocks = [];
415
+ try { blocks = readFileSync(violationsPath(), "utf8").split("\n").filter(Boolean).map((l) => JSON.parse(l)); } catch { /* none yet */ }
416
+ const byT = new Map();
417
+ for (const b of blocks) for (const h of b.hits) byT.set(h.t, (byT.get(h.t) ?? 0) + 1);
418
+ console.log(`memory-pulse report — ${corrections.length} corrections recorded, ${corrections.filter((c) => c.withdrawn?.length).length} with enforceable withdrawn terms`);
419
+ 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)})` : ""}`);
420
+ const top = [...byT.entries()].sort((a, b) => b[1] - a[1]).slice(0, 5);
421
+ 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(", ")}`); }
422
+ const unenforced = corrections.filter((c) => !c.withdrawn?.length);
423
+ if (unenforced.length) console.log(` ${unenforced.length} correction(s) have no withdrawn terms and cannot be enforced — add them with remember(withdrawn: [...])`);
424
+ }
425
+
426
+ // `npx memory-pulse bench` — instant measured metrics on YOUR ledger: how much
427
+ // re-entry saves, whether every correction surfaces first, whether the guard
428
+ // would block each withdrawn term, and recall self-consistency on your own
429
+ // causal links. Numbers, not adjectives.
430
+ async function cliBench() {
431
+ const { events } = readEvents();
432
+ if (!events.length) { console.log("no ledger — nothing to measure yet"); return; }
433
+ const corrections = events.filter((e) => e.kind === "correction");
434
+ const dump = JSON.stringify(events).length;
435
+ const pulse = await callApi("/v1/pulse", { events, tier: "brief" }).catch((e) => ({ error: e.message }));
436
+ console.log(`memory-pulse bench — ${events.length} events, ${corrections.length} corrections`);
437
+ if (pulse.error) { console.log(` brief: unavailable (${pulse.error})`); }
438
+ else {
439
+ const lines = pulse.text.split("\n");
440
+ const first = lines.findIndex((l) => l.startsWith("CORRECTIONS"));
441
+ const headerN = Number((lines[first] ?? "").match(/CORRECTIONS \((\d+)\)/)?.[1] ?? 0);
442
+ const listed = corrections.filter((c) => pulse.text.includes(`${c.cause} -> ${c.effect}`)).length;
443
+ console.log(` re-entry brief: ${pulse.chars.toLocaleString()} chars vs ${dump.toLocaleString()} char dump (${pulse.savedVsFullDump ?? "no saving on a ledger this small"})`);
444
+ 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)` : ""}`);
445
+ }
446
+ const enforceable = corrections.filter((c) => c.withdrawn?.length);
447
+ const guardHits = enforceable.filter((c) => findViolations(events, c.withdrawn.join(" ")).length > 0).length;
448
+ 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)` : ""}`);
449
+ const sample = events.filter((e) => e.kind !== "correction").slice(-12);
450
+ let ok = 0, tried = 0, errors = 0, lastErr = "";
451
+ for (const e of sample) {
452
+ tried++;
453
+ // An API error is NOT a miss. Counting failures as misses would let a
454
+ // broken network read as a broken memory — report them separately.
455
+ let r;
456
+ try { r = await callApi("/v1/recall", { events, op: "effects", subject: e.cause, topk: 3 }); }
457
+ catch (err) { errors++; lastErr = String(err?.message ?? err); continue; }
458
+ if (r?.result?.hits?.some((h) => h.entity === e.effect)) ok++;
459
+ }
460
+ 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)}` : ""}`);
461
+ console.log(` telemetry: ${readTelemetry()?.counters ? "signed capsule present — run `stats`" : "none yet"}`);
462
+ }
463
+
284
464
  // `npx memory-pulse install-hook` — make re-entry automatic: a Claude Code
285
465
  // SessionStart hook that runs the brief. Idempotent; merges, never clobbers.
286
466
  function cliInstallHook() {
287
- const home = process.env.MEMORY_PULSE_SETTINGS_DIR || join(process.env.HOME || "", ".claude");
467
+ // --project writes to <repo>/.claude/settings.json so the hooks TRAVEL WITH
468
+ // THE REPO: a teammate who clones is guarded without installing anything.
469
+ // (Adversarial review, 2026-08-31: a user-scope hook does not spread.)
470
+ const project = process.argv.includes("--project");
471
+ const home = process.env.MEMORY_PULSE_SETTINGS_DIR || (project ? join(process.cwd(), ".claude") : join(process.env.HOME || "", ".claude"));
288
472
  const file = join(home, "settings.json");
289
473
  let settings = {};
290
474
  if (existsSync(file)) {
291
475
  try { settings = JSON.parse(readFileSync(file, "utf8")); }
292
476
  catch { console.error(`refusing to touch ${file}: it is not valid JSON`); process.exit(1); }
293
477
  }
294
- const CMD = "npx -y memory-pulse brief";
295
478
  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 }] });
479
+ let changed = 0;
480
+ const BRIEF = "npx -y memory-pulse brief";
481
+ const start = (settings.hooks.SessionStart = settings.hooks.SessionStart || []);
482
+ if (!JSON.stringify(start).includes(BRIEF)) { start.push({ hooks: [{ type: "command", command: BRIEF }] }); changed++; }
483
+ const GUARD = "npx -y memory-pulse guard";
484
+ const pre = (settings.hooks.PreToolUse = settings.hooks.PreToolUse || []);
485
+ if (!JSON.stringify(pre).includes(GUARD)) { pre.push({ matcher: "Edit|Write|MultiEdit", hooks: [{ type: "command", command: GUARD }] }); changed++; }
486
+ if (!changed) { console.log("hooks already installed — nothing to do"); return; }
300
487
  mkdirSync(home, { recursive: true });
301
488
  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.");
489
+ console.log(`installed ${changed} hook(s) in ${file}`);
490
+ console.log("SessionStart: every session re-enters through the ledger automatically.");
491
+ console.log("PreToolUse (Edit/Write): an edit that writes back a withdrawn value is blocked and explained.");
492
+ 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.");
493
+ console.log("Remove either by deleting the memory-pulse entries from hooks.");
305
494
  }
306
495
 
307
496
  // Importable for tests; the transport runs only when this file is the entry
@@ -317,9 +506,12 @@ if (isMain) {
317
506
  const sub = process.argv[2];
318
507
  if (sub === "brief") { await cliBrief(); process.exit(0); }
319
508
  if (sub === "install-hook") { cliInstallHook(); process.exit(0); }
509
+ if (sub === "guard") { await cliGuard(); process.exit(0); }
510
+ if (sub === "report") { cliReport(); process.exit(0); }
511
+ if (sub === "bench") { await cliBench(); process.exit(0); }
320
512
  if (sub === "stats") { await cliStats(); process.exit(0); }
321
513
  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); }
514
+ if (sub && sub !== "serve") { console.error(`unknown command: ${sub} (try: brief, guard, report, bench, stats, badge, install-hook)`); process.exit(1); }
323
515
  process.stderr.write(`memory-pulse: ledger ${ledgerPath()} — api ${API}\n`);
324
516
  const rl = readline.createInterface({ input: process.stdin, terminal: false });
325
517
  // In-flight calls are drained before exit. Exiting the moment stdin closes