memwarden 0.0.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 (119) hide show
  1. package/LICENSE +202 -0
  2. package/README.md +402 -0
  3. package/dist/bundle/bundle.d.ts +28 -0
  4. package/dist/bundle/bundle.js +85 -0
  5. package/dist/cli/bin.d.ts +2 -0
  6. package/dist/cli/bin.js +593 -0
  7. package/dist/cli/connect.d.ts +63 -0
  8. package/dist/cli/connect.js +121 -0
  9. package/dist/cli/hook.d.ts +24 -0
  10. package/dist/cli/hook.js +186 -0
  11. package/dist/cli/tools.d.ts +47 -0
  12. package/dist/cli/tools.js +246 -0
  13. package/dist/daemon/ensure.d.ts +12 -0
  14. package/dist/daemon/ensure.js +54 -0
  15. package/dist/daemon/service.d.ts +15 -0
  16. package/dist/daemon/service.js +210 -0
  17. package/dist/embedding/index.d.ts +10 -0
  18. package/dist/embedding/index.js +33 -0
  19. package/dist/embedding/local-embedding.d.ts +14 -0
  20. package/dist/embedding/local-embedding.js +80 -0
  21. package/dist/functions/access-tracker.d.ts +13 -0
  22. package/dist/functions/access-tracker.js +92 -0
  23. package/dist/functions/audit.d.ts +46 -0
  24. package/dist/functions/audit.js +0 -0
  25. package/dist/functions/cjk-segmenter.d.ts +6 -0
  26. package/dist/functions/cjk-segmenter.js +120 -0
  27. package/dist/functions/compress-synthetic.d.ts +2 -0
  28. package/dist/functions/compress-synthetic.js +104 -0
  29. package/dist/functions/config.d.ts +68 -0
  30. package/dist/functions/config.js +231 -0
  31. package/dist/functions/conflicts.d.ts +19 -0
  32. package/dist/functions/conflicts.js +328 -0
  33. package/dist/functions/context.d.ts +3 -0
  34. package/dist/functions/context.js +155 -0
  35. package/dist/functions/dedup.d.ts +11 -0
  36. package/dist/functions/dedup.js +51 -0
  37. package/dist/functions/dejafix.d.ts +96 -0
  38. package/dist/functions/dejafix.js +356 -0
  39. package/dist/functions/doctor.d.ts +29 -0
  40. package/dist/functions/doctor.js +137 -0
  41. package/dist/functions/forget.d.ts +3 -0
  42. package/dist/functions/forget.js +87 -0
  43. package/dist/functions/hybrid-search.d.ts +17 -0
  44. package/dist/functions/hybrid-search.js +205 -0
  45. package/dist/functions/index.d.ts +32 -0
  46. package/dist/functions/index.js +44 -0
  47. package/dist/functions/keyed-mutex.d.ts +1 -0
  48. package/dist/functions/keyed-mutex.js +21 -0
  49. package/dist/functions/logger.d.ts +6 -0
  50. package/dist/functions/logger.js +37 -0
  51. package/dist/functions/memory-utils.d.ts +2 -0
  52. package/dist/functions/memory-utils.js +29 -0
  53. package/dist/functions/observe.d.ts +5 -0
  54. package/dist/functions/observe.js +326 -0
  55. package/dist/functions/paths.d.ts +1 -0
  56. package/dist/functions/paths.js +38 -0
  57. package/dist/functions/privacy.d.ts +1 -0
  58. package/dist/functions/privacy.js +30 -0
  59. package/dist/functions/provenance.d.ts +9 -0
  60. package/dist/functions/provenance.js +57 -0
  61. package/dist/functions/quantized-vector-index.d.ts +60 -0
  62. package/dist/functions/quantized-vector-index.js +275 -0
  63. package/dist/functions/receipt.d.ts +31 -0
  64. package/dist/functions/receipt.js +95 -0
  65. package/dist/functions/search-index.d.ts +27 -0
  66. package/dist/functions/search-index.js +217 -0
  67. package/dist/functions/search.d.ts +25 -0
  68. package/dist/functions/search.js +523 -0
  69. package/dist/functions/stemmer.d.ts +1 -0
  70. package/dist/functions/stemmer.js +110 -0
  71. package/dist/functions/synonyms.d.ts +1 -0
  72. package/dist/functions/synonyms.js +69 -0
  73. package/dist/functions/turboquant.d.ts +53 -0
  74. package/dist/functions/turboquant.js +278 -0
  75. package/dist/functions/types.d.ts +217 -0
  76. package/dist/functions/types.js +8 -0
  77. package/dist/functions/vector-index.d.ts +25 -0
  78. package/dist/functions/vector-index.js +125 -0
  79. package/dist/functions/vector-persistence.d.ts +14 -0
  80. package/dist/functions/vector-persistence.js +75 -0
  81. package/dist/functions/verify.d.ts +13 -0
  82. package/dist/functions/verify.js +104 -0
  83. package/dist/index.d.ts +1 -0
  84. package/dist/index.js +219 -0
  85. package/dist/kernel/http.d.ts +24 -0
  86. package/dist/kernel/http.js +261 -0
  87. package/dist/kernel/index.d.ts +19 -0
  88. package/dist/kernel/index.js +21 -0
  89. package/dist/kernel/kernel.d.ts +80 -0
  90. package/dist/kernel/kernel.js +297 -0
  91. package/dist/kernel/pubsub.d.ts +21 -0
  92. package/dist/kernel/pubsub.js +38 -0
  93. package/dist/kernel/types.d.ts +139 -0
  94. package/dist/kernel/types.js +20 -0
  95. package/dist/mcp/bin.d.ts +2 -0
  96. package/dist/mcp/bin.js +27 -0
  97. package/dist/mcp/server.d.ts +34 -0
  98. package/dist/mcp/server.js +377 -0
  99. package/dist/observability/metrics.d.ts +26 -0
  100. package/dist/observability/metrics.js +104 -0
  101. package/dist/proxy/server.d.ts +30 -0
  102. package/dist/proxy/server.js +331 -0
  103. package/dist/state/kv.d.ts +41 -0
  104. package/dist/state/kv.js +50 -0
  105. package/dist/state/oplog.d.ts +25 -0
  106. package/dist/state/oplog.js +57 -0
  107. package/dist/state/schema.d.ts +60 -0
  108. package/dist/state/schema.js +88 -0
  109. package/dist/state/store-libsql.d.ts +46 -0
  110. package/dist/state/store-libsql.js +263 -0
  111. package/dist/state/store-memory.d.ts +23 -0
  112. package/dist/state/store-memory.js +121 -0
  113. package/dist/state/store.d.ts +87 -0
  114. package/dist/state/store.js +58 -0
  115. package/dist/triggers/api.d.ts +14 -0
  116. package/dist/triggers/api.js +510 -0
  117. package/dist/triggers/auth.d.ts +1 -0
  118. package/dist/triggers/auth.js +13 -0
  119. package/package.json +58 -0
@@ -0,0 +1,26 @@
1
+ declare function estimateTokens(text: string): number;
2
+ declare class Metrics {
3
+ private observeCount;
4
+ private observeRawTokens;
5
+ private observeStoredTokens;
6
+ private contextCount;
7
+ private contextCandidateTokens;
8
+ private contextServedTokens;
9
+ private contextLatency;
10
+ private searchCount;
11
+ private searchLatency;
12
+ private pushSample;
13
+ /** Record one observe: raw payload size vs the compressed record stored. */
14
+ recordObserve(rawText: string, storedText: string): void;
15
+ /**
16
+ * Record one context build: candidate = tokens of everything that
17
+ * matched, served = tokens actually returned under budget.
18
+ */
19
+ recordContext(candidateTokens: number, servedTokens: number, latencyMs: number): void;
20
+ recordSearch(latencyMs: number): void;
21
+ snapshot(): Record<string, unknown>;
22
+ reset(): void;
23
+ }
24
+ /** Process-wide metrics singleton. */
25
+ export declare const metrics: Metrics;
26
+ export { estimateTokens };
@@ -0,0 +1,104 @@
1
+ //
2
+ // In-process observability for memwarden. Tracks the two numbers that
3
+ // decide whether this layer is actually good for an agent:
4
+ //
5
+ // 1. Token economy — how many tokens we save by storing compressed
6
+ // observations and serving budgeted context instead of raw history.
7
+ // 2. Latency — how fast search and context return, so switching agents
8
+ // never means "wait while it finds the context."
9
+ //
10
+ // Running aggregates, no I/O on the hot path. Latency keeps a bounded
11
+ // sample window for percentiles. Reset for tests. Durable persistence
12
+ // (a cost-ledger KV table) can layer on top later without changing callers.
13
+ const MAX_SAMPLES = 1000;
14
+ function estimateTokens(text) {
15
+ // Same heuristic the context packer uses (~3 chars/token). Centralized
16
+ // here so every token number in the system comes from one place.
17
+ return Math.ceil(text.length / 3);
18
+ }
19
+ function percentile(sorted, p) {
20
+ if (sorted.length === 0)
21
+ return 0;
22
+ const idx = Math.min(sorted.length - 1, Math.max(0, Math.ceil((p / 100) * sorted.length) - 1));
23
+ return sorted[idx];
24
+ }
25
+ class Metrics {
26
+ observeCount = 0;
27
+ observeRawTokens = 0;
28
+ observeStoredTokens = 0;
29
+ contextCount = 0;
30
+ contextCandidateTokens = 0;
31
+ contextServedTokens = 0;
32
+ contextLatency = [];
33
+ searchCount = 0;
34
+ searchLatency = [];
35
+ pushSample(arr, v) {
36
+ arr.push(v);
37
+ if (arr.length > MAX_SAMPLES)
38
+ arr.shift();
39
+ }
40
+ /** Record one observe: raw payload size vs the compressed record stored. */
41
+ recordObserve(rawText, storedText) {
42
+ this.observeCount++;
43
+ this.observeRawTokens += estimateTokens(rawText);
44
+ this.observeStoredTokens += estimateTokens(storedText);
45
+ }
46
+ /**
47
+ * Record one context build: candidate = tokens of everything that
48
+ * matched, served = tokens actually returned under budget.
49
+ */
50
+ recordContext(candidateTokens, servedTokens, latencyMs) {
51
+ this.contextCount++;
52
+ this.contextCandidateTokens += candidateTokens;
53
+ this.contextServedTokens += servedTokens;
54
+ this.pushSample(this.contextLatency, latencyMs);
55
+ }
56
+ recordSearch(latencyMs) {
57
+ this.searchCount++;
58
+ this.pushSample(this.searchLatency, latencyMs);
59
+ }
60
+ snapshot() {
61
+ const ctxSorted = [...this.contextLatency].sort((a, b) => a - b);
62
+ const searchSorted = [...this.searchLatency].sort((a, b) => a - b);
63
+ const pct = (saved, total) => total > 0 ? Math.round((saved / total) * 1000) / 10 : 0;
64
+ return {
65
+ observe: {
66
+ count: this.observeCount,
67
+ rawTokens: this.observeRawTokens,
68
+ storedTokens: this.observeStoredTokens,
69
+ reductionPct: pct(this.observeRawTokens - this.observeStoredTokens, this.observeRawTokens),
70
+ },
71
+ context: {
72
+ count: this.contextCount,
73
+ candidateTokens: this.contextCandidateTokens,
74
+ servedTokens: this.contextServedTokens,
75
+ reductionPct: pct(this.contextCandidateTokens - this.contextServedTokens, this.contextCandidateTokens),
76
+ latencyMs: {
77
+ p50: Math.round(percentile(ctxSorted, 50)),
78
+ p95: Math.round(percentile(ctxSorted, 95)),
79
+ },
80
+ },
81
+ search: {
82
+ count: this.searchCount,
83
+ latencyMs: {
84
+ p50: Math.round(percentile(searchSorted, 50)),
85
+ p95: Math.round(percentile(searchSorted, 95)),
86
+ },
87
+ },
88
+ };
89
+ }
90
+ reset() {
91
+ this.observeCount = 0;
92
+ this.observeRawTokens = 0;
93
+ this.observeStoredTokens = 0;
94
+ this.contextCount = 0;
95
+ this.contextCandidateTokens = 0;
96
+ this.contextServedTokens = 0;
97
+ this.contextLatency = [];
98
+ this.searchCount = 0;
99
+ this.searchLatency = [];
100
+ }
101
+ }
102
+ /** Process-wide metrics singleton. */
103
+ export const metrics = new Metrics();
104
+ export { estimateTokens };
@@ -0,0 +1,30 @@
1
+ import { type Server } from "node:http";
2
+ export interface ProxyOptions {
3
+ /** Port to listen on. */
4
+ port: number;
5
+ host?: string;
6
+ /** Upstream OpenAI-compatible base URL, e.g. https://api.openai.com/v1. */
7
+ upstreamUrl: string;
8
+ /** API key forwarded to the upstream as Authorization: Bearer. */
9
+ upstreamKey?: string;
10
+ /** Local memwarden daemon base, e.g. http://127.0.0.1:3111. */
11
+ daemonUrl: string;
12
+ /**
13
+ * The install's shared secret. Used outbound for the daemon's auth'd
14
+ * routes, and enforced inbound: when set, proxy clients must present it as
15
+ * `Authorization: Bearer <secret>` (set the tool's API key to it).
16
+ */
17
+ secret?: string;
18
+ /** Project/workspace this proxy's captures belong to (defaults to cwd). */
19
+ project: string;
20
+ /** Working directory used to scope recall. */
21
+ cwd: string;
22
+ /** Token budget for injected memory. */
23
+ tokenBudget?: number;
24
+ }
25
+ export interface RunningProxy {
26
+ server: Server;
27
+ port: number;
28
+ close(): Promise<void>;
29
+ }
30
+ export declare function startProxyServer(opts: ProxyOptions): RunningProxy;
@@ -0,0 +1,331 @@
1
+ //
2
+ // The memory proxy: an OpenAI-compatible gateway that turns memwarden into
3
+ // a universal, automatic memory layer for every tool that speaks the
4
+ // /v1/chat/completions protocol — local models (Ollama :11434/v1, LM Studio
5
+ // :1234/v1) and paid ones (OpenAI, OpenRouter, Together) alike. They all
6
+ // share that one boundary, and the proxy is blind to which is behind it, so
7
+ // it is a single memory layer for all of them.
8
+ //
9
+ // On a chat completion the proxy:
10
+ // 1. pulls the latest user turn as a query,
11
+ // 2. asks the local daemon for relevant memory (/memwarden/search,
12
+ // narrative format),
13
+ // 3. injects it as a system message ahead of the conversation,
14
+ // 4. forwards the rewritten request to the configured upstream,
15
+ // 5. streams the response straight back to the client unchanged while
16
+ // tee-ing it to reconstruct the assistant's answer,
17
+ // 6. captures the user turn + answer into memory (/memwarden/observe).
18
+ //
19
+ // Everything else (GET /v1/models, etc.) is a transparent passthrough — no
20
+ // injection, no capture. The kernel's own HTTP server JSON-buffers every
21
+ // response and cannot stream, which is why the proxy is a separate node:http
22
+ // server doing raw request/response piping.
23
+ import { createServer, request as httpRequest, } from "node:http";
24
+ import { request as httpsRequest } from "node:https";
25
+ import { URL } from "node:url";
26
+ import { isLoopbackHost } from "../kernel/http.js";
27
+ import { timingSafeCompare } from "../triggers/auth.js";
28
+ import { isCaptureEnabled, isInjectEnabled, isProjectExcluded, } from "../functions/config.js";
29
+ // Hop-by-hop headers must not be forwarded across a proxy boundary.
30
+ const HOP_BY_HOP = new Set([
31
+ "connection",
32
+ "keep-alive",
33
+ "proxy-authenticate",
34
+ "proxy-authorization",
35
+ "te",
36
+ "trailer",
37
+ "transfer-encoding",
38
+ "upgrade",
39
+ "content-length",
40
+ "host",
41
+ ]);
42
+ export function startProxyServer(opts) {
43
+ const host = opts.host ?? "127.0.0.1";
44
+ // One session per proxy process. Turns from different conversations share
45
+ // it; the daemon's dedup keys on the prompt so distinct prompts still land.
46
+ const ctx = { ...opts, sessionId: `proxy-${opts.port}` };
47
+ const server = createServer((req, res) => {
48
+ handle(req, res, ctx).catch((err) => {
49
+ if (!res.headersSent) {
50
+ res.statusCode = 502;
51
+ res.setHeader("content-type", "application/json");
52
+ res.end(JSON.stringify({
53
+ error: "memwarden_proxy_error",
54
+ message: err instanceof Error ? err.message : String(err),
55
+ }));
56
+ }
57
+ else if (!res.writableEnded) {
58
+ res.end();
59
+ }
60
+ });
61
+ });
62
+ server.listen(opts.port, host);
63
+ return {
64
+ server,
65
+ port: opts.port,
66
+ close: () => new Promise((resolve) => server.close(() => resolve())),
67
+ };
68
+ }
69
+ async function handle(req, res, ctx) {
70
+ // Same DNS-rebinding firewall as the kernel HTTP server: the proxy is a
71
+ // localhost-only gateway that spends the upstream API key, so a webpage that
72
+ // rebinds DNS to 127.0.0.1 must not reach it. The Host header reflects the
73
+ // hostname the client actually targeted; reject anything non-loopback.
74
+ if (!isLoopbackHost(req.headers.host, req.socket.localPort)) {
75
+ res.statusCode = 403;
76
+ res.setHeader("content-type", "application/json");
77
+ res.end(JSON.stringify({ error: "forbidden_host" }));
78
+ return;
79
+ }
80
+ const url = new URL(req.url ?? "/", "http://localhost");
81
+ const path = url.pathname;
82
+ if (req.method === "GET" && path === "/livez") {
83
+ res.statusCode = 200;
84
+ res.setHeader("content-type", "application/json");
85
+ res.end(JSON.stringify({ status: "ok", service: "memwarden-proxy" }));
86
+ return;
87
+ }
88
+ // Client auth: when the install has a secret, the proxy requires it too.
89
+ // The proxy substitutes the real upstream API key and writes captures into
90
+ // memory, so an open localhost port would let any local process spend the
91
+ // key and poison capture. OpenAI-compatible tools already send their
92
+ // configured API key as `Authorization: Bearer` — set that key to the
93
+ // memwarden secret. buildUpstreamHeaders strips the client Authorization
94
+ // before forwarding, so the secret never reaches the upstream. /livez
95
+ // stays open: it spends no key and captures nothing.
96
+ if (ctx.secret) {
97
+ const auth = req.headers.authorization;
98
+ if (typeof auth !== "string" ||
99
+ !timingSafeCompare(auth, `Bearer ${ctx.secret}`)) {
100
+ res.statusCode = 401;
101
+ res.setHeader("content-type", "application/json");
102
+ res.end(JSON.stringify({
103
+ error: "unauthorized",
104
+ message: "Set your tool's API key to the memwarden secret (the `secret` file in your data dir)",
105
+ }));
106
+ return;
107
+ }
108
+ }
109
+ const isChat = req.method === "POST" &&
110
+ (path === "/v1/chat/completions" || path === "/chat/completions");
111
+ const reqBody = await readBody(req);
112
+ // For a chat completion, inject memory and remember the query so we can
113
+ // capture the answer. Other paths pass straight through.
114
+ // The same switches the hooks honor: MEMWARDEN_INJECT / MEMWARDEN_CAPTURE
115
+ // and the per-project exclude list, checked per request so `memwarden
116
+ // exclude` takes effect without a daemon restart.
117
+ const excluded = isProjectExcluded(ctx.cwd);
118
+ const inject = isInjectEnabled() && !excluded;
119
+ const capture = isCaptureEnabled() && !excluded;
120
+ let outBody = reqBody;
121
+ let query = "";
122
+ if (isChat && reqBody.length && (inject || capture)) {
123
+ try {
124
+ const payload = JSON.parse(reqBody.toString("utf8"));
125
+ query = extractUserQuery(payload);
126
+ if (query && inject) {
127
+ const memory = await fetchMemory(ctx, query).catch(() => "");
128
+ if (memory) {
129
+ outBody = Buffer.from(JSON.stringify(injectMemory(payload, memory)), "utf8");
130
+ }
131
+ }
132
+ }
133
+ catch {
134
+ // Not JSON we understand — forward the original bytes untouched.
135
+ }
136
+ }
137
+ const target = new URL(ctx.upstreamUrl + path.replace(/^\/v1/, ""));
138
+ const upstream = await requestUpstream(target, req.method ?? "GET", buildUpstreamHeaders(req.headers, outBody, ctx.upstreamKey), outBody.length ? outBody : undefined);
139
+ res.statusCode = upstream.statusCode ?? 502;
140
+ for (const [k, v] of Object.entries(upstream.headers)) {
141
+ if (v === undefined || HOP_BY_HOP.has(k.toLowerCase()))
142
+ continue;
143
+ res.setHeader(k, v);
144
+ }
145
+ // Pipe the response straight back, tee-ing it so we can reconstruct the
146
+ // assistant's answer for capture once the stream finishes.
147
+ const captured = [];
148
+ const wantCapture = isChat && query !== "" && capture;
149
+ upstream.on("data", (chunk) => {
150
+ if (wantCapture)
151
+ captured.push(chunk);
152
+ res.write(chunk);
153
+ });
154
+ upstream.on("end", () => {
155
+ res.end();
156
+ if (wantCapture && (upstream.statusCode ?? 0) < 400) {
157
+ const answer = extractAnswer(Buffer.concat(captured), upstream.headers["content-type"]);
158
+ if (answer)
159
+ void captureExchange(ctx, query, answer);
160
+ }
161
+ });
162
+ upstream.on("error", () => {
163
+ if (!res.writableEnded)
164
+ res.end();
165
+ });
166
+ }
167
+ // --- request/response plumbing -------------------------------------
168
+ function readBody(req) {
169
+ return new Promise((resolve, reject) => {
170
+ const chunks = [];
171
+ req.on("data", (c) => chunks.push(c));
172
+ req.on("end", () => resolve(Buffer.concat(chunks)));
173
+ req.on("error", reject);
174
+ });
175
+ }
176
+ function buildUpstreamHeaders(incoming, body, upstreamKey) {
177
+ const out = {};
178
+ for (const [k, v] of Object.entries(incoming)) {
179
+ const lower = k.toLowerCase();
180
+ if (v === undefined || HOP_BY_HOP.has(lower))
181
+ continue;
182
+ if (lower === "authorization")
183
+ continue; // overridden below
184
+ if (lower === "accept-encoding")
185
+ continue; // forced to identity below
186
+ out[k] = Array.isArray(v) ? v.join(", ") : v;
187
+ }
188
+ // Identity encoding so the tee can parse the answer without gunzipping.
189
+ out["accept-encoding"] = "identity";
190
+ if (body.length)
191
+ out["content-length"] = String(body.length);
192
+ // The client's key (if any) is replaced with the configured upstream key;
193
+ // a local model that needs none simply gets no Authorization header.
194
+ if (upstreamKey)
195
+ out["authorization"] = `Bearer ${upstreamKey}`;
196
+ return out;
197
+ }
198
+ function requestUpstream(target, method, headers, body) {
199
+ return new Promise((resolve, reject) => {
200
+ const isHttps = target.protocol === "https:";
201
+ const send = isHttps ? httpsRequest : httpRequest;
202
+ const req = send({
203
+ protocol: target.protocol,
204
+ hostname: target.hostname,
205
+ port: target.port || (isHttps ? 443 : 80),
206
+ path: target.pathname + target.search,
207
+ method,
208
+ headers,
209
+ }, (res) => resolve(res));
210
+ req.on("error", reject);
211
+ if (body)
212
+ req.write(body);
213
+ req.end();
214
+ });
215
+ }
216
+ function messageText(content) {
217
+ if (typeof content === "string")
218
+ return content;
219
+ if (Array.isArray(content)) {
220
+ return content
221
+ .map((p) => (typeof p?.text === "string" ? p.text : ""))
222
+ .filter(Boolean)
223
+ .join("\n");
224
+ }
225
+ return "";
226
+ }
227
+ function extractUserQuery(payload) {
228
+ const messages = Array.isArray(payload.messages) ? payload.messages : [];
229
+ for (let i = messages.length - 1; i >= 0; i--) {
230
+ const m = messages[i];
231
+ if (m && m.role === "user")
232
+ return messageText(m.content).trim();
233
+ }
234
+ return "";
235
+ }
236
+ function injectMemory(payload, memory) {
237
+ const messages = Array.isArray(payload.messages)
238
+ ? payload.messages.slice()
239
+ : [];
240
+ const sys = {
241
+ role: "system",
242
+ content: "# Relevant memory (memwarden)\n" +
243
+ "Context recalled from past sessions. Use it if helpful; ignore if not.\n\n" +
244
+ memory,
245
+ };
246
+ // Insert after any leading system messages so the developer's own system
247
+ // prompt still comes first.
248
+ let at = 0;
249
+ while (at < messages.length && messages[at]?.role === "system")
250
+ at++;
251
+ messages.splice(at, 0, sys);
252
+ return { ...payload, messages };
253
+ }
254
+ function extractAnswer(buf, contentType) {
255
+ const text = buf.toString("utf8");
256
+ const ct = typeof contentType === "string" ? contentType : "";
257
+ if (ct.includes("text/event-stream") || text.startsWith("data:")) {
258
+ let acc = "";
259
+ for (const line of text.split("\n")) {
260
+ const trimmed = line.trim();
261
+ if (!trimmed.startsWith("data:"))
262
+ continue;
263
+ const data = trimmed.slice(5).trim();
264
+ if (!data || data === "[DONE]")
265
+ continue;
266
+ try {
267
+ const obj = JSON.parse(data);
268
+ const piece = obj.choices?.[0]?.delta?.content;
269
+ if (typeof piece === "string")
270
+ acc += piece;
271
+ }
272
+ catch {
273
+ // skip malformed SSE frame
274
+ }
275
+ }
276
+ return acc.trim();
277
+ }
278
+ try {
279
+ const obj = JSON.parse(text);
280
+ const content = obj.choices?.[0]?.message?.content;
281
+ if (content !== undefined)
282
+ return messageText(content).trim();
283
+ }
284
+ catch {
285
+ // not JSON
286
+ }
287
+ return "";
288
+ }
289
+ // --- daemon calls (recall + capture) -------------------------------
290
+ function daemonHeaders(secret) {
291
+ const h = { "content-type": "application/json" };
292
+ if (secret)
293
+ h["authorization"] = `Bearer ${secret}`;
294
+ return h;
295
+ }
296
+ async function fetchMemory(ctx, query) {
297
+ const res = await fetch(`${ctx.daemonUrl}/memwarden/search`, {
298
+ method: "POST",
299
+ headers: daemonHeaders(ctx.secret),
300
+ body: JSON.stringify({
301
+ query,
302
+ format: "narrative",
303
+ project: ctx.project,
304
+ cwd: ctx.cwd,
305
+ safe_only: true, // Verified Recall: never inject stale memory into a model call
306
+ ...(ctx.tokenBudget ? { token_budget: ctx.tokenBudget } : {}),
307
+ }),
308
+ });
309
+ if (!res.ok)
310
+ return "";
311
+ const body = (await res.json());
312
+ return typeof body.text === "string" ? body.text : "";
313
+ }
314
+ async function captureExchange(ctx, query, answer) {
315
+ await fetch(`${ctx.daemonUrl}/memwarden/observe`, {
316
+ method: "POST",
317
+ headers: daemonHeaders(ctx.secret),
318
+ body: JSON.stringify({
319
+ hookType: "post_tool_use",
320
+ sessionId: ctx.sessionId,
321
+ project: ctx.project,
322
+ cwd: ctx.cwd,
323
+ timestamp: new Date().toISOString(),
324
+ data: {
325
+ tool_name: "chat",
326
+ tool_input: { prompt: query.slice(0, 4000) },
327
+ tool_output: answer.slice(0, 8000),
328
+ },
329
+ }),
330
+ }).catch(() => undefined);
331
+ }
@@ -0,0 +1,41 @@
1
+ /** Built-in state function ids the kernel routes to the StateStore. */
2
+ export declare const STATE_FUNCTION_IDS: {
3
+ readonly get: "state::get";
4
+ readonly set: "state::set";
5
+ readonly update: "state::update";
6
+ readonly delete: "state::delete";
7
+ readonly list: "state::list";
8
+ };
9
+ /** A single flat update operation; callers only ever produce `type:"set"`. */
10
+ export interface KvUpdateOp {
11
+ type: string;
12
+ path: string;
13
+ value?: unknown;
14
+ }
15
+ /** The one dispatch method StateKV needs; the kernel's ISdk satisfies it. */
16
+ export interface TriggerSink {
17
+ trigger<P = unknown, R = unknown>(opts: {
18
+ function_id: string;
19
+ payload: P;
20
+ action?: unknown;
21
+ }): Promise<R>;
22
+ }
23
+ /**
24
+ * Five-method KV facade over the kernel trigger chokepoint:
25
+ * get -> T | null (null on miss, never throws)
26
+ * set -> upsert, returns the written value (last write wins)
27
+ * update -> read-or-{}, apply flat set-ops, write back, return result
28
+ * delete -> idempotent, void
29
+ * list -> values only, exact scope match, insertion order, [] on miss
30
+ */
31
+ export declare class StateKV {
32
+ private readonly sdk;
33
+ constructor(sdk: TriggerSink);
34
+ private call;
35
+ get<T = unknown>(scope: string, key: string): Promise<T | null>;
36
+ set<T = unknown>(scope: string, key: string, value: T): Promise<T>;
37
+ update<T = unknown>(scope: string, key: string, ops: Array<KvUpdateOp>): Promise<T>;
38
+ delete(scope: string, key: string): Promise<void>;
39
+ list<T = unknown>(scope: string): Promise<T[]>;
40
+ }
41
+ export type { ISdk } from "../kernel/index.js";
@@ -0,0 +1,50 @@
1
+ //
2
+ // StateKV is the single persistence chokepoint for every mem:: function. It
3
+ // turns five operations (get/set/update/delete/list over scope + key) into one
4
+ // trigger dispatch against the in-process kernel, which routes the five
5
+ // state::* function ids to a StateStore (store.ts / store-libsql.ts /
6
+ // store-memory.ts).
7
+ //
8
+ // The constructor takes the narrow TriggerSink (just the `trigger` method) so
9
+ // the state layer has no build-time dependency on the kernel; the kernel's ISdk
10
+ // is structurally assignable and re-exported below for `new StateKV(kernel)`.
11
+ /** Built-in state function ids the kernel routes to the StateStore. */
12
+ export const STATE_FUNCTION_IDS = {
13
+ get: "state::get",
14
+ set: "state::set",
15
+ update: "state::update",
16
+ delete: "state::delete",
17
+ list: "state::list",
18
+ };
19
+ /**
20
+ * Five-method KV facade over the kernel trigger chokepoint:
21
+ * get -> T | null (null on miss, never throws)
22
+ * set -> upsert, returns the written value (last write wins)
23
+ * update -> read-or-{}, apply flat set-ops, write back, return result
24
+ * delete -> idempotent, void
25
+ * list -> values only, exact scope match, insertion order, [] on miss
26
+ */
27
+ export class StateKV {
28
+ sdk;
29
+ constructor(sdk) {
30
+ this.sdk = sdk;
31
+ }
32
+ call(functionId, payload) {
33
+ return this.sdk.trigger({ function_id: functionId, payload });
34
+ }
35
+ get(scope, key) {
36
+ return this.call(STATE_FUNCTION_IDS.get, { scope, key });
37
+ }
38
+ set(scope, key, value) {
39
+ return this.call(STATE_FUNCTION_IDS.set, { scope, key, value });
40
+ }
41
+ update(scope, key, ops) {
42
+ return this.call(STATE_FUNCTION_IDS.update, { scope, key, ops });
43
+ }
44
+ delete(scope, key) {
45
+ return this.call(STATE_FUNCTION_IDS.delete, { scope, key });
46
+ }
47
+ list(scope) {
48
+ return this.call(STATE_FUNCTION_IDS.list, { scope });
49
+ }
50
+ }
@@ -0,0 +1,25 @@
1
+ import { type OplogEntry, type StateEventType } from "./store.js";
2
+ /** The empty-string sentinel used as `prev_hash` for the genesis entry. */
3
+ export declare const GENESIS_PREV_HASH = "";
4
+ /**
5
+ * Compute the hash for an oplog entry. The hash covers everything that
6
+ * matters for tamper-evidence: identity, time, operation, location, value,
7
+ * and the link to the previous entry. The `hash` field itself is excluded
8
+ * (it is the output).
9
+ */
10
+ export declare function hashOplogEntry(fields: {
11
+ id: number;
12
+ ts: string;
13
+ op: StateEventType;
14
+ scope: string;
15
+ key: string;
16
+ payload: unknown;
17
+ prev_hash: string;
18
+ }): string;
19
+ /**
20
+ * Walk an ordered list of oplog entries and confirm the chain is intact:
21
+ * ids strictly increasing, each entry's prev_hash equal to the prior entry's
22
+ * hash (genesis links to GENESIS_PREV_HASH), and each entry's hash matching a
23
+ * fresh recomputation. Returns the id of the first broken entry or null.
24
+ */
25
+ export declare function verifyChain(entries: readonly OplogEntry[]): number | null;
@@ -0,0 +1,57 @@
1
+ //
2
+ // Hash-chain helpers for the append-only oplog: a SHA-256 chain that is
3
+ // tamper-evident (any edit/reorder/drop breaks the chain at the first touched
4
+ // entry). The canonicalization here is the contract that anything signing the
5
+ // oplog later would sign over, so it must stay stable.
6
+ import { createHash } from "node:crypto";
7
+ import { canonicalize } from "./store.js";
8
+ /** The empty-string sentinel used as `prev_hash` for the genesis entry. */
9
+ export const GENESIS_PREV_HASH = "";
10
+ /**
11
+ * Compute the hash for an oplog entry. The hash covers everything that
12
+ * matters for tamper-evidence: identity, time, operation, location, value,
13
+ * and the link to the previous entry. The `hash` field itself is excluded
14
+ * (it is the output).
15
+ */
16
+ export function hashOplogEntry(fields) {
17
+ const canonical = canonicalize({
18
+ id: fields.id,
19
+ ts: fields.ts,
20
+ op: fields.op,
21
+ scope: fields.scope,
22
+ key: fields.key,
23
+ payload: fields.payload ?? null,
24
+ prev_hash: fields.prev_hash,
25
+ });
26
+ return createHash("sha256").update(canonical).digest("hex");
27
+ }
28
+ /**
29
+ * Walk an ordered list of oplog entries and confirm the chain is intact:
30
+ * ids strictly increasing, each entry's prev_hash equal to the prior entry's
31
+ * hash (genesis links to GENESIS_PREV_HASH), and each entry's hash matching a
32
+ * fresh recomputation. Returns the id of the first broken entry or null.
33
+ */
34
+ export function verifyChain(entries) {
35
+ let expectedPrev = GENESIS_PREV_HASH;
36
+ let lastId = -Infinity;
37
+ for (const entry of entries) {
38
+ if (entry.id <= lastId)
39
+ return entry.id;
40
+ if (entry.prev_hash !== expectedPrev)
41
+ return entry.id;
42
+ const recomputed = hashOplogEntry({
43
+ id: entry.id,
44
+ ts: entry.ts,
45
+ op: entry.op,
46
+ scope: entry.scope,
47
+ key: entry.key,
48
+ payload: entry.payload,
49
+ prev_hash: entry.prev_hash,
50
+ });
51
+ if (recomputed !== entry.hash)
52
+ return entry.id;
53
+ expectedPrev = entry.hash;
54
+ lastId = entry.id;
55
+ }
56
+ return null;
57
+ }