memory-pulse 0.1.4 → 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.
- package/README.md +57 -16
- package/package.json +1 -1
- package/server.mjs +217 -25
package/README.md
CHANGED
|
@@ -32,36 +32,67 @@ Two design decisions do the heavy lifting:
|
|
|
32
32
|
The failure this prevents: your agent confidently quotes the benchmark number
|
|
33
33
|
you withdrew three sessions ago.
|
|
34
34
|
|
|
35
|
-
**Silence beats a wrong answer.**
|
|
35
|
+
**Silence beats a wrong answer.** When recall isn't confident enough, it returns nothing rather than guessing.
|
|
36
36
|
When the answer isn't there, you get nothing — not a plausible guess.
|
|
37
37
|
|
|
38
38
|
## Install
|
|
39
39
|
|
|
40
|
-
**
|
|
40
|
+
**Any MCP client, one line** (Claude Code shown):
|
|
41
|
+
|
|
42
|
+
```
|
|
43
|
+
claude mcp add memory-pulse -- npx -y memory-pulse
|
|
44
|
+
```
|
|
45
|
+
|
|
46
|
+
**Make re-entry automatic** — a Claude Code SessionStart hook that runs the
|
|
47
|
+
brief before your first prompt (idempotent; merges into your settings, never
|
|
48
|
+
clobbers them; silent in projects that have no ledger):
|
|
49
|
+
|
|
50
|
+
```
|
|
51
|
+
npx memory-pulse install-hook
|
|
52
|
+
```
|
|
53
|
+
|
|
54
|
+
**Claude Code plugin** (installs straight from this repo, hook included):
|
|
41
55
|
|
|
42
56
|
```
|
|
43
57
|
/plugin marketplace add t-crew/memory-pulse
|
|
44
58
|
/plugin install memory-pulse@memory-pulse
|
|
45
59
|
```
|
|
46
60
|
|
|
47
|
-
**Codex CLI / Cursor /
|
|
48
|
-
(
|
|
61
|
+
**Codex CLI / Cursor / other clients** — stdio server, command
|
|
62
|
+
`npx -y memory-pulse` (or clone this repo and run `node /path/to/server.mjs`).
|
|
63
|
+
|
|
64
|
+
Then tell your agent to remember things. Record a withdrawn number with
|
|
65
|
+
`kind: "correction"` and it will outrank the history that contained it — at
|
|
66
|
+
every brief size, in every session.
|
|
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:
|
|
49
75
|
|
|
50
76
|
```
|
|
51
|
-
|
|
77
|
+
remember({ cause: "pricing-shipped", effect: "price-corrected", kind: "correction",
|
|
78
|
+
note: "measured willingness to pay is $29", withdrawn: ["$49"], replacement: ["$29"] })
|
|
52
79
|
```
|
|
53
80
|
|
|
54
|
-
|
|
55
|
-
# Codex: ~/.codex/config.toml
|
|
56
|
-
[mcp_servers.memory-pulse]
|
|
57
|
-
command = "node"
|
|
58
|
-
args = ["/path/to/memory-pulse/server.mjs"]
|
|
59
|
-
```
|
|
81
|
+
### Commands
|
|
60
82
|
|
|
61
|
-
|
|
83
|
+
```
|
|
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
|
|
88
|
+
npx memory-pulse stats # your telemetry capsule, signature verified by the engine
|
|
89
|
+
npx memory-pulse badge # README badge markdown from your own signed numbers
|
|
90
|
+
npx memory-pulse install-hook # installs both hooks (idempotent); --project commits them to the repo
|
|
91
|
+
```
|
|
62
92
|
|
|
63
|
-
|
|
64
|
-
the
|
|
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.
|
|
65
96
|
|
|
66
97
|
## What runs where (the privacy contract)
|
|
67
98
|
|
|
@@ -72,8 +103,18 @@ the memory". It figures the rest out from the tool descriptions.
|
|
|
72
103
|
which computes the answer and forgets the request. **The service keeps no
|
|
73
104
|
database of your memory** — state arrives in the request and leaves in the
|
|
74
105
|
response.
|
|
75
|
-
-
|
|
76
|
-
|
|
106
|
+
- Telemetry is a **signed capsule beside your ledger**
|
|
107
|
+
(`.memory-pulse/telemetry.rain`): the engine advances it on each read call
|
|
108
|
+
and hands it back — it never stores it. `stats` verifies the signature;
|
|
109
|
+
`badge` turns it into a README badge. Delete the file and it restarts.
|
|
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.
|
|
77
118
|
|
|
78
119
|
## Pricing
|
|
79
120
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "memory-pulse",
|
|
3
|
-
"version": "0.1.
|
|
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://
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
102
|
-
|
|
103
|
-
|
|
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
|
-
|
|
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
|
-
"
|
|
144
|
-
"
|
|
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.
|
|
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
|
|
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(`[](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
|
-
|
|
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
|
-
|
|
297
|
-
const
|
|
298
|
-
|
|
299
|
-
|
|
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
|
|
303
|
-
console.log("
|
|
304
|
-
console.log("
|
|
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
|