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,261 @@
1
+ //
2
+ // The kernel's HTTP front door. A node:http server that matches
3
+ // registered `type:"http"` routes by method + path, parses the JSON
4
+ // body into `req.body`, runs the middleware chain, invokes the bound
5
+ // function, and serializes its `{status_code, headers?, body}` return.
6
+ // Answers CORS preflight per the configured origins.
7
+ import { createServer } from "node:http";
8
+ import { URL } from "node:url";
9
+ const DEFAULT_ORIGINS = [
10
+ "http://localhost:3111",
11
+ "http://localhost:3113",
12
+ "http://127.0.0.1:3111",
13
+ "http://127.0.0.1:3113",
14
+ ];
15
+ const ALLOWED_METHODS = "GET,POST,PUT,DELETE,OPTIONS";
16
+ const ALLOWED_HEADERS = "Content-Type,Authorization";
17
+ const DEFAULT_MAX_BODY = 16 * 1024 * 1024;
18
+ export function startHttpServer(kernel, opts) {
19
+ const host = opts.host ?? "127.0.0.1";
20
+ const allowedOrigins = opts.allowedOrigins ?? DEFAULT_ORIGINS;
21
+ const maxBodyBytes = opts.maxBodyBytes ?? DEFAULT_MAX_BODY;
22
+ // Index routes by `METHOD path` for O(1) exact-match lookup.
23
+ const routeIndex = new Map();
24
+ for (const route of kernel.getHttpRoutes()) {
25
+ routeIndex.set(routeKey(route.method, route.path), route);
26
+ }
27
+ const server = createServer((req, res) => {
28
+ handleRequest(req, res, kernel, routeIndex, {
29
+ allowedOrigins,
30
+ maxBodyBytes,
31
+ }).catch((err) => {
32
+ // Last-resort guard: never let a handler throw take down the
33
+ // connection without a response.
34
+ if (!res.headersSent) {
35
+ sendJson(res, 500, undefined, {
36
+ error: "internal",
37
+ message: err instanceof Error ? err.message : String(err),
38
+ });
39
+ }
40
+ });
41
+ });
42
+ server.listen(opts.port, host);
43
+ return {
44
+ server,
45
+ port: opts.port,
46
+ close: () => new Promise((resolve) => {
47
+ server.close(() => resolve());
48
+ }),
49
+ };
50
+ }
51
+ async function handleRequest(req, res, kernel, routeIndex, cfg) {
52
+ const origin = req.headers.origin;
53
+ applyCors(res, origin, cfg.allowedOrigins);
54
+ const method = (req.method ?? "GET").toUpperCase();
55
+ // DNS-rebinding firewall: only serve requests whose Host header is a
56
+ // loopback host bound to our actual listening port. A malicious webpage can
57
+ // rebind its hostname's DNS to 127.0.0.1, but the browser still sends the
58
+ // page's own hostname in Host — so a non-loopback Host means the request did
59
+ // not originate from a local client. We compare the Host port against the
60
+ // socket's local port (handles ephemeral `port: 0` correctly). This runs
61
+ // BEFORE CORS-exempt routes and before any body is read, so it also blocks
62
+ // whole-brain exfil via GET /memwarden/export. Applies to every method incl.
63
+ // OPTIONS.
64
+ const localPort = req.socket.localPort;
65
+ if (!isLoopbackHost(req.headers.host, localPort)) {
66
+ sendJson(res, 403, undefined, { error: "forbidden_host" });
67
+ return;
68
+ }
69
+ // CORS preflight.
70
+ if (method === "OPTIONS") {
71
+ res.statusCode = 204;
72
+ res.end();
73
+ return;
74
+ }
75
+ const url = new URL(req.url ?? "/", "http://localhost");
76
+ const pathname = url.pathname;
77
+ const route = routeIndex.get(routeKey(method, pathname));
78
+ if (!route) {
79
+ sendJson(res, 404, undefined, { error: "not_found", path: pathname });
80
+ return;
81
+ }
82
+ // Require JSON for body-bearing methods BEFORE parsing. A cross-origin
83
+ // text/plain POST is a "simple request" that skips the CORS preflight, so
84
+ // without this a webpage could POST to /observe or /import (memory
85
+ // poisoning). Demanding application/json forces a preflight the browser
86
+ // will block. Bodyless POST/PUT (e.g. action triggers) still pass.
87
+ if (method === "POST" || method === "PUT") {
88
+ if (hasRequestBody(req) && !isJsonContentType(req.headers["content-type"])) {
89
+ sendJson(res, 415, undefined, {
90
+ error: "unsupported_media_type",
91
+ message: "Content-Type must be application/json",
92
+ });
93
+ return;
94
+ }
95
+ }
96
+ const headers = normalizeHeaders(req.headers);
97
+ const queryParams = queryToRecord(url);
98
+ // Middleware chain (auth). Short-circuits on `respond`.
99
+ const short = await kernel.runMiddleware(route.middlewareFunctionIds, headers);
100
+ if (short) {
101
+ sendJson(res, short.status_code, undefined, short.body);
102
+ return;
103
+ }
104
+ // Parse the JSON body for methods that carry one.
105
+ let body;
106
+ if (method === "POST" || method === "PUT" || method === "DELETE") {
107
+ const parsed = await readJsonBody(req, cfg.maxBodyBytes);
108
+ if (parsed.error) {
109
+ sendJson(res, parsed.status, undefined, { error: parsed.error });
110
+ return;
111
+ }
112
+ body = parsed.value;
113
+ }
114
+ const apiRequest = { headers, query_params: queryParams };
115
+ if (body !== undefined)
116
+ apiRequest.body = body;
117
+ const response = await kernel.invokeHttp(route.functionId, apiRequest);
118
+ sendJson(res, response.status_code, response.headers, response.body);
119
+ }
120
+ // --- helpers --------------------------------------------------------
121
+ function routeKey(method, path) {
122
+ return `${method} ${path}`;
123
+ }
124
+ /**
125
+ * Accept only a loopback Host header bound to our port (or with no port). The
126
+ * Host header is case-insensitive and may carry `:port` or be a bracketed IPv6
127
+ * literal; we split host/port robustly and reject anything non-loopback. This
128
+ * is the DNS-rebinding guard: the value reflects the hostname the client
129
+ * actually targeted, which a rebinding attacker cannot forge to "localhost".
130
+ */
131
+ export function isLoopbackHost(hostHeader, port) {
132
+ if (typeof hostHeader !== "string" || hostHeader.trim() === "")
133
+ return false;
134
+ const raw = hostHeader.trim();
135
+ let hostname;
136
+ let portPart;
137
+ if (raw.startsWith("[")) {
138
+ // Bracketed IPv6: [::1] or [::1]:3111
139
+ const close = raw.indexOf("]");
140
+ if (close === -1)
141
+ return false;
142
+ hostname = raw.slice(1, close);
143
+ const after = raw.slice(close + 1);
144
+ portPart = after.startsWith(":") ? after.slice(1) : after || undefined;
145
+ }
146
+ else {
147
+ const idx = raw.lastIndexOf(":");
148
+ if (idx === -1) {
149
+ hostname = raw;
150
+ }
151
+ else {
152
+ hostname = raw.slice(0, idx);
153
+ portPart = raw.slice(idx + 1);
154
+ }
155
+ }
156
+ const h = hostname.toLowerCase();
157
+ const loopback = h === "localhost" || h === "::1" || h === "127.0.0.1" || /^127\.\d{1,3}\.\d{1,3}\.\d{1,3}$/.test(h);
158
+ if (!loopback)
159
+ return false;
160
+ // A present port must match ours; absent port is allowed. If we can't
161
+ // determine our own port (port undefined), accept any numeric port on a
162
+ // loopback hostname — the hostname check already carries the guarantee.
163
+ if (portPart !== undefined && portPart !== "") {
164
+ if (!/^\d+$/.test(portPart))
165
+ return false;
166
+ if (port !== undefined && Number(portPart) !== port)
167
+ return false;
168
+ }
169
+ return true;
170
+ }
171
+ function isJsonContentType(contentType) {
172
+ if (typeof contentType !== "string")
173
+ return false;
174
+ // Strip any parameters (charset, boundary) and lowercase the media type.
175
+ const media = contentType.split(";")[0]?.trim().toLowerCase() ?? "";
176
+ return media === "application/json";
177
+ }
178
+ // A request carries a body if it advertises one. Bodyless POST/PUT (no
179
+ // content-length, no chunked transfer-encoding) are exempt from the
180
+ // content-type requirement.
181
+ function hasRequestBody(req) {
182
+ const len = req.headers["content-length"];
183
+ if (typeof len === "string" && /^\d+$/.test(len) && Number(len) > 0)
184
+ return true;
185
+ const te = req.headers["transfer-encoding"];
186
+ if (typeof te === "string" && te.toLowerCase().includes("chunked"))
187
+ return true;
188
+ return false;
189
+ }
190
+ function applyCors(res, origin, allowedOrigins) {
191
+ if (origin && allowedOrigins.includes(origin)) {
192
+ res.setHeader("Access-Control-Allow-Origin", origin);
193
+ }
194
+ res.setHeader("Access-Control-Allow-Methods", ALLOWED_METHODS);
195
+ res.setHeader("Access-Control-Allow-Headers", ALLOWED_HEADERS);
196
+ res.setHeader("Vary", "Origin");
197
+ }
198
+ function normalizeHeaders(raw) {
199
+ const out = {};
200
+ for (const [k, v] of Object.entries(raw)) {
201
+ out[k] = Array.isArray(v) ? v.join(", ") : v;
202
+ }
203
+ return out;
204
+ }
205
+ function queryToRecord(url) {
206
+ const out = {};
207
+ for (const [k, v] of url.searchParams)
208
+ out[k] = v;
209
+ return out;
210
+ }
211
+ function readJsonBody(req, maxBytes) {
212
+ return new Promise((resolve) => {
213
+ const chunks = [];
214
+ let total = 0;
215
+ let aborted = false;
216
+ req.on("data", (chunk) => {
217
+ if (aborted)
218
+ return;
219
+ total += chunk.length;
220
+ if (total > maxBytes) {
221
+ aborted = true;
222
+ resolve({ error: "payload_too_large", status: 413 });
223
+ req.destroy();
224
+ return;
225
+ }
226
+ chunks.push(chunk);
227
+ });
228
+ req.on("end", () => {
229
+ if (aborted)
230
+ return;
231
+ if (chunks.length === 0) {
232
+ resolve({ value: {}, status: 200 });
233
+ return;
234
+ }
235
+ const text = Buffer.concat(chunks).toString("utf-8").trim();
236
+ if (!text) {
237
+ resolve({ value: {}, status: 200 });
238
+ return;
239
+ }
240
+ try {
241
+ resolve({ value: JSON.parse(text), status: 200 });
242
+ }
243
+ catch {
244
+ resolve({ error: "invalid_json", status: 400 });
245
+ }
246
+ });
247
+ req.on("error", () => {
248
+ if (!aborted)
249
+ resolve({ error: "request_error", status: 400 });
250
+ });
251
+ });
252
+ }
253
+ function sendJson(res, status, headers, body) {
254
+ if (headers) {
255
+ for (const [k, v] of Object.entries(headers))
256
+ res.setHeader(k, v);
257
+ }
258
+ res.setHeader("Content-Type", "application/json");
259
+ res.statusCode = status;
260
+ res.end(JSON.stringify(body ?? null));
261
+ }
@@ -0,0 +1,19 @@
1
+ import type { VoidAction } from "./types.js";
2
+ export { registerWorker, Kernel, __resetKernelSingleton } from "./kernel.js";
3
+ export type { HttpRoute, KernelOptions } from "./kernel.js";
4
+ export { startHttpServer } from "./http.js";
5
+ export type { HttpServerOptions, RunningHttpServer } from "./http.js";
6
+ export { PubSub } from "./pubsub.js";
7
+ export type { StreamItem } from "./pubsub.js";
8
+ export { TriggerError } from "./types.js";
9
+ export type { ApiRequest, ApiResponse, ConnectionStateListener, Counter, FunctionHandler, Histogram, HttpMethod, ISdk, Meter, MiddlewareResult, RegisterWorkerOptions, StateChangeEvent, TriggerConfig, TriggerOptions, VoidAction, } from "./types.js";
10
+ /**
11
+ * Fire-and-forget trigger sentinel. App code does
12
+ * `import { TriggerAction } from "<kernel>"` and calls
13
+ * `TriggerAction.Void()` to mark a trigger whose result is not awaited.
14
+ * `trigger` recognizes the `{ __void: true }` sentinel and swallows any
15
+ * rejection so a fire-and-forget failure never crashes the process.
16
+ */
17
+ export declare const TriggerAction: {
18
+ readonly Void: () => VoidAction;
19
+ };
@@ -0,0 +1,21 @@
1
+ //
2
+ // Public barrel for the memwarden kernel. This is the module that
3
+ // is the in-process runtime app code builds against: it exports the
4
+ // `registerWorker` factory, the `TriggerAction` value, and the
5
+ // `ISdk` / `ApiRequest` types the call sites import.
6
+ export { registerWorker, Kernel, __resetKernelSingleton } from "./kernel.js";
7
+ export { startHttpServer } from "./http.js";
8
+ export { PubSub } from "./pubsub.js";
9
+ export { TriggerError } from "./types.js";
10
+ /**
11
+ * Fire-and-forget trigger sentinel. App code does
12
+ * `import { TriggerAction } from "<kernel>"` and calls
13
+ * `TriggerAction.Void()` to mark a trigger whose result is not awaited.
14
+ * `trigger` recognizes the `{ __void: true }` sentinel and swallows any
15
+ * rejection so a fire-and-forget failure never crashes the process.
16
+ */
17
+ export const TriggerAction = {
18
+ Void() {
19
+ return { __void: true };
20
+ },
21
+ };
@@ -0,0 +1,80 @@
1
+ import { type ApiResponse, type ConnectionStateListener, type FunctionHandler, type HttpMethod, type ISdk, type Meter, type RegisterWorkerOptions, type TriggerConfig, type TriggerOptions } from "./types.js";
2
+ import { PubSub } from "./pubsub.js";
3
+ import type { StateStore } from "../state/store.js";
4
+ /** A resolved HTTP route the server can dispatch against. */
5
+ export interface HttpRoute {
6
+ method: HttpMethod;
7
+ path: string;
8
+ functionId: string;
9
+ middlewareFunctionIds: string[];
10
+ }
11
+ export interface KernelOptions {
12
+ /**
13
+ * The persistence store the five `state::*` ids route to. Defaults to
14
+ * an in-memory StoreMemory; the boot path injects a durable
15
+ * StoreLibsql.
16
+ */
17
+ store?: StateStore;
18
+ }
19
+ export declare class Kernel implements ISdk {
20
+ readonly workerName: string;
21
+ private readonly functions;
22
+ private readonly httpRoutes;
23
+ private readonly stateTriggers;
24
+ private readonly connectionStateListeners;
25
+ private readonly store;
26
+ private readonly unsubscribeMutations;
27
+ private readonly pubsub;
28
+ private shuttingDown;
29
+ private lastSwallowedLogAt;
30
+ constructor(opts: RegisterWorkerOptions, kernelOpts?: KernelOptions);
31
+ registerFunction(id: string, handler: FunctionHandler): void;
32
+ registerTrigger(cfg: TriggerConfig): void;
33
+ on(event: "connection_state", cb: ConnectionStateListener): void;
34
+ getMeter(_name: string): Meter;
35
+ trigger<P = any, R = any>(opts: TriggerOptions<P>): Promise<R>;
36
+ /**
37
+ * Resolve a function_id to a result. Built-in ids are routed here
38
+ * before consulting the app registry. Unregistered ids reject with a
39
+ * TriggerError carrying { code, function_id, message }.
40
+ */
41
+ private invoke;
42
+ /**
43
+ * Route engine-provided built-ins: the five `state::*` ops, the
44
+ * `stream::*` surface, and `engine::workers::list`. Returns the
45
+ * sentinel NOT_BUILTIN for everything else. State-change events are
46
+ * emitted by the store (via onMutation), not here, so set/update/delete
47
+ * stay a single store call.
48
+ */
49
+ private routeBuiltin;
50
+ /**
51
+ * Fan a store mutation event out to any `type:"state"` trigger bound
52
+ * to that scope. The original only ever subscribed `KV.sessions`, but
53
+ * this generically dispatches for any subscribed scope.
54
+ */
55
+ private dispatchStateChange;
56
+ /** Snapshot of registered HTTP routes. */
57
+ getHttpRoutes(): readonly HttpRoute[];
58
+ /**
59
+ * Run an ordered middleware chain. Returns the short-circuit response
60
+ * if any middleware responds, else null to proceed.
61
+ */
62
+ runMiddleware(middlewareFunctionIds: string[], headers: Record<string, string | undefined>): Promise<{
63
+ status_code: number;
64
+ body: unknown;
65
+ } | null>;
66
+ /** Invoke an HTTP-bound function and return its ApiResponse. */
67
+ invokeHttp(functionId: string, request: {
68
+ body?: unknown;
69
+ headers?: Record<string, string | undefined>;
70
+ query_params?: Record<string, string>;
71
+ }): Promise<ApiResponse>;
72
+ get streams(): PubSub;
73
+ /** The underlying state store (exposed for StateKV construction etc.). */
74
+ get stateStore(): StateStore;
75
+ shutdown(): Promise<void>;
76
+ private logSwallowed;
77
+ }
78
+ export declare function registerWorker(_engineUrl: string, opts: RegisterWorkerOptions, kernelOpts?: KernelOptions): Kernel;
79
+ /** Test helper: drop the singleton so a fresh kernel can be built. */
80
+ export declare function __resetKernelSingleton(): void;
@@ -0,0 +1,297 @@
1
+ //
2
+ // The memwarden kernel: an in-process worker runtime
3
+ // worker runtime. It owns
4
+ // - the function registry (Map<id, handler>),
5
+ // - the single `trigger` dispatch chokepoint (including the built-in
6
+ // `state::*`, `stream::*`, and `engine::workers::list` ids that the
7
+ // engine, not app code, used to provide),
8
+ // - `registerTrigger` wiring to HTTP / durable-subscriber / state
9
+ // surfaces,
10
+ // - the optional `on("connection_state")` and `getMeter` shims,
11
+ // - the `shutdown` lifecycle.
12
+ //
13
+ // Persistence lives behind the STATE layer's `StateStore` abstraction
14
+ // (../state/store.ts). The kernel routes the five `state::*`
15
+ // function_ids to that store and drives any registered `type:"state"`
16
+ // trigger from the store's mutation events. The kernel does NOT carry
17
+ // its own KV implementation.
18
+ //
19
+ // HTTP serving is delegated to the router in ./http.ts; the kernel just
20
+ // collects route registrations and exposes them.
21
+ import { TriggerError, } from "./types.js";
22
+ import { PubSub } from "./pubsub.js";
23
+ import { StoreMemory } from "../state/store-memory.js";
24
+ const NOOP_METER = {
25
+ createCounter: () => ({ add: () => { } }),
26
+ createHistogram: () => ({ record: () => { } }),
27
+ };
28
+ export class Kernel {
29
+ workerName;
30
+ functions = new Map();
31
+ httpRoutes = [];
32
+ stateTriggers = new Map(); // scope -> functionIds
33
+ connectionStateListeners = [];
34
+ store;
35
+ unsubscribeMutations;
36
+ pubsub = new PubSub();
37
+ shuttingDown = false;
38
+ lastSwallowedLogAt = 0;
39
+ constructor(opts, kernelOpts = {}) {
40
+ this.workerName = opts.workerName;
41
+ this.store = kernelOpts.store ?? new StoreMemory();
42
+ // Drive registered type:"state" triggers from the store's mutation
43
+ // events. The store emits {scope, key, event_type, old/new_value};
44
+ // we fan it out to any function bound to that scope.
45
+ this.unsubscribeMutations = this.store.onMutation((event) => this.dispatchStateChange(event));
46
+ }
47
+ // --- registration -------------------------------------------------
48
+ registerFunction(id, handler) {
49
+ // Last-write-wins; ids are unique in practice.
50
+ this.functions.set(id, handler);
51
+ }
52
+ registerTrigger(cfg) {
53
+ switch (cfg.type) {
54
+ case "http": {
55
+ this.httpRoutes.push({
56
+ method: cfg.config.http_method,
57
+ path: cfg.config.api_path,
58
+ functionId: cfg.function_id,
59
+ middlewareFunctionIds: cfg.config.middleware_function_ids ?? [],
60
+ });
61
+ break;
62
+ }
63
+ case "durable:subscriber": {
64
+ const fnId = cfg.function_id;
65
+ this.pubsub.subscribe(cfg.config.topic, (payload) => {
66
+ // Subscriber invocation is fire-and-forget; never crash.
67
+ void this.invoke(fnId, payload).catch((err) => this.logSwallowed("durable:subscriber", fnId, err));
68
+ });
69
+ break;
70
+ }
71
+ case "state": {
72
+ const list = this.stateTriggers.get(cfg.config.scope) ?? [];
73
+ list.push(cfg.function_id);
74
+ this.stateTriggers.set(cfg.config.scope, list);
75
+ break;
76
+ }
77
+ }
78
+ }
79
+ on(event, cb) {
80
+ if (event === "connection_state") {
81
+ this.connectionStateListeners.push(cb);
82
+ // In-process kernel is "connected" the moment it exists. Fire on
83
+ // the next tick so listeners registered synchronously after
84
+ // construction still observe it.
85
+ queueMicrotask(() => cb("connected"));
86
+ }
87
+ }
88
+ getMeter(_name) {
89
+ // No real OTel transport in-process; hand back no-op instruments.
90
+ // The boot path feature-detects this and falls back to NOOP anyway,
91
+ // but providing it keeps the call site identical.
92
+ return NOOP_METER;
93
+ }
94
+ // --- dispatch -----------------------------------------------------
95
+ async trigger(opts) {
96
+ const isVoid = !!opts.action?.__void;
97
+ if (isVoid) {
98
+ // Fire-and-forget: never reject toward the caller (many call
99
+ // sites invoke without await, as a bare statement). Run the
100
+ // handler and swallow/log any rejection.
101
+ void this.invoke(opts.function_id, opts.payload).catch((err) => this.logSwallowed("trigger:void", opts.function_id, err));
102
+ return undefined;
103
+ }
104
+ return this.invoke(opts.function_id, opts.payload);
105
+ }
106
+ /**
107
+ * Resolve a function_id to a result. Built-in ids are routed here
108
+ * before consulting the app registry. Unregistered ids reject with a
109
+ * TriggerError carrying { code, function_id, message }.
110
+ */
111
+ async invoke(functionId, payload) {
112
+ const builtin = await this.routeBuiltin(functionId, payload);
113
+ if (builtin !== NOT_BUILTIN)
114
+ return builtin;
115
+ const handler = this.functions.get(functionId);
116
+ if (!handler) {
117
+ throw new TriggerError(`No function registered for "${functionId}"`, "FUNCTION_NOT_FOUND", functionId);
118
+ }
119
+ return (await handler(payload));
120
+ }
121
+ /**
122
+ * Route engine-provided built-ins: the five `state::*` ops, the
123
+ * `stream::*` surface, and `engine::workers::list`. Returns the
124
+ * sentinel NOT_BUILTIN for everything else. State-change events are
125
+ * emitted by the store (via onMutation), not here, so set/update/delete
126
+ * stay a single store call.
127
+ */
128
+ async routeBuiltin(functionId, payload) {
129
+ switch (functionId) {
130
+ case "state::get": {
131
+ const p = payload;
132
+ return (await this.store.get(p.scope, p.key));
133
+ }
134
+ case "state::set": {
135
+ const p = payload;
136
+ return (await this.store.set(p.scope, p.key, p.value));
137
+ }
138
+ case "state::update": {
139
+ const p = payload;
140
+ return (await this.store.update(p.scope, p.key, p.ops));
141
+ }
142
+ case "state::delete": {
143
+ const p = payload;
144
+ await this.store.delete(p.scope, p.key);
145
+ return undefined;
146
+ }
147
+ case "state::list": {
148
+ const p = payload;
149
+ return (await this.store.list(p.scope));
150
+ }
151
+ case "state::verify": {
152
+ // Tamper-evidence: verify the whole oplog hash chain. Read-only.
153
+ return (await this.store.verifyOplog());
154
+ }
155
+ case "state::oplog-count": {
156
+ const entries = await this.store.readOplog();
157
+ return { count: entries.length };
158
+ }
159
+ case "state::oplog-find": {
160
+ // Chain evidence for one key (delete receipts). Payloads are
161
+ // STRIPPED — a receipt proves an entry existed in the chain without
162
+ // re-disclosing the content that was just deleted.
163
+ const p = payload;
164
+ const entries = await this.store.readOplog();
165
+ const matches = entries
166
+ .filter((e) => e.key === p.key)
167
+ .map((e) => ({
168
+ id: e.id,
169
+ ts: e.ts,
170
+ op: e.op,
171
+ scope: e.scope,
172
+ key: e.key,
173
+ hash: e.hash,
174
+ prev_hash: e.prev_hash,
175
+ }));
176
+ return { entries: matches };
177
+ }
178
+ case "stream::set":
179
+ case "stream::send": {
180
+ // Live-viewer surface. Best-effort fan-out to in-process
181
+ // listeners; no durable backing.
182
+ this.pubsub.emitStream(payload ?? {});
183
+ return undefined;
184
+ }
185
+ case "engine::workers::list": {
186
+ // Engine-internal in the original; the kernel is the only
187
+ // worker. Health monitor reads `.workers`.
188
+ return { workers: [] };
189
+ }
190
+ default:
191
+ return NOT_BUILTIN;
192
+ }
193
+ }
194
+ /**
195
+ * Fan a store mutation event out to any `type:"state"` trigger bound
196
+ * to that scope. The original only ever subscribed `KV.sessions`, but
197
+ * this generically dispatches for any subscribed scope.
198
+ */
199
+ dispatchStateChange(event) {
200
+ const fnIds = this.stateTriggers.get(event.scope);
201
+ if (!fnIds || fnIds.length === 0)
202
+ return;
203
+ const payload = {
204
+ key: event.key,
205
+ event_type: event.event_type,
206
+ ...(event.old_value !== undefined ? { old_value: event.old_value } : {}),
207
+ ...(event.new_value !== undefined ? { new_value: event.new_value } : {}),
208
+ };
209
+ for (const fnId of fnIds) {
210
+ // State-change handlers are fire-and-forget side effects.
211
+ void this.invoke(fnId, payload).catch((err) => this.logSwallowed("state-change", fnId, err));
212
+ }
213
+ }
214
+ // --- HTTP surface (consumed by ./http.ts) -------------------------
215
+ /** Snapshot of registered HTTP routes. */
216
+ getHttpRoutes() {
217
+ return this.httpRoutes;
218
+ }
219
+ /**
220
+ * Run an ordered middleware chain. Returns the short-circuit response
221
+ * if any middleware responds, else null to proceed.
222
+ */
223
+ async runMiddleware(middlewareFunctionIds, headers) {
224
+ for (const id of middlewareFunctionIds) {
225
+ const handler = this.functions.get(id);
226
+ if (!handler)
227
+ continue; // Missing middleware = open (no-op).
228
+ const result = (await handler({ request: { headers } }));
229
+ if (result && result.action === "respond") {
230
+ return result.response;
231
+ }
232
+ }
233
+ return null;
234
+ }
235
+ /** Invoke an HTTP-bound function and return its ApiResponse. */
236
+ async invokeHttp(functionId, request) {
237
+ const handler = this.functions.get(functionId);
238
+ if (!handler) {
239
+ return {
240
+ status_code: 500,
241
+ body: { error: `No handler for ${functionId}` },
242
+ };
243
+ }
244
+ return (await handler(request));
245
+ }
246
+ // --- pub/sub passthrough (for the viewer / external wiring) -------
247
+ get streams() {
248
+ return this.pubsub;
249
+ }
250
+ /** The underlying state store (exposed for StateKV construction etc.). */
251
+ get stateStore() {
252
+ return this.store;
253
+ }
254
+ // --- lifecycle ----------------------------------------------------
255
+ async shutdown() {
256
+ if (this.shuttingDown)
257
+ return;
258
+ this.shuttingDown = true;
259
+ this.unsubscribeMutations();
260
+ for (const cb of this.connectionStateListeners) {
261
+ try {
262
+ cb("disconnected");
263
+ }
264
+ catch {
265
+ /* ignore */
266
+ }
267
+ }
268
+ await this.store.close().catch(() => undefined);
269
+ }
270
+ // --- internals ----------------------------------------------------
271
+ logSwallowed(context, functionId, err) {
272
+ const now = Date.now();
273
+ // Throttle to avoid spamming on bursts of fire-and-forget failures.
274
+ if (now - this.lastSwallowedLogAt < 60_000)
275
+ return;
276
+ this.lastSwallowedLogAt = now;
277
+ const message = err instanceof Error ? err.message : String(err);
278
+ console.warn(`[memwarden] swallowed ${context} rejection (${functionId}): ${message}`);
279
+ }
280
+ }
281
+ const NOT_BUILTIN = Symbol("not-builtin");
282
+ /**
283
+ * Factory matching the daemon factory entrypoint. The kernel is a
284
+ * process singleton: repeated calls return the same instance so every
285
+ * module that imports `registerWorker` shares one registry + store.
286
+ */
287
+ let singleton = null;
288
+ export function registerWorker(_engineUrl, opts, kernelOpts) {
289
+ if (!singleton) {
290
+ singleton = new Kernel(opts, kernelOpts ?? {});
291
+ }
292
+ return singleton;
293
+ }
294
+ /** Test helper: drop the singleton so a fresh kernel can be built. */
295
+ export function __resetKernelSingleton() {
296
+ singleton = null;
297
+ }