@mnexium/core 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,1065 @@
1
+ #!/usr/bin/env node
2
+ import "dotenv/config";
3
+ import { createServer } from "node:http";
4
+ import { randomUUID } from "node:crypto";
5
+ import { URL } from "node:url";
6
+ import { readFileSync } from "node:fs";
7
+ import { dirname, join } from "node:path";
8
+ import { fileURLToPath } from "node:url";
9
+ import { Pool } from "pg";
10
+ import { getSystemStatus, runCoreRouteSuite } from "./e2e.lib.mjs";
11
+
12
+ const WEB_PORT = Number(process.env.CORE_E2E_WEB_PORT || 8091);
13
+ const WEB_HOST = String(process.env.CORE_E2E_WEB_HOST || "127.0.0.1").trim();
14
+
15
+ const defaults = {
16
+ baseUrl: process.env.CORE_E2E_BASE_URL || `http://127.0.0.1:${process.env.PORT || 8080}`,
17
+ projectId: process.env.CORE_E2E_PROJECT_ID || process.env.CORE_DEFAULT_PROJECT_ID || "default-project",
18
+ subjectId: process.env.CORE_E2E_SUBJECT_ID || "user_web_e2e",
19
+ db: {
20
+ host: process.env.CORE_E2E_WEB_DB_HOST || "127.0.0.1",
21
+ port: Number(process.env.CORE_E2E_WEB_DB_PORT || 5432),
22
+ database: process.env.CORE_E2E_WEB_DB_NAME || "mnexium_core",
23
+ user: process.env.CORE_E2E_WEB_DB_USER || "mnexium",
24
+ password: process.env.CORE_E2E_WEB_DB_PASSWORD || "mnexium_dev_password",
25
+ },
26
+ };
27
+
28
+ const ROUTE_CATALOG = [
29
+ {
30
+ id: "health",
31
+ name: "Health",
32
+ method: "GET",
33
+ path: "/health",
34
+ description: "Service liveness check.",
35
+ useProjectHeader: false,
36
+ example: { pathParams: {}, query: {}, body: {} },
37
+ },
38
+ {
39
+ id: "events_memories",
40
+ name: "Memories SSE",
41
+ method: "GET",
42
+ path: "/api/v1/events/memories",
43
+ description: "Subscribe to SSE memory events.",
44
+ isSse: true,
45
+ example: { pathParams: {}, query: { subject_id: "user_web_e2e" }, body: {} },
46
+ },
47
+ {
48
+ id: "memories_list",
49
+ name: "List Memories",
50
+ method: "GET",
51
+ path: "/api/v1/memories",
52
+ description: "List memories by subject.",
53
+ example: { pathParams: {}, query: { subject_id: "user_web_e2e", limit: 25, offset: 0 }, body: {} },
54
+ },
55
+ {
56
+ id: "memories_search",
57
+ name: "Search Memories",
58
+ method: "GET",
59
+ path: "/api/v1/memories/search",
60
+ description: "Search memories for a subject.",
61
+ example: { pathParams: {}, query: { subject_id: "user_web_e2e", q: "favorite color", limit: 10 }, body: {} },
62
+ },
63
+ {
64
+ id: "memories_create",
65
+ name: "Create Memory",
66
+ method: "POST",
67
+ path: "/api/v1/memories",
68
+ description: "Create a memory.",
69
+ example: {
70
+ pathParams: {},
71
+ query: {},
72
+ body: {
73
+ subject_id: "user_web_e2e",
74
+ text: "My favorite color is blue",
75
+ kind: "fact",
76
+ extract_claims: true,
77
+ no_supersede: false,
78
+ },
79
+ },
80
+ },
81
+ {
82
+ id: "memories_extract",
83
+ name: "Extract Memories",
84
+ method: "POST",
85
+ path: "/api/v1/memories/extract",
86
+ description: "Extract memories from text.",
87
+ example: {
88
+ pathParams: {},
89
+ query: {},
90
+ body: { subject_id: "user_web_e2e", text: "I work at Acme", learn: true },
91
+ },
92
+ },
93
+ {
94
+ id: "memory_get",
95
+ name: "Get Memory",
96
+ method: "GET",
97
+ path: "/api/v1/memories/:id",
98
+ description: "Get memory by id.",
99
+ example: { pathParams: { id: "__last_memory_id__" }, query: {}, body: {} },
100
+ },
101
+ {
102
+ id: "memory_claims",
103
+ name: "Get Memory Claims",
104
+ method: "GET",
105
+ path: "/api/v1/memories/:id/claims",
106
+ description: "Get claims extracted from a memory.",
107
+ example: { pathParams: { id: "__last_memory_id__" }, query: {}, body: {} },
108
+ },
109
+ {
110
+ id: "memory_patch",
111
+ name: "Update Memory",
112
+ method: "PATCH",
113
+ path: "/api/v1/memories/:id",
114
+ description: "Patch memory fields.",
115
+ example: {
116
+ pathParams: { id: "__last_memory_id__" },
117
+ query: {},
118
+ body: { text: "My favorite color is blue (updated)" },
119
+ },
120
+ },
121
+ {
122
+ id: "memory_delete",
123
+ name: "Delete Memory",
124
+ method: "DELETE",
125
+ path: "/api/v1/memories/:id",
126
+ description: "Soft-delete a memory.",
127
+ example: { pathParams: { id: "__last_memory_id__" }, query: {}, body: {} },
128
+ },
129
+ {
130
+ id: "memories_superseded",
131
+ name: "List Superseded Memories",
132
+ method: "GET",
133
+ path: "/api/v1/memories/superseded",
134
+ description: "List superseded memories for subject.",
135
+ example: { pathParams: {}, query: { subject_id: "user_web_e2e", limit: 25, offset: 0 }, body: {} },
136
+ },
137
+ {
138
+ id: "memory_restore",
139
+ name: "Restore Memory",
140
+ method: "POST",
141
+ path: "/api/v1/memories/:id/restore",
142
+ description: "Restore a superseded memory.",
143
+ example: { pathParams: { id: "__last_memory_id__" }, query: {}, body: {} },
144
+ },
145
+ {
146
+ id: "memories_recalls",
147
+ name: "Memory Recalls",
148
+ method: "GET",
149
+ path: "/api/v1/memories/recalls",
150
+ description: "Get recall events by chat_id or memory_id.",
151
+ example: { pathParams: {}, query: { memory_id: "__last_memory_id__", limit: 25 }, body: {} },
152
+ },
153
+ {
154
+ id: "claim_create",
155
+ name: "Create Claim",
156
+ method: "POST",
157
+ path: "/api/v1/claims",
158
+ description: "Create a claim for subject.",
159
+ example: {
160
+ pathParams: {},
161
+ query: {},
162
+ body: { subject_id: "user_web_e2e", predicate: "favorite_color", object_value: "blue" },
163
+ },
164
+ },
165
+ {
166
+ id: "claim_get",
167
+ name: "Get Claim",
168
+ method: "GET",
169
+ path: "/api/v1/claims/:id",
170
+ description: "Get claim details.",
171
+ example: { pathParams: { id: "__last_claim_id__" }, query: {}, body: {} },
172
+ },
173
+ {
174
+ id: "claim_retract",
175
+ name: "Retract Claim",
176
+ method: "POST",
177
+ path: "/api/v1/claims/:id/retract",
178
+ description: "Retract claim and restore previous slot winner.",
179
+ example: { pathParams: { id: "__last_claim_id__" }, query: {}, body: { reason: "manual_retraction" } },
180
+ },
181
+ {
182
+ id: "claims_truth",
183
+ name: "Subject Truth",
184
+ method: "GET",
185
+ path: "/api/v1/claims/subject/:subjectId/truth",
186
+ description: "Get current truth snapshot.",
187
+ example: { pathParams: { subjectId: "user_web_e2e" }, query: { include_source: true }, body: {} },
188
+ },
189
+ {
190
+ id: "claims_slot",
191
+ name: "Subject Slot",
192
+ method: "GET",
193
+ path: "/api/v1/claims/subject/:subjectId/slot/:slot",
194
+ description: "Get active value for a slot.",
195
+ example: { pathParams: { subjectId: "user_web_e2e", slot: "favorite_color" }, query: {}, body: {} },
196
+ },
197
+ {
198
+ id: "claims_slots",
199
+ name: "Subject Slots",
200
+ method: "GET",
201
+ path: "/api/v1/claims/subject/:subjectId/slots",
202
+ description: "Get grouped slot states.",
203
+ example: { pathParams: { subjectId: "user_web_e2e" }, query: { limit: 100 }, body: {} },
204
+ },
205
+ {
206
+ id: "claims_graph",
207
+ name: "Subject Claim Graph",
208
+ method: "GET",
209
+ path: "/api/v1/claims/subject/:subjectId/graph",
210
+ description: "Get claim graph for subject.",
211
+ example: { pathParams: { subjectId: "user_web_e2e" }, query: { limit: 50 }, body: {} },
212
+ },
213
+ {
214
+ id: "claims_history",
215
+ name: "Subject Claim History",
216
+ method: "GET",
217
+ path: "/api/v1/claims/subject/:subjectId/history",
218
+ description: "Get claim history timeline.",
219
+ example: { pathParams: { subjectId: "user_web_e2e" }, query: { limit: 100 }, body: {} },
220
+ },
221
+ ];
222
+
223
+ const DOCS_BASE_URL = "https://www.mnexium.com/docs";
224
+ const DOCS_SECTION_ANCHORS = {
225
+ quickstart: `${DOCS_BASE_URL}#quickstart`,
226
+ memories: `${DOCS_BASE_URL}#memories`,
227
+ claims: `${DOCS_BASE_URL}#claims`,
228
+ events: `${DOCS_BASE_URL}#events`,
229
+ };
230
+
231
+ const ROUTE_DOCS_URL_BY_ID = {
232
+ health: DOCS_SECTION_ANCHORS.quickstart,
233
+ events_memories: DOCS_SECTION_ANCHORS.events,
234
+
235
+ memories_list: DOCS_SECTION_ANCHORS.memories,
236
+ memories_search: DOCS_SECTION_ANCHORS.memories,
237
+ memories_create: DOCS_SECTION_ANCHORS.memories,
238
+ memories_extract: DOCS_SECTION_ANCHORS.memories,
239
+ memory_get: DOCS_SECTION_ANCHORS.memories,
240
+ memory_claims: DOCS_SECTION_ANCHORS.memories,
241
+ memory_patch: DOCS_SECTION_ANCHORS.memories,
242
+ memory_delete: DOCS_SECTION_ANCHORS.memories,
243
+ memories_superseded: DOCS_SECTION_ANCHORS.memories,
244
+ memory_restore: DOCS_SECTION_ANCHORS.memories,
245
+ memories_recalls: DOCS_SECTION_ANCHORS.memories,
246
+
247
+ claim_create: DOCS_SECTION_ANCHORS.claims,
248
+ claim_get: DOCS_SECTION_ANCHORS.claims,
249
+ claim_retract: DOCS_SECTION_ANCHORS.claims,
250
+ claims_truth: DOCS_SECTION_ANCHORS.claims,
251
+ claims_slot: DOCS_SECTION_ANCHORS.claims,
252
+ claims_slots: DOCS_SECTION_ANCHORS.claims,
253
+ claims_graph: DOCS_SECTION_ANCHORS.claims,
254
+ claims_history: DOCS_SECTION_ANCHORS.claims,
255
+ };
256
+
257
+ function docsUrlForRoute(route) {
258
+ const routeId = String(route?.id || "").trim();
259
+ if (routeId && ROUTE_DOCS_URL_BY_ID[routeId]) {
260
+ return ROUTE_DOCS_URL_BY_ID[routeId];
261
+ }
262
+ const path = String(route?.path || "");
263
+ if (path === "/api/v1/events/memories") return DOCS_SECTION_ANCHORS.events;
264
+ if (path.startsWith("/api/v1/memories")) return DOCS_SECTION_ANCHORS.memories;
265
+ if (path.startsWith("/api/v1/claims")) return DOCS_SECTION_ANCHORS.claims;
266
+ return DOCS_BASE_URL;
267
+ }
268
+
269
+ const runs = new Map();
270
+ let activeRunId = null;
271
+
272
+ const __dirname = dirname(fileURLToPath(import.meta.url));
273
+ const clientJs = readFileSync(join(__dirname, "e2e.webapp.client.js"), "utf8");
274
+
275
+ function sendJson(res, status, payload) {
276
+ res.statusCode = status;
277
+ res.setHeader("Content-Type", "application/json; charset=utf-8");
278
+ res.setHeader("Cache-Control", "no-store");
279
+ res.end(JSON.stringify(payload));
280
+ }
281
+
282
+ function sendHtml(res, html) {
283
+ res.statusCode = 200;
284
+ res.setHeader("Content-Type", "text/html; charset=utf-8");
285
+ res.setHeader("Cache-Control", "no-store");
286
+ res.end(html);
287
+ }
288
+
289
+ function sendJs(res, js) {
290
+ res.statusCode = 200;
291
+ res.setHeader("Content-Type", "application/javascript; charset=utf-8");
292
+ res.setHeader("Cache-Control", "no-store");
293
+ res.end(js);
294
+ }
295
+
296
+ function toErrorMessage(err) {
297
+ if (!err) return "unknown_error";
298
+ if (typeof err === "string") return err;
299
+ if (err instanceof AggregateError && Array.isArray(err.errors) && err.errors.length > 0) {
300
+ return `AggregateError: ${err.errors.map((e) => toErrorMessage(e)).join(" | ")}`;
301
+ }
302
+ if (err && typeof err === "object" && Array.isArray(err.errors) && err.errors.length > 0) {
303
+ return `AggregateError: ${err.errors.map((e) => toErrorMessage(e)).join(" | ")}`;
304
+ }
305
+ if (err && typeof err === "object" && err.cause) {
306
+ return `${String(err.message || err)} (cause: ${toErrorMessage(err.cause)})`;
307
+ }
308
+ return String(err.message || err);
309
+ }
310
+
311
+ async function readJsonBody(req) {
312
+ const chunks = [];
313
+ for await (const chunk of req) chunks.push(Buffer.from(chunk));
314
+ const raw = Buffer.concat(chunks).toString("utf8").trim();
315
+ if (!raw) return {};
316
+ try {
317
+ return JSON.parse(raw);
318
+ } catch {
319
+ return null;
320
+ }
321
+ }
322
+
323
+ function normalizeConfig(raw = {}) {
324
+ const body = raw || {};
325
+ return {
326
+ baseUrl: String(body.baseUrl || defaults.baseUrl || "").trim().replace(/\/$/, ""),
327
+ projectId: String(body.projectId || defaults.projectId || "default-project").trim(),
328
+ subjectId: String(body.subjectId || defaults.subjectId || "user_web_e2e").trim(),
329
+ db: {
330
+ host: String(body.db?.host || defaults.db.host || "127.0.0.1").trim(),
331
+ port: Number(body.db?.port || defaults.db.port || 5432),
332
+ database: String(body.db?.database || defaults.db.database || "mnexium_core").trim(),
333
+ user: String(body.db?.user || defaults.db.user || "mnexium").trim(),
334
+ password: String(body.db?.password || defaults.db.password || "").trim(),
335
+ },
336
+ };
337
+ }
338
+
339
+ function createPool(db) {
340
+ return new Pool({
341
+ host: db.host,
342
+ port: Number(db.port),
343
+ database: db.database,
344
+ user: db.user,
345
+ password: db.password,
346
+ });
347
+ }
348
+
349
+ function sanitizeRun(run) {
350
+ if (!run) return null;
351
+ return {
352
+ id: run.id,
353
+ status: run.status,
354
+ started_at: run.started_at,
355
+ ended_at: run.ended_at || null,
356
+ config: {
357
+ baseUrl: run.config.baseUrl,
358
+ projectId: run.config.projectId,
359
+ subjectId: run.config.subjectId,
360
+ db: {
361
+ ...run.config.db,
362
+ password: run.config.db.password ? "********" : "",
363
+ },
364
+ },
365
+ logs: run.logs,
366
+ result: run.result || null,
367
+ error: run.error || null,
368
+ };
369
+ }
370
+
371
+ function buildPathWithQuery(pathname, query) {
372
+ const params = new URLSearchParams();
373
+ const source = query && typeof query === "object" ? query : {};
374
+ for (const [key, value] of Object.entries(source)) {
375
+ if (value == null || value === "") continue;
376
+ if (Array.isArray(value)) {
377
+ for (const item of value) {
378
+ if (item == null || item === "") continue;
379
+ params.append(key, String(item));
380
+ }
381
+ continue;
382
+ }
383
+ params.append(key, String(value));
384
+ }
385
+ const qs = params.toString();
386
+ if (!qs) return pathname;
387
+ return pathname.includes("?") ? `${pathname}&${qs}` : `${pathname}?${qs}`;
388
+ }
389
+
390
+ async function callCore(config, opts) {
391
+ const method = String(opts.method || "GET").toUpperCase();
392
+ const pathname = String(opts.path || "/");
393
+ const useProjectHeader = opts.useProjectHeader !== false;
394
+ const fullPath = buildPathWithQuery(pathname, opts.query);
395
+
396
+ const headers = {
397
+ accept: opts.accept || "application/json",
398
+ };
399
+ if (useProjectHeader) {
400
+ headers["x-project-id"] = config.projectId;
401
+ }
402
+ const hasBody = opts.body != null && method !== "GET";
403
+ if (hasBody) {
404
+ headers["content-type"] = "application/json";
405
+ }
406
+
407
+ const response = await fetch(`${config.baseUrl}${fullPath}`, {
408
+ method,
409
+ headers,
410
+ body: hasBody ? JSON.stringify(opts.body) : undefined,
411
+ });
412
+
413
+ const text = await response.text();
414
+ let data = null;
415
+ if (text) {
416
+ try {
417
+ data = JSON.parse(text);
418
+ } catch {
419
+ data = { raw: text };
420
+ }
421
+ }
422
+
423
+ return {
424
+ ok: response.ok,
425
+ status: response.status,
426
+ path: fullPath,
427
+ data,
428
+ };
429
+ }
430
+
431
+ async function callCoreSseSnapshot(config, opts) {
432
+ const useProjectHeader = opts.useProjectHeader !== false;
433
+ const fullPath = buildPathWithQuery(String(opts.path || "/"), opts.query);
434
+ const timeoutMs = Number(opts.timeoutMs || 4000);
435
+ const controller = new AbortController();
436
+ const timeout = setTimeout(() => controller.abort(), timeoutMs);
437
+
438
+ const headers = {
439
+ accept: "text/event-stream",
440
+ };
441
+ if (useProjectHeader) {
442
+ headers["x-project-id"] = config.projectId;
443
+ }
444
+
445
+ const events = [];
446
+ try {
447
+ const response = await fetch(`${config.baseUrl}${fullPath}`, {
448
+ method: "GET",
449
+ headers,
450
+ signal: controller.signal,
451
+ });
452
+
453
+ if (!response.ok) {
454
+ const text = await response.text();
455
+ let data = null;
456
+ try {
457
+ data = text ? JSON.parse(text) : null;
458
+ } catch {
459
+ data = { raw: text };
460
+ }
461
+ return {
462
+ ok: false,
463
+ status: response.status,
464
+ path: fullPath,
465
+ data,
466
+ is_sse: true,
467
+ };
468
+ }
469
+
470
+ if (!response.body) {
471
+ return {
472
+ ok: false,
473
+ status: response.status,
474
+ path: fullPath,
475
+ data: { error: "empty_sse_body" },
476
+ is_sse: true,
477
+ };
478
+ }
479
+
480
+ const reader = response.body.getReader();
481
+ const decoder = new TextDecoder();
482
+ let buffer = "";
483
+
484
+ while (events.length < 4) {
485
+ const { value, done } = await reader.read();
486
+ if (done) break;
487
+ buffer += decoder.decode(value, { stream: true });
488
+
489
+ let splitIndex = buffer.indexOf("\n\n");
490
+ while (splitIndex !== -1) {
491
+ const block = buffer.slice(0, splitIndex);
492
+ buffer = buffer.slice(splitIndex + 2);
493
+
494
+ if (block.trim()) {
495
+ let event = "message";
496
+ let data = "";
497
+ for (const line of block.split("\n")) {
498
+ if (line.startsWith("event:")) event = line.slice(6).trim();
499
+ if (line.startsWith("data:")) data += line.slice(5).trim();
500
+ }
501
+ let parsed = null;
502
+ if (data) {
503
+ try {
504
+ parsed = JSON.parse(data);
505
+ } catch {
506
+ parsed = { raw: data };
507
+ }
508
+ }
509
+ events.push({ event, data: parsed });
510
+ }
511
+
512
+ if (events.length >= 4) break;
513
+ splitIndex = buffer.indexOf("\n\n");
514
+ }
515
+ }
516
+
517
+ return {
518
+ ok: true,
519
+ status: response.status,
520
+ path: fullPath,
521
+ is_sse: true,
522
+ data: {
523
+ event_count: events.length,
524
+ events,
525
+ },
526
+ };
527
+ } catch (err) {
528
+ if (events.length > 0) {
529
+ return {
530
+ ok: true,
531
+ status: 200,
532
+ path: fullPath,
533
+ is_sse: true,
534
+ data: {
535
+ event_count: events.length,
536
+ events,
537
+ note: "stream interrupted after receiving events",
538
+ },
539
+ };
540
+ }
541
+ return {
542
+ ok: false,
543
+ status: 500,
544
+ path: fullPath,
545
+ is_sse: true,
546
+ data: { error: toErrorMessage(err) },
547
+ };
548
+ } finally {
549
+ clearTimeout(timeout);
550
+ controller.abort();
551
+ }
552
+ }
553
+
554
+ function pageHtml() {
555
+ return `<!doctype html>
556
+ <html lang="en">
557
+ <head>
558
+ <meta charset="utf-8" />
559
+ <meta name="viewport" content="width=device-width, initial-scale=1" />
560
+ <title>Mnexium CORE Dashboard</title>
561
+ <style>
562
+ :root {
563
+ --bg: #0d1117;
564
+ --panel: #161b22;
565
+ --card: #1f2630;
566
+ --line: #2f3845;
567
+ --text: #e6edf3;
568
+ --muted: #9aa6b2;
569
+ --ok: #238636;
570
+ --bad: #da3633;
571
+ --warn: #d29922;
572
+ --accent: #2f81f7;
573
+ --accent-soft: #1f3252;
574
+ }
575
+ * { box-sizing: border-box; }
576
+ body {
577
+ margin: 0;
578
+ background: radial-gradient(1200px circle at 10% 10%, #1a2330 0%, var(--bg) 50%);
579
+ color: var(--text);
580
+ font-family: ui-sans-serif, -apple-system, Segoe UI, Helvetica, Arial, sans-serif;
581
+ }
582
+ .wrap { max-width: 1260px; margin: 24px auto; padding: 0 16px; }
583
+ .title { font-size: 28px; font-weight: 800; margin: 0 0 8px; }
584
+ .sub { color: var(--muted); margin: 0 0 16px; }
585
+ .tabs { display: flex; gap: 8px; margin-bottom: 12px; flex-wrap: wrap; }
586
+ .tab-btn {
587
+ background: var(--accent-soft);
588
+ border: 1px solid var(--line);
589
+ color: var(--text);
590
+ border-radius: 999px;
591
+ padding: 8px 14px;
592
+ cursor: pointer;
593
+ font-weight: 700;
594
+ }
595
+ .tab-btn.active { background: var(--accent); border-color: var(--accent); }
596
+ .tab-panel { display: none; }
597
+ .tab-panel.active { display: block; }
598
+ .grid { display: grid; grid-template-columns: 1fr 1fr; gap: 14px; }
599
+ .card {
600
+ background: linear-gradient(180deg, var(--panel), #121821);
601
+ border: 1px solid var(--line);
602
+ border-radius: 12px;
603
+ padding: 14px;
604
+ }
605
+ .card h3 { margin: 0 0 10px; font-size: 16px; }
606
+ label { display: block; font-size: 12px; color: var(--muted); margin: 10px 0 4px; }
607
+ input, textarea {
608
+ width: 100%;
609
+ background: var(--card);
610
+ border: 1px solid var(--line);
611
+ border-radius: 8px;
612
+ color: var(--text);
613
+ padding: 10px;
614
+ font-size: 14px;
615
+ }
616
+ textarea { min-height: 96px; resize: vertical; }
617
+ .actions { display: flex; flex-wrap: wrap; gap: 8px; margin-top: 14px; }
618
+ button {
619
+ background: var(--accent);
620
+ color: white;
621
+ border: 0;
622
+ border-radius: 8px;
623
+ padding: 10px 14px;
624
+ cursor: pointer;
625
+ font-weight: 700;
626
+ }
627
+ button.secondary { background: #344054; }
628
+ button:disabled { opacity: 0.5; cursor: not-allowed; }
629
+ .statusline {
630
+ margin: 10px 0;
631
+ padding: 8px 10px;
632
+ border-radius: 8px;
633
+ background: var(--card);
634
+ border: 1px solid var(--line);
635
+ font-size: 13px;
636
+ }
637
+ .ok { color: #3fb950; }
638
+ .bad { color: #ff7b72; }
639
+ .warn { color: #f2cc60; }
640
+ pre {
641
+ background: #0b1118;
642
+ border: 1px solid var(--line);
643
+ border-radius: 8px;
644
+ padding: 10px;
645
+ max-height: 420px;
646
+ overflow: auto;
647
+ font-size: 12px;
648
+ white-space: pre-wrap;
649
+ }
650
+ table { width: 100%; border-collapse: collapse; margin-top: 8px; font-size: 13px; }
651
+ th, td { text-align: left; border-bottom: 1px solid var(--line); padding: 6px; }
652
+ .full { margin-top: 14px; }
653
+ .routes { display: grid; grid-template-columns: 1fr; gap: 10px; }
654
+ details.route {
655
+ border: 1px solid var(--line);
656
+ border-radius: 10px;
657
+ background: #101722;
658
+ padding: 8px;
659
+ }
660
+ details.route > summary {
661
+ cursor: pointer;
662
+ list-style: none;
663
+ display: flex;
664
+ gap: 10px;
665
+ align-items: center;
666
+ font-weight: 700;
667
+ }
668
+ details.route > summary::-webkit-details-marker { display: none; }
669
+ .method {
670
+ display: inline-block;
671
+ min-width: 64px;
672
+ text-align: center;
673
+ border-radius: 999px;
674
+ padding: 2px 8px;
675
+ font-size: 11px;
676
+ font-weight: 800;
677
+ border: 1px solid var(--line);
678
+ background: #1c2635;
679
+ }
680
+ .route-body { margin-top: 10px; }
681
+ .route-params {
682
+ margin-top: 8px;
683
+ margin-bottom: 10px;
684
+ padding-bottom: 8px;
685
+ border-bottom: 1px dashed var(--line);
686
+ }
687
+ .route-no-params { margin: 0; }
688
+ .route-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(320px, 1fr)); gap: 10px; }
689
+ .route-grid > div { display: flex; flex-direction: column; }
690
+ .route-grid textarea { min-height: 160px; }
691
+ .route-no-inputs {
692
+ grid-column: 1 / -1;
693
+ margin-top: 4px;
694
+ }
695
+ .route-actions {
696
+ display: flex;
697
+ justify-content: flex-end;
698
+ gap: 8px;
699
+ margin-top: 10px;
700
+ padding-top: 8px;
701
+ border-top: 1px dashed var(--line);
702
+ }
703
+ .route-actions button {
704
+ min-width: 132px;
705
+ }
706
+ .route-result { margin-top: 8px; }
707
+ .route-doc-link {
708
+ color: #93c5fd;
709
+ font-size: 12px;
710
+ text-decoration: none;
711
+ border: 1px solid var(--line);
712
+ border-radius: 999px;
713
+ padding: 2px 8px;
714
+ background: #17253b;
715
+ display: inline-block;
716
+ }
717
+ .route-doc-link:hover {
718
+ color: #bfdbfe;
719
+ border-color: #4b5563;
720
+ }
721
+ .route-doc-wrap {
722
+ margin-top: 6px;
723
+ margin-bottom: 8px;
724
+ }
725
+ .tiny { font-size: 12px; color: var(--muted); }
726
+ @media (max-width: 980px) {
727
+ .grid { grid-template-columns: 1fr; }
728
+ .route-actions { justify-content: flex-start; }
729
+ }
730
+ </style>
731
+ </head>
732
+ <body>
733
+ <div class="wrap">
734
+ <h1 class="title">Mnexium CORE Dashboard</h1>
735
+ <p class="sub">Connection checks, route-suite runs, and per-route examples.</p>
736
+
737
+ <div class="tabs">
738
+ <button class="tab-btn active" data-tab="connection">Connection</button>
739
+ <button class="tab-btn" data-tab="routes">Routes</button>
740
+ </div>
741
+
742
+ <section id="tab-connection" class="tab-panel active">
743
+ <div class="grid">
744
+ <section class="card">
745
+ <h3>Target Config</h3>
746
+
747
+ <label>Core Base URL</label>
748
+ <input id="baseUrl" />
749
+
750
+ <label>Project ID</label>
751
+ <input id="projectId" />
752
+
753
+ <label>Subject ID</label>
754
+ <input id="subjectId" />
755
+
756
+ <label>Postgres Host</label>
757
+ <input id="dbHost" />
758
+
759
+ <label>Postgres Port</label>
760
+ <input id="dbPort" type="number" />
761
+
762
+ <label>Postgres DB</label>
763
+ <input id="dbName" />
764
+
765
+ <label>Postgres User</label>
766
+ <input id="dbUser" />
767
+
768
+ <label>Postgres Password</label>
769
+ <input id="dbPass" type="password" />
770
+
771
+ <div class="actions">
772
+ <button id="checkStatusBtn">Check Status</button>
773
+ <button id="runBtn">Run Full Route Suite</button>
774
+ <button class="secondary" id="refreshRunBtn">Refresh Active Run</button>
775
+ </div>
776
+ </section>
777
+
778
+ <section class="card">
779
+ <h3>System Status</h3>
780
+ <div id="statusLine" class="statusline">No status checked yet.</div>
781
+ <pre id="statusJson">{}</pre>
782
+ </section>
783
+ </div>
784
+
785
+ <section class="card full">
786
+ <h3>Test Run</h3>
787
+ <div id="runLine" class="statusline">No run started.</div>
788
+ <div id="stepsWrap"></div>
789
+ <pre id="runLogs">[]</pre>
790
+ </section>
791
+ </section>
792
+
793
+ <section id="tab-routes" class="tab-panel">
794
+ <section class="card">
795
+ <h3>Route Explorer</h3>
796
+ <p class="tiny">Each route has a prefilled example payload. Click <strong>Reset Inputs</strong> to restore defaults, then <strong>Run Route</strong>.</p>
797
+ <div id="routesContainer" class="routes"></div>
798
+ </section>
799
+ </section>
800
+ </div>
801
+
802
+ <script src="/app.js"></script>
803
+ </body>
804
+ </html>`;
805
+ }
806
+
807
+ const server = createServer(async (req, res) => {
808
+ const method = String(req.method || "GET").toUpperCase();
809
+ const url = new URL(req.url || "/", `http://${WEB_HOST}:${WEB_PORT}`);
810
+
811
+ if (method === "GET" && url.pathname === "/") {
812
+ sendHtml(res, pageHtml());
813
+ return;
814
+ }
815
+
816
+ if (method === "GET" && url.pathname === "/app.js") {
817
+ sendJs(res, clientJs);
818
+ return;
819
+ }
820
+
821
+ if (method === "GET" && url.pathname === "/api/defaults") {
822
+ sendJson(res, 200, { defaults });
823
+ return;
824
+ }
825
+
826
+ if (method === "GET" && url.pathname === "/api/routes") {
827
+ sendJson(res, 200, {
828
+ routes: ROUTE_CATALOG.map((route) => ({
829
+ ...route,
830
+ docsUrl: route.docsUrl || docsUrlForRoute(route),
831
+ })),
832
+ });
833
+ return;
834
+ }
835
+
836
+ if (method === "POST" && url.pathname === "/api/status") {
837
+ const body = await readJsonBody(req);
838
+ if (!body) {
839
+ sendJson(res, 400, { error: "invalid_json_body" });
840
+ return;
841
+ }
842
+
843
+ const config = normalizeConfig(body);
844
+ const pool = createPool(config.db);
845
+ try {
846
+ const status = await getSystemStatus({
847
+ baseUrl: config.baseUrl,
848
+ dbPool: pool,
849
+ projectId: config.projectId,
850
+ subjectId: config.subjectId,
851
+ });
852
+ sendJson(res, 200, { status, config: { ...config, db: { ...config.db, password: "********" } } });
853
+ } catch (err) {
854
+ sendJson(res, 500, { error: "status_check_failed", message: toErrorMessage(err) });
855
+ } finally {
856
+ await pool.end().catch(() => undefined);
857
+ }
858
+ return;
859
+ }
860
+
861
+ if (method === "POST" && url.pathname === "/api/memories/list") {
862
+ const body = await readJsonBody(req);
863
+ if (!body) {
864
+ sendJson(res, 400, { error: "invalid_json_body" });
865
+ return;
866
+ }
867
+ const config = normalizeConfig(body.config || body);
868
+ const subjectId = String(body.subjectId || config.subjectId || "").trim();
869
+ if (!subjectId) {
870
+ sendJson(res, 400, { error: "subject_id_required" });
871
+ return;
872
+ }
873
+ const result = await callCore(config, {
874
+ method: "GET",
875
+ path: "/api/v1/memories",
876
+ query: { subject_id: subjectId, limit: 50, offset: 0 },
877
+ });
878
+ sendJson(res, result.status, result);
879
+ return;
880
+ }
881
+
882
+ if (method === "POST" && url.pathname === "/api/memories/search") {
883
+ const body = await readJsonBody(req);
884
+ if (!body) {
885
+ sendJson(res, 400, { error: "invalid_json_body" });
886
+ return;
887
+ }
888
+ const config = normalizeConfig(body.config || body);
889
+ const subjectId = String(body.subjectId || config.subjectId || "").trim();
890
+ const q = String(body.q || "").trim();
891
+ if (!subjectId) {
892
+ sendJson(res, 400, { error: "subject_id_required" });
893
+ return;
894
+ }
895
+ if (!q) {
896
+ sendJson(res, 400, { error: "q_required" });
897
+ return;
898
+ }
899
+ const result = await callCore(config, {
900
+ method: "GET",
901
+ path: "/api/v1/memories/search",
902
+ query: { subject_id: subjectId, q, limit: 25 },
903
+ });
904
+ sendJson(res, result.status, result);
905
+ return;
906
+ }
907
+
908
+ if (method === "POST" && url.pathname === "/api/memories/create") {
909
+ const body = await readJsonBody(req);
910
+ if (!body) {
911
+ sendJson(res, 400, { error: "invalid_json_body" });
912
+ return;
913
+ }
914
+ const config = normalizeConfig(body.config || body);
915
+ const subjectId = String(body.subjectId || config.subjectId || "").trim();
916
+ const text = String(body.text || "").trim();
917
+ const kind = String(body.kind || "").trim();
918
+ if (!subjectId) {
919
+ sendJson(res, 400, { error: "subject_id_required" });
920
+ return;
921
+ }
922
+ if (!text) {
923
+ sendJson(res, 400, { error: "text_required" });
924
+ return;
925
+ }
926
+ const payload = { subject_id: subjectId, text, ...(kind ? { kind } : {}) };
927
+ const result = await callCore(config, {
928
+ method: "POST",
929
+ path: "/api/v1/memories",
930
+ body: payload,
931
+ });
932
+ sendJson(res, result.status, result);
933
+ return;
934
+ }
935
+
936
+ if (method === "POST" && url.pathname === "/api/route-exec") {
937
+ const body = await readJsonBody(req);
938
+ if (!body) {
939
+ sendJson(res, 400, { error: "invalid_json_body" });
940
+ return;
941
+ }
942
+
943
+ const config = normalizeConfig(body.config || body);
944
+ const route = body.route || {};
945
+ const routeMethod = String(route.method || "GET").toUpperCase();
946
+ const allowed = new Set(["GET", "POST", "PATCH", "DELETE"]);
947
+ if (!allowed.has(routeMethod)) {
948
+ sendJson(res, 400, { error: "invalid_method" });
949
+ return;
950
+ }
951
+
952
+ const routePath = String(route.path || "").trim();
953
+ if (!routePath.startsWith("/")) {
954
+ sendJson(res, 400, { error: "invalid_path" });
955
+ return;
956
+ }
957
+
958
+ try {
959
+ const result = route.isSse
960
+ ? await callCoreSseSnapshot(config, {
961
+ path: routePath,
962
+ query: route.query,
963
+ useProjectHeader: route.useProjectHeader !== false,
964
+ timeoutMs: 4000,
965
+ })
966
+ : await callCore(config, {
967
+ method: routeMethod,
968
+ path: routePath,
969
+ query: route.query,
970
+ body: route.body,
971
+ useProjectHeader: route.useProjectHeader !== false,
972
+ });
973
+ sendJson(res, 200, {
974
+ route_id: route.id || null,
975
+ method: routeMethod,
976
+ path: routePath,
977
+ result,
978
+ });
979
+ } catch (err) {
980
+ sendJson(res, 500, {
981
+ error: "route_exec_failed",
982
+ message: toErrorMessage(err),
983
+ });
984
+ }
985
+ return;
986
+ }
987
+
988
+ if (method === "POST" && url.pathname === "/api/run-tests") {
989
+ if (activeRunId) {
990
+ const active = runs.get(activeRunId);
991
+ if (active && active.status === "running") {
992
+ sendJson(res, 409, { error: "run_in_progress", run_id: activeRunId });
993
+ return;
994
+ }
995
+ }
996
+
997
+ const body = await readJsonBody(req);
998
+ if (!body) {
999
+ sendJson(res, 400, { error: "invalid_json_body" });
1000
+ return;
1001
+ }
1002
+
1003
+ const config = normalizeConfig(body);
1004
+ const runId = `run_${randomUUID()}`;
1005
+ const run = {
1006
+ id: runId,
1007
+ status: "running",
1008
+ started_at: new Date().toISOString(),
1009
+ ended_at: null,
1010
+ config,
1011
+ logs: [],
1012
+ result: null,
1013
+ error: null,
1014
+ };
1015
+
1016
+ runs.set(runId, run);
1017
+ activeRunId = runId;
1018
+
1019
+ void (async () => {
1020
+ const pool = createPool(config.db);
1021
+ try {
1022
+ const result = await runCoreRouteSuite({
1023
+ baseUrl: config.baseUrl,
1024
+ projectId: config.projectId,
1025
+ subjectId: config.subjectId,
1026
+ dbPool: pool,
1027
+ onLog: (line) => {
1028
+ run.logs.push(line);
1029
+ if (run.logs.length > 1000) run.logs.shift();
1030
+ },
1031
+ });
1032
+ run.result = result;
1033
+ run.status = result.ok ? "passed" : "failed";
1034
+ run.error = result.ok ? null : result.error || "suite_failed";
1035
+ } catch (err) {
1036
+ run.status = "failed";
1037
+ run.error = toErrorMessage(err);
1038
+ } finally {
1039
+ run.ended_at = new Date().toISOString();
1040
+ await pool.end().catch(() => undefined);
1041
+ }
1042
+ })();
1043
+
1044
+ sendJson(res, 202, { run_id: runId, status: "running" });
1045
+ return;
1046
+ }
1047
+
1048
+ const runMatch = url.pathname.match(/^\/api\/run-tests\/([^/]+)$/);
1049
+ if (method === "GET" && runMatch) {
1050
+ const id = decodeURIComponent(runMatch[1]);
1051
+ const run = runs.get(id);
1052
+ if (!run) {
1053
+ sendJson(res, 404, { error: "run_not_found" });
1054
+ return;
1055
+ }
1056
+ sendJson(res, 200, { run: sanitizeRun(run) });
1057
+ return;
1058
+ }
1059
+
1060
+ sendJson(res, 404, { error: "not_found", path: url.pathname, method });
1061
+ });
1062
+
1063
+ server.listen(WEB_PORT, WEB_HOST, () => {
1064
+ console.log(`[core:e2e:web] listening on http://${WEB_HOST}:${WEB_PORT}`);
1065
+ });