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.
- package/README.md +37 -3
- package/package.json +1 -1
- 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 #
|
|
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
|
-
-
|
|
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.
|
|
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://
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
102
|
-
|
|
103
|
-
|
|
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
|
-
|
|
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).
|
|
144
|
-
"
|
|
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.
|
|
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
|
|
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(`[](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
|
-
|
|
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
|
-
|
|
297
|
-
const
|
|
298
|
-
|
|
299
|
-
|
|
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
|
|
303
|
-
console.log("
|
|
304
|
-
console.log("
|
|
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
|