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.
- package/LICENSE +202 -0
- package/README.md +402 -0
- package/dist/bundle/bundle.d.ts +28 -0
- package/dist/bundle/bundle.js +85 -0
- package/dist/cli/bin.d.ts +2 -0
- package/dist/cli/bin.js +593 -0
- package/dist/cli/connect.d.ts +63 -0
- package/dist/cli/connect.js +121 -0
- package/dist/cli/hook.d.ts +24 -0
- package/dist/cli/hook.js +186 -0
- package/dist/cli/tools.d.ts +47 -0
- package/dist/cli/tools.js +246 -0
- package/dist/daemon/ensure.d.ts +12 -0
- package/dist/daemon/ensure.js +54 -0
- package/dist/daemon/service.d.ts +15 -0
- package/dist/daemon/service.js +210 -0
- package/dist/embedding/index.d.ts +10 -0
- package/dist/embedding/index.js +33 -0
- package/dist/embedding/local-embedding.d.ts +14 -0
- package/dist/embedding/local-embedding.js +80 -0
- package/dist/functions/access-tracker.d.ts +13 -0
- package/dist/functions/access-tracker.js +92 -0
- package/dist/functions/audit.d.ts +46 -0
- package/dist/functions/audit.js +0 -0
- package/dist/functions/cjk-segmenter.d.ts +6 -0
- package/dist/functions/cjk-segmenter.js +120 -0
- package/dist/functions/compress-synthetic.d.ts +2 -0
- package/dist/functions/compress-synthetic.js +104 -0
- package/dist/functions/config.d.ts +68 -0
- package/dist/functions/config.js +231 -0
- package/dist/functions/conflicts.d.ts +19 -0
- package/dist/functions/conflicts.js +328 -0
- package/dist/functions/context.d.ts +3 -0
- package/dist/functions/context.js +155 -0
- package/dist/functions/dedup.d.ts +11 -0
- package/dist/functions/dedup.js +51 -0
- package/dist/functions/dejafix.d.ts +96 -0
- package/dist/functions/dejafix.js +356 -0
- package/dist/functions/doctor.d.ts +29 -0
- package/dist/functions/doctor.js +137 -0
- package/dist/functions/forget.d.ts +3 -0
- package/dist/functions/forget.js +87 -0
- package/dist/functions/hybrid-search.d.ts +17 -0
- package/dist/functions/hybrid-search.js +205 -0
- package/dist/functions/index.d.ts +32 -0
- package/dist/functions/index.js +44 -0
- package/dist/functions/keyed-mutex.d.ts +1 -0
- package/dist/functions/keyed-mutex.js +21 -0
- package/dist/functions/logger.d.ts +6 -0
- package/dist/functions/logger.js +37 -0
- package/dist/functions/memory-utils.d.ts +2 -0
- package/dist/functions/memory-utils.js +29 -0
- package/dist/functions/observe.d.ts +5 -0
- package/dist/functions/observe.js +326 -0
- package/dist/functions/paths.d.ts +1 -0
- package/dist/functions/paths.js +38 -0
- package/dist/functions/privacy.d.ts +1 -0
- package/dist/functions/privacy.js +30 -0
- package/dist/functions/provenance.d.ts +9 -0
- package/dist/functions/provenance.js +57 -0
- package/dist/functions/quantized-vector-index.d.ts +60 -0
- package/dist/functions/quantized-vector-index.js +275 -0
- package/dist/functions/receipt.d.ts +31 -0
- package/dist/functions/receipt.js +95 -0
- package/dist/functions/search-index.d.ts +27 -0
- package/dist/functions/search-index.js +217 -0
- package/dist/functions/search.d.ts +25 -0
- package/dist/functions/search.js +523 -0
- package/dist/functions/stemmer.d.ts +1 -0
- package/dist/functions/stemmer.js +110 -0
- package/dist/functions/synonyms.d.ts +1 -0
- package/dist/functions/synonyms.js +69 -0
- package/dist/functions/turboquant.d.ts +53 -0
- package/dist/functions/turboquant.js +278 -0
- package/dist/functions/types.d.ts +217 -0
- package/dist/functions/types.js +8 -0
- package/dist/functions/vector-index.d.ts +25 -0
- package/dist/functions/vector-index.js +125 -0
- package/dist/functions/vector-persistence.d.ts +14 -0
- package/dist/functions/vector-persistence.js +75 -0
- package/dist/functions/verify.d.ts +13 -0
- package/dist/functions/verify.js +104 -0
- package/dist/index.d.ts +1 -0
- package/dist/index.js +219 -0
- package/dist/kernel/http.d.ts +24 -0
- package/dist/kernel/http.js +261 -0
- package/dist/kernel/index.d.ts +19 -0
- package/dist/kernel/index.js +21 -0
- package/dist/kernel/kernel.d.ts +80 -0
- package/dist/kernel/kernel.js +297 -0
- package/dist/kernel/pubsub.d.ts +21 -0
- package/dist/kernel/pubsub.js +38 -0
- package/dist/kernel/types.d.ts +139 -0
- package/dist/kernel/types.js +20 -0
- package/dist/mcp/bin.d.ts +2 -0
- package/dist/mcp/bin.js +27 -0
- package/dist/mcp/server.d.ts +34 -0
- package/dist/mcp/server.js +377 -0
- package/dist/observability/metrics.d.ts +26 -0
- package/dist/observability/metrics.js +104 -0
- package/dist/proxy/server.d.ts +30 -0
- package/dist/proxy/server.js +331 -0
- package/dist/state/kv.d.ts +41 -0
- package/dist/state/kv.js +50 -0
- package/dist/state/oplog.d.ts +25 -0
- package/dist/state/oplog.js +57 -0
- package/dist/state/schema.d.ts +60 -0
- package/dist/state/schema.js +88 -0
- package/dist/state/store-libsql.d.ts +46 -0
- package/dist/state/store-libsql.js +263 -0
- package/dist/state/store-memory.d.ts +23 -0
- package/dist/state/store-memory.js +121 -0
- package/dist/state/store.d.ts +87 -0
- package/dist/state/store.js +58 -0
- package/dist/triggers/api.d.ts +14 -0
- package/dist/triggers/api.js +510 -0
- package/dist/triggers/auth.d.ts +1 -0
- package/dist/triggers/auth.js +13 -0
- 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";
|
package/dist/state/kv.js
ADDED
|
@@ -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
|
+
}
|