memory-pulse 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (4) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +113 -0
  3. package/package.json +14 -0
  4. package/server.mjs +231 -0
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Travis Crew
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,113 @@
1
+ # memory-pulse
2
+
3
+ Causal project memory for coding agents — built for the thing MCP memory
4
+ servers usually get wrong: **the cost of having it installed.**
5
+
6
+ All four tools together cost **~2.5 KB (≈670 tokens) of definitions**
7
+ (cert_c71bba29493a). A test in this repo fails if they ever exceed 4 KB. Compare that to what a typical
8
+ MCP setup already burns before you type your first prompt — independent
9
+ measurements put 5–10 installed servers at [50–67k tokens of tool definitions](https://getunblocked.com/blog/mcp-token-budget-autopsy/),
10
+ a third of a 200k context window.
11
+
12
+ ## What it does
13
+
14
+ Your agent's session ends and everything it learned dies with it. memory-pulse
15
+ gives it a ledger of **cause → effect** events in a local file, and four tools:
16
+
17
+ | tool | what it does | runs |
18
+ |---|---|---|
19
+ | `remember` | record a finding (or a **correction**) | locally, offline |
20
+ | `pulse` | re-enter the project: a ranked brief instead of re-reading history | hosted engine |
21
+ | `recall` | what caused X? what did X cause? when was the link strongest? | hosted engine |
22
+ | `execute` | run JS against memory in a sandbox; only the return value enters context | hosted engine |
23
+
24
+ Two design decisions do the heavy lifting:
25
+
26
+ **Corrections come first, always.** An event recorded with
27
+ `kind: "correction"` outranks everything at every brief size and never decays.
28
+ The failure this prevents: your agent confidently quotes the benchmark number
29
+ you withdrew three sessions ago.
30
+
31
+ **Silence beats a wrong answer.** Recall is gated by a measured noise floor.
32
+ When the answer isn't there, you get nothing — not a plausible guess.
33
+
34
+ ## Install
35
+
36
+ **Claude Code** (plugin — no npm needed, installs straight from this repo):
37
+
38
+ ```
39
+ /plugin marketplace add t-crew/memory-pulse
40
+ /plugin install memory-pulse@memory-pulse
41
+ ```
42
+
43
+ **Codex CLI / Cursor / any MCP client** — clone this repo and point at it
44
+ (zero dependencies, nothing to build):
45
+
46
+ ```
47
+ git clone https://github.com/t-crew/memory-pulse
48
+ ```
49
+
50
+ ```toml
51
+ # Codex: ~/.codex/config.toml
52
+ [mcp_servers.memory-pulse]
53
+ command = "node"
54
+ args = ["/path/to/memory-pulse/server.mjs"]
55
+ ```
56
+
57
+ For other clients: stdio server, command `node /path/to/memory-pulse/server.mjs`.
58
+
59
+ Then just tell your agent to remember things, and start sessions with "pulse
60
+ the memory". It figures the rest out from the tool descriptions.
61
+
62
+ ## What runs where (the privacy contract)
63
+
64
+ - Your ledger is a **local file**: `.memory-pulse/events.jsonl` in your
65
+ project. Commit it, grep it, delete it — it's yours.
66
+ - `remember` writes to it directly and **works offline**.
67
+ - Read operations send the ledger's events to the hosted engine over TLS,
68
+ which computes the answer and forgets the request. **The service keeps no
69
+ database of your memory** — state arrives in the request and leaves in the
70
+ response.
71
+ - This client is the entire client: ~300 lines, zero dependencies, read it in
72
+ one sitting.
73
+
74
+ ## Pricing
75
+
76
+ - **Free** — ledgers up to 500 events, 200 reads/day. No account, no key.
77
+ - **Pro ($19/mo)** — ledgers to 20,000 events, unlimited reads. One env var:
78
+ `MEMORY_PULSE_KEY`.
79
+
80
+ Local writes are free forever either way.
81
+
82
+ ## Measured, on our own ledger
83
+
84
+ We run memory-pulse on the 767-event, 1.08 MB ledger of the project that
85
+ builds it. On that corpus (pinned run cert_c71bba29493a):
86
+
87
+ - A cross-referencing question answered through `execute` returned **124
88
+ chars** against the 1,080,983-char full dump — the intermediates never
89
+ entered context.
90
+ - Re-entry briefs at the smallest tier run **~99% smaller** than reading the
91
+ ledger in.
92
+ - On our recall benchmark (351 distinct causes), the noise-floor gate returned
93
+ **zero wrong top answers** — when it couldn't clear the floor, it returned
94
+ nothing instead.
95
+
96
+ Your ratios scale with ledger size — a ledger ten events old has nothing to
97
+ compress. The methodology lives in the engine's benchmark suite and the
98
+ numbers above are from pinned run cert_c71bba29493a, not a projection.
99
+
100
+ ## FAQ
101
+
102
+ **Why is the engine hosted?** The ranking engine is the part that took the
103
+ research. The tradeoff we chose: local ledger + thin auditable client +
104
+ hosted engine, over shipping a weaker local ranker. If the engine being remote
105
+ is a dealbreaker, `MEMORY_PULSE_API` points the client anywhere.
106
+
107
+ **What about team memory?** Commit `.memory-pulse/` to the repo. Your
108
+ teammates' agents pulse the same ledger. (Shared hosted ledgers are on the
109
+ roadmap.)
110
+
111
+ **License?** Client: MIT. Engine: proprietary, hosted.
112
+
113
+ MIT © Travis Crew
package/package.json ADDED
@@ -0,0 +1,14 @@
1
+ {
2
+ "name": "memory-pulse",
3
+ "version": "0.1.0",
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
+ "type": "module",
6
+ "bin": { "memory-pulse": "./server.mjs" },
7
+ "files": ["server.mjs", "README.md", "LICENSE"],
8
+ "scripts": { "test": "node --test test/*.test.js" },
9
+ "keywords": ["mcp", "modelcontextprotocol", "memory", "claude-code", "codex", "agent-memory", "context", "token-savings"],
10
+ "author": "Travis Crew",
11
+ "license": "MIT",
12
+ "repository": { "type": "git", "url": "git+https://github.com/t-crew/memory-pulse.git" },
13
+ "engines": { "node": ">=18" }
14
+ }
package/server.mjs ADDED
@@ -0,0 +1,231 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * memory-pulse — MCP server (stdio) for Claude Code, Codex, Cursor, and any
4
+ * MCP client.
5
+ *
6
+ * What runs where, stated plainly because it is the privacy contract:
7
+ *
8
+ * - Your ledger is a local file: .memory-pulse/events.jsonl in your project.
9
+ * `remember` writes to it directly and works offline.
10
+ * - Read operations (pulse / recall / execute) send the ledger's events to
11
+ * the hosted engine over TLS, which computes the answer and forgets the
12
+ * request. The service keeps NO database of your memory — state arrives
13
+ * in the request and leaves in the response.
14
+ * - This client is the entire client. No SDK, no dependencies, ~300 lines
15
+ * you can read in one sitting.
16
+ *
17
+ * Config (env):
18
+ * MEMORY_PULSE_KEY license key (optional — free tier without one)
19
+ * MEMORY_PULSE_API override the API base (default: hosted service)
20
+ * MEMORY_PULSE_LEDGER override the ledger path (default: ./.memory-pulse/events.jsonl)
21
+ */
22
+ import readline from "node:readline";
23
+ import { existsSync, mkdirSync, readFileSync, appendFileSync, writeFileSync } from "node:fs";
24
+ import { dirname, isAbsolute, join } from "node:path";
25
+
26
+ const API = (process.env.MEMORY_PULSE_API ?? "https://memory-pulse.strategic-innovations.workers.dev").replace(/\/$/, "");
27
+ const KEY = process.env.MEMORY_PULSE_KEY ?? null;
28
+
29
+ // ---------------------------------------------------------------- ledger ----
30
+ function ledgerPath() {
31
+ const env = process.env.MEMORY_PULSE_LEDGER;
32
+ if (env) return isAbsolute(env) ? env : join(process.cwd(), env);
33
+ return join(process.cwd(), ".memory-pulse", "events.jsonl");
34
+ }
35
+
36
+ function readEvents() {
37
+ const path = ledgerPath();
38
+ if (!existsSync(path)) return { path, events: [] };
39
+ const events = [];
40
+ for (const line of readFileSync(path, "utf8").split("\n")) {
41
+ const s = line.trim();
42
+ if (!s) continue;
43
+ try {
44
+ const e = JSON.parse(s);
45
+ if (typeof e.cause === "string" && typeof e.effect === "string") events.push(e);
46
+ } catch { /* a torn line does not take the ledger down */ }
47
+ }
48
+ return { path, events };
49
+ }
50
+
51
+ function appendEvent({ cause, effect, note, kind, tags, pinned }) {
52
+ const { path, events } = readEvents();
53
+ const dup = events.find((e) => e.cause === cause && e.effect === effect && (e.note ?? "") === (note ?? ""));
54
+ if (dup) return { written: false, reason: "duplicate", t: dup.t, ledger: path };
55
+ if (!existsSync(path)) {
56
+ mkdirSync(dirname(path), { recursive: true });
57
+ writeFileSync(path, "");
58
+ }
59
+ const t = events.reduce((m, e) => Math.max(m, e.t ?? 0), 0) + 1;
60
+ const event = { t, cause, effect, kind: kind || "event" };
61
+ if (note) event.note = note;
62
+ if (Array.isArray(tags) && tags.length) event.tags = tags;
63
+ if (pinned) event.pinned = true;
64
+ appendFileSync(path, JSON.stringify(event) + "\n");
65
+ return { written: true, t, ledger: path };
66
+ }
67
+
68
+ // ------------------------------------------------------------------- api ----
69
+ async function callApi(route, body) {
70
+ let res;
71
+ try {
72
+ res = await fetch(`${API}${route}`, {
73
+ method: "POST",
74
+ headers: { "content-type": "application/json", ...(KEY ? { "x-mp-key": KEY } : {}) },
75
+ body: JSON.stringify(body),
76
+ });
77
+ } catch {
78
+ throw new Error(
79
+ "memory-pulse API unreachable. `remember` still works (it writes locally); " +
80
+ "pulse/recall/execute need the network. Check connectivity or MEMORY_PULSE_API.",
81
+ );
82
+ }
83
+ const out = await res.json().catch(() => ({}));
84
+ if (!res.ok) {
85
+ let msg = out.error ?? `API error ${res.status}`;
86
+ if (out.upgrade) msg += ` — upgrade: ${out.upgrade}`;
87
+ throw new Error(msg);
88
+ }
89
+ return out;
90
+ }
91
+
92
+ // ----------------------------------------------------------------- tools ----
93
+ const TIERS = ["index", "brief", "notes", "full"];
94
+ export const TOOLS = [
95
+ {
96
+ name: "pulse",
97
+ description:
98
+ "Re-enter a project without reading its history into context. Returns a salience-ranked brief " +
99
+ "that ALWAYS carries every recorded correction first, so a superseded number cannot be quoted by " +
100
+ "accident. Call this before answering questions about prior work.",
101
+ inputSchema: {
102
+ type: "object",
103
+ properties: {
104
+ tier: { type: "string", enum: TIERS, description: "index (smallest) → full. Default brief." },
105
+ root: { type: "string", description: "Entity to centre the causal front on." },
106
+ },
107
+ },
108
+ },
109
+ {
110
+ name: "recall",
111
+ description:
112
+ "Query the causal graph: what an event caused (effects), what caused it (causes), a multi-hop " +
113
+ "wavefront (pulse), or when an edge was strongest (when). Returns nothing rather than guessing " +
114
+ "when the answer is below the noise floor.",
115
+ inputSchema: {
116
+ type: "object",
117
+ properties: {
118
+ op: { type: "string", enum: ["effects", "causes", "pulse", "when"] },
119
+ subject: { type: "string", description: "Entity to query." },
120
+ object: { type: "string", description: "Second entity — required by 'when', which scores an edge." },
121
+ topk: { type: "number", description: "Max hits. Default 5." },
122
+ },
123
+ required: ["op", "subject"],
124
+ },
125
+ },
126
+ {
127
+ name: "remember",
128
+ description:
129
+ "Record a finding so the next session starts with it. Use kind='correction' when a claim is " +
130
+ "withdrawn or a number is superseded — corrections are surfaced first at every tier and never decay. " +
131
+ "Cheap, idempotent, and fully local (works offline).",
132
+ inputSchema: {
133
+ type: "object",
134
+ properties: {
135
+ cause: { type: "string" },
136
+ effect: { type: "string" },
137
+ note: { type: "string", description: "What was measured, and how." },
138
+ kind: { type: "string", enum: ["event", "correction"], description: "Default event." },
139
+ tags: { type: "array", items: { type: "string" } },
140
+ pinned: { type: "boolean", description: "Never decays out of the brief." },
141
+ },
142
+ required: ["cause", "effect"],
143
+ },
144
+ },
145
+ {
146
+ name: "execute",
147
+ description:
148
+ "Run a JavaScript program against memory in a sandbox; ONLY its return value enters context. " +
149
+ "Use for questions needing many lookups — filtering, counting, cross-referencing — where the " +
150
+ "intermediates would otherwise cost more than the answer. `ctx.memory.effects|causes|pulse|when` " +
151
+ "are available and async. Example: return (await ctx.memory.effects('x')).hits.length",
152
+ inputSchema: {
153
+ type: "object",
154
+ properties: { program: { type: "string", description: "Body of an async function; must return." } },
155
+ required: ["program"],
156
+ },
157
+ },
158
+ ];
159
+
160
+ export async function handleCall(name, args = {}) {
161
+ if (name === "remember") {
162
+ if (!args.cause || !args.effect) throw new Error("remember needs both cause and effect");
163
+ return appendEvent(args);
164
+ }
165
+
166
+ const { path, events } = readEvents();
167
+ if (!events.length) return { empty: true, ledger: path, hint: "Nothing recorded yet — use `remember` to start." };
168
+
169
+ if (name === "pulse") return callApi("/v1/pulse", { events, tier: args.tier, root: args.root });
170
+ if (name === "recall") return callApi("/v1/recall", { events, op: args.op, subject: args.subject, object: args.object, topk: args.topk });
171
+ if (name === "execute") return callApi("/v1/execute", { events, program: args.program });
172
+ throw new Error(`unknown tool: ${name}`);
173
+ }
174
+
175
+ // ------------------------------------------------------- stdio transport ----
176
+ // STDOUT IS THE PROTOCOL. Never console.log here; diagnostics go to stderr.
177
+ const SUPPORTED = ["2025-06-18", "2025-03-26", "2024-11-05"];
178
+ const send = (msg) => process.stdout.write(JSON.stringify(msg) + "\n");
179
+ const ok = (id, result) => send({ jsonrpc: "2.0", id, result });
180
+ const fail = (id, code, message) => send({ jsonrpc: "2.0", id, error: { code, message } });
181
+
182
+ async function dispatch(msg) {
183
+ const { id, method, params } = msg;
184
+ if (method === "initialize") {
185
+ const wanted = params?.protocolVersion;
186
+ return ok(id, {
187
+ protocolVersion: SUPPORTED.includes(wanted) ? wanted : SUPPORTED[0],
188
+ capabilities: { tools: {} },
189
+ serverInfo: { name: "memory-pulse", version: "0.1.0" },
190
+ });
191
+ }
192
+ if (method === "notifications/initialized" || method === "initialized") return;
193
+ if (method === "ping") return ok(id, {});
194
+ if (method === "tools/list") return ok(id, { tools: TOOLS });
195
+ if (method === "tools/call") {
196
+ const { name, arguments: args } = params || {};
197
+ try {
198
+ const result = await handleCall(name, args || {});
199
+ return ok(id, { content: [{ type: "text", text: JSON.stringify(result, null, 2) }] });
200
+ } catch (e) {
201
+ return ok(id, { content: [{ type: "text", text: String(e?.message ?? e) }], isError: true });
202
+ }
203
+ }
204
+ if (id !== undefined) fail(id, -32601, `method not found: ${method}`);
205
+ }
206
+
207
+ // Importable for tests; only run the transport when invoked as a binary.
208
+ if (process.argv[1] && import.meta.url.endsWith(process.argv[1].split("/").pop())) {
209
+ process.stderr.write(`memory-pulse: ledger ${ledgerPath()} — api ${API}\n`);
210
+ const rl = readline.createInterface({ input: process.stdin, terminal: false });
211
+ // In-flight calls are drained before exit. Exiting the moment stdin closes
212
+ // would kill network requests mid-flight and swallow their responses — found
213
+ // by piping a scripted session rather than assuming the happy path.
214
+ const inflight = new Set();
215
+ rl.on("line", (line) => {
216
+ const s = line.trim();
217
+ if (!s) return;
218
+ let msg;
219
+ try { msg = JSON.parse(s); }
220
+ catch { process.stderr.write("memory-pulse: dropped malformed line\n"); return; }
221
+ const job = dispatch(msg)
222
+ .catch((e) => { if (msg && msg.id !== undefined) fail(msg.id, -32603, String(e?.message ?? e)); })
223
+ .finally(() => inflight.delete(job));
224
+ inflight.add(job);
225
+ });
226
+ rl.on("close", async () => {
227
+ await Promise.allSettled([...inflight]);
228
+ process.exit(0);
229
+ });
230
+ process.on("uncaughtException", (e) => process.stderr.write(`memory-pulse: ${e.stack}\n`));
231
+ }