memory-pulse 0.1.9 → 0.2.1

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 +7 -0
  2. package/package.json +1 -1
  3. package/server.mjs +54 -15
package/README.md CHANGED
@@ -107,6 +107,13 @@ to respect the guard.
107
107
  (`.memory-pulse/telemetry.rain`): the engine advances it on each read call
108
108
  and hands it back — it never stores it. `stats` verifies the signature;
109
109
  `badge` turns it into a README badge. Delete the file and it restarts.
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.
110
117
  - **Memory integrity.** A note that reads like an instruction ("ignore previous
111
118
  instructions", "run this command", a fake system tag) is refused by
112
119
  `remember` and, if one is already in a ledger, quarantined at read time and
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "memory-pulse",
3
- "version": "0.1.9",
3
+ "version": "0.2.1",
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
@@ -22,6 +22,7 @@
22
22
  import readline from "node:readline";
23
23
  import http from "node:http";
24
24
  import https from "node:https";
25
+ import { gzipSync } from "node:zlib";
25
26
  import { existsSync, mkdirSync, readFileSync, appendFileSync, writeFileSync, realpathSync } from "node:fs";
26
27
  import { dirname, isAbsolute, join } from "node:path";
27
28
  import { fileURLToPath } from "node:url";
@@ -51,18 +52,18 @@ function readEvents() {
51
52
  return { path, events };
52
53
  }
53
54
 
54
- // The engine's injection-through-memory list, mirrored so a refusal happens
55
+ // The engine's memory-safety checks, mirrored so a refusal happens
55
56
  // before the write. Keep in step with the engine; the engine is authoritative.
56
57
  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],
58
+ ["protect-instructions", /\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
+ ["protect-role", /\byou are now\b|\bfrom now on,? you\b|\bact as (an?|the) (system|admin|developer)\b/i],
60
+ ["protect-boundaries", /<\/?\s*(system|assistant|tool|user|human|developer)\s*>|\[(system|assistant|tool)\s*:?\s*\]/i],
61
+ ["protect-configuration", /\b(reveal|print|show|dump)\b[^.\n]{0,30}\b(system prompt|hidden prompt|your instructions)\b/i],
62
+ ["protect-execution", /\b(run|execute|paste)\b[^.\n]{0,25}\b(this|the following)\b[^.\n]{0,15}\b(command|script|shell|code)\b/i],
63
+ ["protect-installs", /\b(curl|wget)\b[^\n]{0,120}\|\s*(sudo\s+)?(sh|bash|zsh)\b/i],
64
+ ["protect-data", /\b(rm\s+-rf\s+[\/~]|drop\s+table|truncate\s+table|format\s+c:)/i],
65
+ ["protect-credentials", /\b(send|post|upload|exfiltrat\w*|leak|forward|email)\b[^.\n]{0,40}\b(api[\s_-]?keys?|secrets?|tokens?|passwords?|credentials?)\b[^.\n]{0,40}\b(to|via|at)\b[^.\n]{0,4}(https?:\/\/|[\w.-]+@[\w.-]+\b|this\b|the following\b|that (address|url|endpoint|server)\b|me\b)|\b(send|give|show|tell) me\b[^.\n]{0,30}\b(api[\s_-]?keys?|secrets?|tokens?|passwords?|credentials?)\b/i ],
66
+ ["protect-transparency", /\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
  ];
67
68
  export function instructionLike(text) {
68
69
  if (typeof text !== "string" || !text) return [];
@@ -128,10 +129,41 @@ const fmtK = (n) => (n >= 1_000_000 ? `${(n / 1_000_000).toFixed(1)}M` : n >= 10
128
129
  export function telemetryFooter(c) {
129
130
  const k = c?.counters;
130
131
  if (!k || !k.calls) return "";
131
- const drift = c.drift?.reasons?.length ? ` · drift: ${c.drift.reasons.join("; ")}` : "";
132
+ // Quarantine is printed on its own line by the brief; the footer carries
133
+ // the drift reasons that are actually about the ledger changing.
134
+ const reasons = (c.drift?.reasons ?? []).filter((r) => !/quarantined/.test(r));
135
+ const drift = reasons.length ? ` · ⚠ drift: ${reasons.join("; ")}` : "";
132
136
  return `— memory-pulse · ${k.pulse} re-entries · ${k.correctionsSurfaced} corrections surfaced · ~${fmtK(k.tokensSavedEst)} tokens saved (est., signed)${drift}`;
133
137
  }
134
138
 
139
+ // ------------------------------------------------------------ memory key ----
140
+ // State persistence without a database. After a read the engine hands back a
141
+ // signed memory key; presenting it on the next read resumes the memory and
142
+ // ingests only the events recorded since (measured: a full re-entry on an
143
+ // 825-event ledger went from 4.5 s to 1.5 s). It lives beside your ledger as
144
+ // memory.rain, it is yours, and a lost or stale key costs one rebuild — never
145
+ // data. It never enters the agent's context. MEMORY_PULSE_MEMORY_KEY=off disables it.
146
+ const memoryKeyPath = () => join(dirname(ledgerPath()), "memory.rain");
147
+ const memoryKeyOn = () => (process.env.MEMORY_PULSE_MEMORY_KEY || "on") !== "off";
148
+ function readMemoryKey() {
149
+ if (!memoryKeyOn()) return null;
150
+ try { return JSON.parse(readFileSync(memoryKeyPath(), "utf8")); } catch { return null; }
151
+ }
152
+ function writeMemoryKey(k) {
153
+ if (!memoryKeyOn() || !k || typeof k !== "object") return;
154
+ try {
155
+ const dir = dirname(memoryKeyPath());
156
+ mkdirSync(dir, { recursive: true });
157
+ writeFileSync(memoryKeyPath(), JSON.stringify(k));
158
+ // The ledger is the source of truth; the key is a rebuildable cache and
159
+ // has no business in version control.
160
+ // Only in the default layout: a custom ledger directory is the user's to
161
+ // manage, and this client does not leave files in it uninvited.
162
+ const gi = join(dir, ".gitignore");
163
+ if (dir.endsWith(".memory-pulse") && !existsSync(gi)) writeFileSync(gi, "memory.rain\n");
164
+ } catch { /* a read-only checkout must not break a read call */ }
165
+ }
166
+
135
167
  // ------------------------------------------------------------------- api ----
136
168
  // Transport: Node's own http(s) on a FRESH HTTP/1.1 connection per call.
137
169
  // The global fetch pools an HTTP/2 session that the edge retires after a
@@ -142,10 +174,14 @@ export function telemetryFooter(c) {
142
174
  function postJson(url, headers, payload) {
143
175
  const u = new URL(url);
144
176
  const mod = u.protocol === "http:" ? http : https;
177
+ // Ledgers compress 5-10x (notes are prose); anything past 4 KB goes up
178
+ // gzipped. The engine inflates it; a small body is not worth the header.
179
+ const gz = Buffer.byteLength(payload) >= 4096;
180
+ const body = gz ? gzipSync(payload) : Buffer.from(payload);
145
181
  return new Promise((resolve, reject) => {
146
182
  const req = mod.request(u, {
147
183
  method: "POST", agent: false,
148
- headers: { ...headers, "content-length": Buffer.byteLength(payload), connection: "close" },
184
+ headers: { ...headers, ...(gz ? { "content-encoding": "gzip" } : {}), "content-length": body.length, connection: "close" },
149
185
  }, (res) => {
150
186
  let data = "";
151
187
  res.setEncoding("utf8");
@@ -153,7 +189,7 @@ function postJson(url, headers, payload) {
153
189
  res.on("end", () => resolve({ ok: res.statusCode >= 200 && res.statusCode < 300, status: res.statusCode, json: async () => JSON.parse(data) }));
154
190
  });
155
191
  req.on("error", reject);
156
- req.end(payload);
192
+ req.end(body);
157
193
  });
158
194
  }
159
195
 
@@ -171,7 +207,9 @@ async function postJsonRetry(url, headers, payload) {
171
207
 
172
208
  async function callApi(route, body) {
173
209
  const prior = readTelemetry();
174
- body = { ...body, project: projectName(), ...(prior ? { telemetry: prior } : {}) };
210
+ const mk = readMemoryKey();
211
+ const wantKey = !mk && memoryKeyOn() && Array.isArray(body.events) && body.events.length >= 500;
212
+ body = { ...body, project: projectName(), ...(prior ? { telemetry: prior } : {}), ...(mk ? { key: mk } : wantKey ? { wantKey: true } : {}) };
175
213
  let res;
176
214
  try {
177
215
  res = await postJsonRetry(`${API}${route}`, { "content-type": "application/json", ...(KEY ? { "x-mp-key": KEY } : {}) }, JSON.stringify(body));
@@ -184,6 +222,7 @@ async function callApi(route, body) {
184
222
  }
185
223
  const out = await res.json().catch(() => ({}));
186
224
  if (res.ok && out.telemetry) { writeTelemetry(out.telemetry); }
225
+ if (res.ok && out.key) { writeMemoryKey(out.key); delete out.key; }
187
226
  if (!res.ok) {
188
227
  let msg = out.error ?? `API error ${res.status}`;
189
228
  if (out.upgrade) msg += ` — upgrade: ${out.upgrade}`;
@@ -291,7 +330,7 @@ async function dispatch(msg) {
291
330
  return ok(id, {
292
331
  protocolVersion: SUPPORTED.includes(wanted) ? wanted : SUPPORTED[0],
293
332
  capabilities: { tools: {} },
294
- serverInfo: { name: "memory-pulse", version: "0.1.9" },
333
+ serverInfo: { name: "memory-pulse", version: "0.2.1" },
295
334
  });
296
335
  }
297
336
  if (method === "notifications/initialized" || method === "initialized") return;