@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,1154 @@
1
+ import { createServer, type IncomingMessage, type Server, type ServerResponse } from "http";
2
+ import { randomUUID } from "crypto";
3
+ import type { CoreStore } from "../contracts/storage";
4
+ import { MemoryEventBus } from "./memoryEventBus";
5
+ import type { RecallService } from "../ai/recallService";
6
+ import { createSimpleMemoryExtractionService, type MemoryExtractionService } from "../ai/memoryExtractionService";
7
+
8
+ export interface CreateCoreServerOptions {
9
+ store: CoreStore;
10
+ defaultProjectId?: string;
11
+ resolveProjectId?: (req: IncomingMessage) => Promise<string | null> | string | null;
12
+ embed?: (text: string) => Promise<number[]>;
13
+ recallService?: RecallService;
14
+ memoryExtractionService?: MemoryExtractionService;
15
+ debug?: boolean;
16
+ }
17
+
18
+ function sendJson(res: ServerResponse, status: number, payload: Record<string, unknown>) {
19
+ res.statusCode = status;
20
+ res.setHeader("Content-Type", "application/json; charset=utf-8");
21
+ res.end(JSON.stringify(payload));
22
+ }
23
+
24
+ function parseBool(raw: string | null, fallback = false): boolean {
25
+ if (raw == null) return fallback;
26
+ return String(raw).toLowerCase() === "true";
27
+ }
28
+
29
+ function parseIntInRange(raw: string | null, fallback: number, min: number, max: number): number {
30
+ const n = Number(raw);
31
+ if (!Number.isFinite(n)) return fallback;
32
+ if (n < min) return min;
33
+ if (n > max) return max;
34
+ return Math.floor(n);
35
+ }
36
+
37
+ async function readJsonBody(req: IncomingMessage): Promise<Record<string, any> | null> {
38
+ const chunks: Buffer[] = [];
39
+ for await (const chunk of req) chunks.push(Buffer.from(chunk));
40
+ if (chunks.length === 0) return {};
41
+ const raw = Buffer.concat(chunks).toString("utf8").trim();
42
+ if (!raw) return {};
43
+ try {
44
+ return JSON.parse(raw);
45
+ } catch {
46
+ return null;
47
+ }
48
+ }
49
+
50
+ function normalizeProjectId(headerValue: string | string[] | undefined, fallback?: string): string | null {
51
+ const header = Array.isArray(headerValue) ? headerValue[0] : headerValue;
52
+ const projectId = String(header || fallback || "").trim();
53
+ return projectId || null;
54
+ }
55
+
56
+ async function resolveProjectId(req: IncomingMessage, options: CreateCoreServerOptions): Promise<string | null> {
57
+ if (options.resolveProjectId) {
58
+ const projectId = await options.resolveProjectId(req);
59
+ const normalized = String(projectId || "").trim();
60
+ if (normalized) return normalized;
61
+ }
62
+ return normalizeProjectId(req.headers["x-project-id"], options.defaultProjectId);
63
+ }
64
+
65
+ function decodePathPart(value: string): string {
66
+ try {
67
+ return decodeURIComponent(value);
68
+ } catch {
69
+ return value;
70
+ }
71
+ }
72
+
73
+ function sanitizeMemoryForApi(memory: Record<string, any>): Record<string, unknown> {
74
+ const { embedding: _embedding, is_deleted: _isDeleted, ...rest } = memory || {};
75
+ return rest;
76
+ }
77
+
78
+ function mapMemorySearchResult(memory: Record<string, any>): Record<string, unknown> {
79
+ return {
80
+ id: memory.id,
81
+ text: memory.text,
82
+ kind: memory.kind,
83
+ importance: memory.importance,
84
+ is_temporal: memory.is_temporal,
85
+ created_at: memory.created_at,
86
+ score: Number(memory.score) || 0,
87
+ effective_score: Number(memory.effective_score) || 0,
88
+ };
89
+ }
90
+
91
+ function toErrorMessage(err: unknown): string {
92
+ if (!err) return "unknown_error";
93
+ if (typeof err === "string") return err;
94
+ if (err instanceof AggregateError && Array.isArray(err.errors) && err.errors.length > 0) {
95
+ return `AggregateError: ${err.errors.map((e) => toErrorMessage(e)).join(" | ")}`;
96
+ }
97
+ if (typeof err === "object" && err && Array.isArray((err as any).errors) && (err as any).errors.length > 0) {
98
+ return `AggregateError: ${(err as any).errors.map((e: unknown) => toErrorMessage(e)).join(" | ")}`;
99
+ }
100
+ if (typeof err === "object" && err && (err as any).cause) {
101
+ return `${String((err as any).message || err)} (cause: ${toErrorMessage((err as any).cause)})`;
102
+ }
103
+ return String((err as any)?.message || err);
104
+ }
105
+
106
+ export function createCoreServer(options: CreateCoreServerOptions): Server {
107
+ const bus = new MemoryEventBus();
108
+ const extractionService = options.memoryExtractionService || createSimpleMemoryExtractionService();
109
+ const debugEnabled = options.debug === true;
110
+
111
+ function debugLog(message: string, meta?: Record<string, unknown>) {
112
+ if (!debugEnabled) return;
113
+ const ts = new Date().toISOString();
114
+ if (meta) {
115
+ console.log(`[core][debug][${ts}] ${message}`, meta);
116
+ return;
117
+ }
118
+ console.log(`[core][debug][${ts}] ${message}`);
119
+ }
120
+
121
+ return createServer(async (req, res) => {
122
+ const method = String(req.method || "GET").toUpperCase();
123
+ const url = new URL(req.url || "/", "http://localhost");
124
+ const path = url.pathname;
125
+ const startedAt = Date.now();
126
+
127
+ debugLog("request.start", {
128
+ method,
129
+ path,
130
+ query: url.searchParams.toString(),
131
+ hasProjectHeader: !!req.headers["x-project-id"],
132
+ });
133
+
134
+ if (method === "GET" && path === "/health") {
135
+ sendJson(res, 200, { ok: true, service: "mnexium-core", timestamp: new Date().toISOString() });
136
+ debugLog("request.end", { method, path, status: 200, duration_ms: Date.now() - startedAt });
137
+ return;
138
+ }
139
+
140
+ const projectId = await resolveProjectId(req, options);
141
+ if (!projectId) {
142
+ sendJson(res, 400, { error: "project_id_required", message: "Provide x-project-id header or configure defaultProjectId" });
143
+ debugLog("request.end", { method, path, status: 400, error: "project_id_required", duration_ms: Date.now() - startedAt });
144
+ return;
145
+ }
146
+ debugLog("project.resolved", { projectId, method, path });
147
+
148
+ try {
149
+ // ------------------------------------------------------------------
150
+ // SSE Events
151
+ // ------------------------------------------------------------------
152
+ if (method === "GET" && path === "/api/v1/events/memories") {
153
+ const subjectId = String(url.searchParams.get("subject_id") || "").trim() || null;
154
+ debugLog("sse.subscribe", { projectId, subjectId });
155
+ res.writeHead(200, {
156
+ "Content-Type": "text/event-stream",
157
+ "Cache-Control": "no-cache, no-transform",
158
+ Connection: "keep-alive",
159
+ "X-Accel-Buffering": "no",
160
+ });
161
+
162
+ const writeSse = (eventType: string, data: Record<string, unknown>) => {
163
+ res.write(`event: ${eventType}\n`);
164
+ res.write(`data: ${JSON.stringify(data)}\n\n`);
165
+ };
166
+
167
+ writeSse("connected", {
168
+ project_id: projectId,
169
+ subject_id: subjectId,
170
+ timestamp: new Date().toISOString(),
171
+ });
172
+
173
+ const unsubscribe = bus.subscribe(projectId, subjectId, (event) => {
174
+ writeSse(event.type, event.data);
175
+ });
176
+
177
+ const heartbeat = setInterval(() => {
178
+ writeSse("heartbeat", { timestamp: new Date().toISOString() });
179
+ }, 30000);
180
+
181
+ req.on("close", () => {
182
+ clearInterval(heartbeat);
183
+ unsubscribe();
184
+ debugLog("sse.unsubscribe", { projectId, subjectId });
185
+ });
186
+ return;
187
+ }
188
+
189
+ // ------------------------------------------------------------------
190
+ // Memories
191
+ // ------------------------------------------------------------------
192
+ if (method === "GET" && path === "/api/v1/memories") {
193
+ const subjectId = String(url.searchParams.get("subject_id") || "").trim();
194
+ if (!subjectId) {
195
+ sendJson(res, 400, { error: "subject_id_required" });
196
+ return;
197
+ }
198
+ const limit = parseIntInRange(url.searchParams.get("limit"), 50, 1, 200);
199
+ const offset = parseIntInRange(url.searchParams.get("offset"), 0, 0, 1_000_000);
200
+ const includeDeleted = parseBool(url.searchParams.get("include_deleted"), false);
201
+ const includeSuperseded = parseBool(url.searchParams.get("include_superseded"), false);
202
+ const data = await options.store.listMemories({
203
+ project_id: projectId,
204
+ subject_id: subjectId,
205
+ limit,
206
+ offset,
207
+ include_deleted: includeDeleted,
208
+ include_superseded: includeSuperseded,
209
+ });
210
+ const sanitized = data.map((m) => sanitizeMemoryForApi(m as unknown as Record<string, any>));
211
+ sendJson(res, 200, { data: sanitized, count: sanitized.length });
212
+ return;
213
+ }
214
+
215
+ if (method === "POST" && path === "/api/v1/memories/extract") {
216
+ const body = await readJsonBody(req);
217
+ if (!body) {
218
+ sendJson(res, 400, { error: "invalid_json_body" });
219
+ return;
220
+ }
221
+ const subjectId = String(body.subject_id || "").trim();
222
+ const text = String(body.text || "").trim();
223
+ if (!subjectId) {
224
+ sendJson(res, 400, { error: "subject_id_required" });
225
+ return;
226
+ }
227
+ if (!text) {
228
+ sendJson(res, 400, { error: "text_required" });
229
+ return;
230
+ }
231
+ const learn = body.learn === true || parseBool(url.searchParams.get("learn"), false);
232
+ const force = body.force === true || parseBool(url.searchParams.get("force"), false);
233
+ const context = Array.isArray(body.conversation_context)
234
+ ? body.conversation_context.map((v: unknown) => String(v || "").trim()).filter(Boolean).slice(-5)
235
+ : [];
236
+
237
+ const extracted = await extractionService.extract({
238
+ subject_id: subjectId,
239
+ text,
240
+ force,
241
+ conversation_context: context,
242
+ });
243
+ debugLog("memories.extract", {
244
+ mode: extractionService.name,
245
+ learn,
246
+ extracted_count: extracted.memories.length,
247
+ subjectId,
248
+ });
249
+
250
+ if (!learn) {
251
+ sendJson(res, 200, {
252
+ ok: true,
253
+ learned: false,
254
+ mode: extractionService.name,
255
+ extracted_count: extracted.memories.length,
256
+ memories: extracted.memories,
257
+ });
258
+ debugLog("request.end", { method, path, status: 200, mode: extractionService.name, duration_ms: Date.now() - startedAt });
259
+ return;
260
+ }
261
+
262
+ const learnedMemories: Array<Record<string, unknown>> = [];
263
+ let learnedClaims = 0;
264
+ for (const mem of extracted.memories) {
265
+ let embedding: number[] | null = null;
266
+ if (options.embed) {
267
+ try {
268
+ embedding = await options.embed(mem.text);
269
+ } catch {
270
+ embedding = null;
271
+ }
272
+ }
273
+ const memoryId = `mem_${randomUUID()}`;
274
+ const created = await options.store.createMemory({
275
+ id: memoryId,
276
+ project_id: projectId,
277
+ subject_id: subjectId,
278
+ text: mem.text,
279
+ kind: mem.kind,
280
+ visibility: mem.visibility,
281
+ importance: mem.importance,
282
+ confidence: mem.confidence,
283
+ is_temporal: mem.is_temporal,
284
+ tags: mem.tags,
285
+ metadata: {
286
+ extracted_via: extractionService.name,
287
+ force,
288
+ },
289
+ source_type: "inferred",
290
+ embedding,
291
+ });
292
+
293
+ bus.emit(projectId, subjectId, "memory.created", {
294
+ id: created.id,
295
+ subject_id: created.subject_id,
296
+ text: created.text,
297
+ kind: created.kind,
298
+ visibility: created.visibility,
299
+ importance: created.importance,
300
+ tags: created.tags,
301
+ created_at: created.created_at,
302
+ });
303
+
304
+ const createdClaimIds: string[] = [];
305
+ for (const claim of mem.claims) {
306
+ const claimId = `clm_${randomUUID()}`;
307
+ let claimEmbedding: number[] | null = null;
308
+ if (options.embed) {
309
+ try {
310
+ claimEmbedding = await options.embed(`${claim.predicate}: ${claim.object_value}`);
311
+ } catch {
312
+ claimEmbedding = null;
313
+ }
314
+ }
315
+ const createdClaim = await options.store.createClaim({
316
+ claim_id: claimId,
317
+ project_id: projectId,
318
+ subject_id: subjectId,
319
+ predicate: claim.predicate,
320
+ object_value: claim.object_value,
321
+ claim_type: claim.claim_type,
322
+ confidence: claim.confidence,
323
+ source_memory_id: memoryId,
324
+ embedding: claimEmbedding,
325
+ });
326
+ createdClaimIds.push(createdClaim.claim_id);
327
+ learnedClaims++;
328
+ }
329
+
330
+ learnedMemories.push({
331
+ memory_id: created.id,
332
+ text: created.text,
333
+ kind: created.kind,
334
+ claim_ids: createdClaimIds,
335
+ });
336
+ }
337
+
338
+ sendJson(res, 200, {
339
+ ok: true,
340
+ mode: extractionService.name,
341
+ learned: true,
342
+ extracted_count: extracted.memories.length,
343
+ learned_memory_count: learnedMemories.length,
344
+ learned_claim_count: learnedClaims,
345
+ memories: learnedMemories,
346
+ });
347
+ debugLog("request.end", {
348
+ method,
349
+ path,
350
+ status: 200,
351
+ mode: extractionService.name,
352
+ learned_memory_count: learnedMemories.length,
353
+ learned_claim_count: learnedClaims,
354
+ duration_ms: Date.now() - startedAt,
355
+ });
356
+ return;
357
+ }
358
+
359
+ if (method === "GET" && path === "/api/v1/memories/search") {
360
+ const subjectId = String(url.searchParams.get("subject_id") || "").trim();
361
+ const q = String(url.searchParams.get("q") || "").trim();
362
+ if (!subjectId) {
363
+ sendJson(res, 400, { error: "subject_id_required" });
364
+ return;
365
+ }
366
+ if (!q) {
367
+ sendJson(res, 400, { error: "q_required" });
368
+ return;
369
+ }
370
+ const limit = parseIntInRange(url.searchParams.get("limit"), 25, 1, 200);
371
+ const minScore = Number(url.searchParams.get("min_score") ?? url.searchParams.get("distance") ?? "30");
372
+ const conversationContext = url.searchParams
373
+ .getAll("context")
374
+ .map((v) => String(v || "").trim())
375
+ .filter(Boolean)
376
+ .slice(-5);
377
+
378
+ if (options.recallService) {
379
+ const result = await options.recallService.search({
380
+ project_id: projectId,
381
+ subject_id: subjectId,
382
+ query: q,
383
+ limit,
384
+ min_score: Number.isFinite(minScore) ? minScore : 30,
385
+ conversation_context: conversationContext,
386
+ });
387
+ const data = result.memories.map((m) => ({
388
+ id: m.id,
389
+ text: m.text,
390
+ kind: m.kind,
391
+ importance: m.importance,
392
+ is_temporal: m.is_temporal,
393
+ created_at: m.created_at,
394
+ score: Number(m.score) || 0,
395
+ effective_score: Number(m.effective_score) || 0,
396
+ }));
397
+ sendJson(res, 200, {
398
+ data,
399
+ query: q,
400
+ count: data.length,
401
+ engine: options.recallService.name,
402
+ mode: result.mode,
403
+ used_queries: result.used_queries,
404
+ predicates: result.predicates,
405
+ });
406
+ debugLog("request.end", {
407
+ method,
408
+ path,
409
+ status: 200,
410
+ engine: options.recallService.name,
411
+ mode: result.mode,
412
+ count: data.length,
413
+ duration_ms: Date.now() - startedAt,
414
+ });
415
+ return;
416
+ }
417
+
418
+ let embedding: number[] | null = null;
419
+ if (options.embed) {
420
+ try {
421
+ embedding = await options.embed(q);
422
+ } catch {
423
+ embedding = null;
424
+ }
425
+ }
426
+ const data = await options.store.searchMemories({
427
+ project_id: projectId,
428
+ subject_id: subjectId,
429
+ q,
430
+ query_embedding: embedding,
431
+ limit,
432
+ min_score: Number.isFinite(minScore) ? minScore : 30,
433
+ });
434
+ const mapped = data.map((m) => mapMemorySearchResult(m as unknown as Record<string, any>));
435
+ sendJson(res, 200, { data: mapped, query: q, count: mapped.length, engine: "fallback" });
436
+ debugLog("request.end", {
437
+ method,
438
+ path,
439
+ status: 200,
440
+ engine: "fallback",
441
+ count: mapped.length,
442
+ duration_ms: Date.now() - startedAt,
443
+ });
444
+ return;
445
+ }
446
+
447
+ if (method === "GET" && path === "/api/v1/memories/superseded") {
448
+ const subjectId = String(url.searchParams.get("subject_id") || "").trim();
449
+ if (!subjectId) {
450
+ sendJson(res, 400, { error: "subject_id_required" });
451
+ return;
452
+ }
453
+ const limit = parseIntInRange(url.searchParams.get("limit"), 50, 1, 200);
454
+ const offset = parseIntInRange(url.searchParams.get("offset"), 0, 0, 1_000_000);
455
+ const data = await options.store.listSupersededMemories({
456
+ project_id: projectId,
457
+ subject_id: subjectId,
458
+ limit,
459
+ offset,
460
+ });
461
+ const sanitized = data.map((m) => sanitizeMemoryForApi(m as unknown as Record<string, any>));
462
+ sendJson(res, 200, { data: sanitized, count: sanitized.length });
463
+ return;
464
+ }
465
+
466
+ if (method === "GET" && path === "/api/v1/memories/recalls") {
467
+ const chatId = String(url.searchParams.get("chat_id") || "").trim();
468
+ const memoryId = String(url.searchParams.get("memory_id") || "").trim();
469
+ const stats = parseBool(url.searchParams.get("stats"), false);
470
+ const limit = parseIntInRange(url.searchParams.get("limit"), 100, 1, 1000);
471
+
472
+ if (!chatId && !memoryId) {
473
+ sendJson(res, 400, {
474
+ error: "missing_parameter",
475
+ message: "Provide either chat_id or memory_id",
476
+ });
477
+ return;
478
+ }
479
+ if (chatId) {
480
+ const data = await options.store.getRecallEventsByChat({
481
+ project_id: projectId,
482
+ chat_id: chatId,
483
+ });
484
+ sendJson(res, 200, { data, count: data.length, chat_id: chatId });
485
+ return;
486
+ }
487
+ if (stats) {
488
+ const data = await options.store.getMemoryRecallStats({
489
+ project_id: projectId,
490
+ memory_id: memoryId,
491
+ });
492
+ sendJson(res, 200, { memory_id: memoryId, stats: data });
493
+ return;
494
+ }
495
+ const data = await options.store.getRecallEventsByMemory({
496
+ project_id: projectId,
497
+ memory_id: memoryId,
498
+ limit,
499
+ });
500
+ sendJson(res, 200, { data, count: data.length, memory_id: memoryId });
501
+ return;
502
+ }
503
+
504
+ const memoriesClaimsMatch = path.match(/^\/api\/v1\/memories\/([^/]+)\/claims$/);
505
+ if (method === "GET" && memoriesClaimsMatch) {
506
+ const memoryId = decodePathPart(memoriesClaimsMatch[1]);
507
+ const memory = await options.store.getMemory({ project_id: projectId, id: memoryId });
508
+ if (!memory) {
509
+ sendJson(res, 404, { error: "memory_not_found" });
510
+ return;
511
+ }
512
+ if (memory.is_deleted) {
513
+ sendJson(res, 404, { error: "memory_deleted" });
514
+ return;
515
+ }
516
+ const claims = await options.store.getMemoryClaims({
517
+ project_id: projectId,
518
+ memory_id: memoryId,
519
+ });
520
+ const data = claims.map((row) => {
521
+ let value: unknown = row.value_string;
522
+ if (row.object_type === "number") value = row.value_number;
523
+ if (row.object_type === "date") value = row.value_date;
524
+ if (row.object_type === "json") value = row.value_json;
525
+ return {
526
+ id: row.assertion_id,
527
+ predicate: row.predicate,
528
+ type: row.object_type,
529
+ value,
530
+ confidence: row.confidence,
531
+ status: row.status,
532
+ first_seen_at: row.first_seen_at,
533
+ last_seen_at: row.last_seen_at,
534
+ };
535
+ });
536
+ sendJson(res, 200, { data, count: data.length });
537
+ return;
538
+ }
539
+
540
+ const memoriesRestoreMatch = path.match(/^\/api\/v1\/memories\/([^/]+)\/restore$/);
541
+ if (method === "POST" && memoriesRestoreMatch) {
542
+ const memoryId = decodePathPart(memoriesRestoreMatch[1]);
543
+ const existing = await options.store.getMemory({ project_id: projectId, id: memoryId });
544
+ if (!existing) {
545
+ sendJson(res, 404, { error: "memory_not_found" });
546
+ return;
547
+ }
548
+ if (existing.is_deleted) {
549
+ sendJson(res, 400, {
550
+ error: "memory_deleted",
551
+ message: "Cannot restore a deleted memory",
552
+ });
553
+ return;
554
+ }
555
+ if (existing.status === "active") {
556
+ sendJson(res, 200, {
557
+ ok: true,
558
+ restored: false,
559
+ message: "Memory is already active",
560
+ });
561
+ return;
562
+ }
563
+
564
+ const restored = await options.store.restoreMemory({
565
+ project_id: projectId,
566
+ id: memoryId,
567
+ });
568
+ if (!restored) {
569
+ sendJson(res, 404, { error: "memory_not_found" });
570
+ return;
571
+ }
572
+ bus.emit(projectId, restored.subject_id, "memory.updated", {
573
+ id: restored.id,
574
+ status: restored.status,
575
+ });
576
+ sendJson(res, 200, {
577
+ ok: true,
578
+ restored: true,
579
+ id: restored.id,
580
+ subject_id: restored.subject_id,
581
+ text: restored.text,
582
+ });
583
+ return;
584
+ }
585
+
586
+ const memoryIdMatch = path.match(/^\/api\/v1\/memories\/([^/]+)$/);
587
+ if (memoryIdMatch) {
588
+ const memoryId = decodePathPart(memoryIdMatch[1]);
589
+ if (method === "GET") {
590
+ const memory = await options.store.getMemory({ project_id: projectId, id: memoryId });
591
+ if (!memory) {
592
+ sendJson(res, 404, { error: "memory_not_found" });
593
+ return;
594
+ }
595
+ if (memory.is_deleted) {
596
+ sendJson(res, 404, { error: "memory_deleted" });
597
+ return;
598
+ }
599
+ sendJson(res, 200, { data: sanitizeMemoryForApi(memory as unknown as Record<string, any>) });
600
+ return;
601
+ }
602
+ if (method === "PATCH") {
603
+ const body = await readJsonBody(req);
604
+ if (!body) {
605
+ sendJson(res, 400, { error: "invalid_json_body" });
606
+ return;
607
+ }
608
+ const existing = await options.store.getMemory({ project_id: projectId, id: memoryId });
609
+ if (!existing) {
610
+ sendJson(res, 404, { error: "memory_not_found" });
611
+ return;
612
+ }
613
+ if (existing.is_deleted) {
614
+ sendJson(res, 404, { error: "memory_deleted" });
615
+ return;
616
+ }
617
+ let embedding: number[] | null | undefined = undefined;
618
+ if (typeof body.text === "string" && body.text.trim() && options.embed) {
619
+ try {
620
+ embedding = await options.embed(body.text.trim());
621
+ } catch {
622
+ embedding = undefined;
623
+ }
624
+ }
625
+ const updated = await options.store.updateMemory({
626
+ project_id: projectId,
627
+ id: memoryId,
628
+ patch: {
629
+ text: body.text,
630
+ kind: body.kind,
631
+ visibility: body.visibility,
632
+ importance: body.importance,
633
+ confidence: body.confidence,
634
+ is_temporal: body.is_temporal,
635
+ tags: Array.isArray(body.tags) ? body.tags : undefined,
636
+ metadata: body.metadata,
637
+ embedding,
638
+ },
639
+ });
640
+ if (!updated) {
641
+ sendJson(res, 404, { error: "memory_not_found" });
642
+ return;
643
+ }
644
+ bus.emit(projectId, updated.subject_id, "memory.updated", {
645
+ id: updated.id,
646
+ subject_id: updated.subject_id,
647
+ });
648
+ sendJson(res, 200, { id: updated.id, updated: true });
649
+ return;
650
+ }
651
+ if (method === "DELETE") {
652
+ const existing = await options.store.getMemory({ project_id: projectId, id: memoryId });
653
+ const result = await options.store.deleteMemory({ project_id: projectId, id: memoryId });
654
+ if (result.deleted && existing) {
655
+ bus.emit(projectId, existing.subject_id, "memory.deleted", { id: memoryId });
656
+ }
657
+ sendJson(res, 200, { ok: true, deleted: result.deleted });
658
+ return;
659
+ }
660
+ }
661
+
662
+ if (method === "POST" && path === "/api/v1/memories") {
663
+ const body = await readJsonBody(req);
664
+ if (!body) {
665
+ sendJson(res, 400, { error: "invalid_json_body" });
666
+ return;
667
+ }
668
+ const subjectId = String(body.subject_id || "").trim();
669
+ const text = String(body.text || "").trim();
670
+ if (!subjectId) {
671
+ sendJson(res, 400, { error: "subject_id_required" });
672
+ return;
673
+ }
674
+ if (!text) {
675
+ sendJson(res, 400, { error: "text_required" });
676
+ return;
677
+ }
678
+ if (text.length > 10000) {
679
+ sendJson(res, 400, { error: "text_too_long", max: 10000 });
680
+ return;
681
+ }
682
+
683
+ const extractClaims = body.extract_claims !== false;
684
+ const noSupersede = body.no_supersede === true;
685
+
686
+ let embedding: number[] | null = null;
687
+ if (options.embed) {
688
+ try {
689
+ embedding = await options.embed(text);
690
+ } catch {
691
+ embedding = null;
692
+ }
693
+ }
694
+
695
+ const embeddingVector = Array.isArray(embedding) && embedding.length > 0 ? embedding : null;
696
+
697
+ if (embeddingVector && options.store.findDuplicateMemory) {
698
+ const duplicate = await options.store.findDuplicateMemory({
699
+ project_id: projectId,
700
+ subject_id: subjectId,
701
+ embedding: embeddingVector,
702
+ threshold: 85,
703
+ });
704
+ if (duplicate) {
705
+ sendJson(res, 200, {
706
+ id: null,
707
+ subject_id: subjectId,
708
+ text,
709
+ kind: body.kind || "fact",
710
+ created: false,
711
+ skipped: true,
712
+ reason: "duplicate",
713
+ });
714
+ debugLog("request.end", {
715
+ method,
716
+ path,
717
+ status: 200,
718
+ subject_id: subjectId,
719
+ skipped: "duplicate",
720
+ duration_ms: Date.now() - startedAt,
721
+ });
722
+ return;
723
+ }
724
+ }
725
+
726
+ let conflictingIds: string[] = [];
727
+ if (embeddingVector && !noSupersede && options.store.findConflictingMemories) {
728
+ const conflicts = await options.store.findConflictingMemories({
729
+ project_id: projectId,
730
+ subject_id: subjectId,
731
+ embedding: embeddingVector,
732
+ min_similarity: 60,
733
+ max_similarity: 85,
734
+ limit: 50,
735
+ });
736
+ conflictingIds = conflicts.map((c) => String(c.id || "").trim()).filter(Boolean);
737
+ }
738
+
739
+ const id = String(body.id || `mem_${randomUUID()}`);
740
+ const memory = await options.store.createMemory({
741
+ id,
742
+ project_id: projectId,
743
+ subject_id: subjectId,
744
+ text,
745
+ kind: body.kind,
746
+ visibility: body.visibility,
747
+ importance: body.importance,
748
+ confidence: body.confidence,
749
+ is_temporal: body.is_temporal,
750
+ tags: Array.isArray(body.tags) ? body.tags : [],
751
+ metadata: body.metadata && typeof body.metadata === "object" ? body.metadata : {},
752
+ source_type: body.source_type || "explicit",
753
+ embedding,
754
+ });
755
+
756
+ let supersededCount = 0;
757
+ if (conflictingIds.length > 0 && options.store.supersedeMemories) {
758
+ supersededCount = await options.store.supersedeMemories({
759
+ project_id: projectId,
760
+ subject_id: subjectId,
761
+ memory_ids: conflictingIds,
762
+ superseded_by: memory.id,
763
+ });
764
+ }
765
+
766
+ bus.emit(projectId, subjectId, "memory.created", {
767
+ id: memory.id,
768
+ subject_id: memory.subject_id,
769
+ text: memory.text,
770
+ kind: memory.kind,
771
+ visibility: memory.visibility,
772
+ importance: memory.importance,
773
+ tags: memory.tags,
774
+ created_at: memory.created_at,
775
+ });
776
+
777
+ if (supersededCount > 0) {
778
+ for (const conflictId of conflictingIds) {
779
+ bus.emit(projectId, subjectId, "memory.superseded", {
780
+ id: conflictId,
781
+ superseded_by: memory.id,
782
+ });
783
+ }
784
+ }
785
+
786
+ if (extractClaims && !noSupersede) {
787
+ void (async () => {
788
+ try {
789
+ const extracted = await extractionService.extract({
790
+ subject_id: subjectId,
791
+ text,
792
+ force: true,
793
+ });
794
+ const seen = new Set<string>();
795
+ const claims = extracted.memories
796
+ .flatMap((m) => (Array.isArray(m.claims) ? m.claims : []))
797
+ .map((claim) => ({
798
+ predicate: String(claim?.predicate || "").trim(),
799
+ object_value: String(claim?.object_value || "").trim(),
800
+ claim_type: claim?.claim_type ? String(claim.claim_type) : undefined,
801
+ confidence: Number(claim?.confidence),
802
+ }))
803
+ .filter((claim) => claim.predicate && claim.object_value)
804
+ .filter((claim) => {
805
+ const key = `${claim.predicate.toLowerCase()}::${claim.object_value.toLowerCase()}`;
806
+ if (seen.has(key)) return false;
807
+ seen.add(key);
808
+ return true;
809
+ })
810
+ .slice(0, 20);
811
+
812
+ for (const claim of claims) {
813
+ let claimEmbedding: number[] | null = null;
814
+ if (options.embed) {
815
+ try {
816
+ claimEmbedding = await options.embed(`${claim.predicate}: ${claim.object_value}`);
817
+ } catch {
818
+ claimEmbedding = null;
819
+ }
820
+ }
821
+ await options.store.createClaim({
822
+ claim_id: `clm_${randomUUID()}`,
823
+ project_id: projectId,
824
+ subject_id: subjectId,
825
+ predicate: claim.predicate,
826
+ object_value: claim.object_value,
827
+ claim_type: claim.claim_type,
828
+ confidence: Number.isFinite(claim.confidence) ? claim.confidence : undefined,
829
+ source_memory_id: memory.id,
830
+ embedding: claimEmbedding,
831
+ });
832
+ }
833
+ } catch (extractErr) {
834
+ debugLog("memories.create.extract_claims_failed", {
835
+ subject_id: subjectId,
836
+ memory_id: memory.id,
837
+ message: toErrorMessage(extractErr),
838
+ });
839
+ }
840
+ })();
841
+ }
842
+
843
+ sendJson(res, 201, {
844
+ id: memory.id,
845
+ subject_id: memory.subject_id,
846
+ text: memory.text,
847
+ kind: memory.kind,
848
+ created: true,
849
+ superseded_count: supersededCount,
850
+ superseded_ids: conflictingIds,
851
+ });
852
+ debugLog("request.end", {
853
+ method,
854
+ path,
855
+ status: 201,
856
+ memory_id: memory.id,
857
+ subject_id: memory.subject_id,
858
+ superseded_count: supersededCount,
859
+ duration_ms: Date.now() - startedAt,
860
+ });
861
+ return;
862
+ }
863
+
864
+ // ------------------------------------------------------------------
865
+ // Claims
866
+ // ------------------------------------------------------------------
867
+ if (method === "POST" && path === "/api/v1/claims") {
868
+ const body = await readJsonBody(req);
869
+ if (!body) {
870
+ sendJson(res, 400, { error: "invalid_json_body" });
871
+ return;
872
+ }
873
+ const subjectId = String(body.subject_id || "").trim();
874
+ const predicate = String(body.predicate || "").trim();
875
+ const objectValue = String(body.object_value || "").trim();
876
+ if (!subjectId) {
877
+ sendJson(res, 400, { error: "subject_id_required" });
878
+ return;
879
+ }
880
+ if (!predicate) {
881
+ sendJson(res, 400, { error: "predicate_required" });
882
+ return;
883
+ }
884
+ if (!objectValue) {
885
+ sendJson(res, 400, { error: "object_value_required" });
886
+ return;
887
+ }
888
+ let embedding: number[] | null = null;
889
+ if (options.embed) {
890
+ try {
891
+ embedding = await options.embed(`${predicate}: ${objectValue}`);
892
+ } catch {
893
+ embedding = null;
894
+ }
895
+ }
896
+
897
+ const claimId = String(body.claim_id || `clm_${randomUUID()}`);
898
+ const claim = await options.store.createClaim({
899
+ claim_id: claimId,
900
+ project_id: projectId,
901
+ subject_id: subjectId,
902
+ predicate,
903
+ object_value: objectValue,
904
+ claim_type: body.claim_type,
905
+ slot: body.slot,
906
+ confidence: body.confidence,
907
+ importance: body.importance,
908
+ tags: Array.isArray(body.tags) ? body.tags : [],
909
+ source_memory_id: body.source_memory_id || null,
910
+ source_observation_id: body.source_observation_id || null,
911
+ subject_entity: body.subject_entity || "self",
912
+ valid_from: body.valid_from || null,
913
+ valid_until: body.valid_until || null,
914
+ embedding,
915
+ });
916
+
917
+ sendJson(res, 201, {
918
+ claim_id: claim.claim_id,
919
+ subject_id: claim.subject_id,
920
+ predicate: claim.predicate,
921
+ object_value: claim.object_value,
922
+ slot: claim.slot,
923
+ claim_type: claim.claim_type,
924
+ confidence: claim.confidence,
925
+ observation_id: claim.source_observation_id,
926
+ linking_triggered: true,
927
+ });
928
+ return;
929
+ }
930
+
931
+ const claimRetractMatch = path.match(/^\/api\/v1\/claims\/([^/]+)\/retract$/);
932
+ if (method === "POST" && claimRetractMatch) {
933
+ const claimId = decodePathPart(claimRetractMatch[1]);
934
+ const body = await readJsonBody(req);
935
+ const reason = String(body?.reason || "manual_retraction");
936
+ const result = await options.store.retractClaim({
937
+ project_id: projectId,
938
+ claim_id: claimId,
939
+ reason,
940
+ });
941
+ if (!result.success) {
942
+ sendJson(res, 404, { error: "claim_not_found" });
943
+ return;
944
+ }
945
+ sendJson(res, 200, {
946
+ success: true,
947
+ claim_id: result.claim_id,
948
+ slot: result.slot,
949
+ previous_claim_id: result.previous_claim_id,
950
+ restored_previous: result.restored_previous,
951
+ reason,
952
+ });
953
+ return;
954
+ }
955
+
956
+ const claimsTruthMatch = path.match(/^\/api\/v1\/claims\/subject\/([^/]+)\/truth$/);
957
+ if (method === "GET" && claimsTruthMatch) {
958
+ const subjectId = decodePathPart(claimsTruthMatch[1]);
959
+ if (!subjectId) {
960
+ sendJson(res, 400, { error: "subject_id_required" });
961
+ return;
962
+ }
963
+ const includeSource = parseBool(url.searchParams.get("include_source"), true);
964
+ const slots = await options.store.getCurrentTruth({
965
+ project_id: projectId,
966
+ subject_id: subjectId,
967
+ });
968
+ const data = slots.map((s) => {
969
+ if (!includeSource) {
970
+ const { source_memory_id, source_observation_id, ...rest } = s;
971
+ return rest;
972
+ }
973
+ return {
974
+ ...s,
975
+ source: {
976
+ memory_id: s.source_memory_id || null,
977
+ observation_id: s.source_observation_id || null,
978
+ },
979
+ };
980
+ });
981
+ sendJson(res, 200, {
982
+ subject_id: subjectId,
983
+ project_id: projectId,
984
+ slot_count: slots.length,
985
+ slots: data,
986
+ });
987
+ return;
988
+ }
989
+
990
+ const claimsSlotMatch = path.match(/^\/api\/v1\/claims\/subject\/([^/]+)\/slot\/([^/]+)$/);
991
+ if (method === "GET" && claimsSlotMatch) {
992
+ const subjectId = decodePathPart(claimsSlotMatch[1]);
993
+ const slot = decodePathPart(claimsSlotMatch[2]);
994
+ if (!subjectId) {
995
+ sendJson(res, 400, { error: "subject_id_required" });
996
+ return;
997
+ }
998
+ if (!slot) {
999
+ sendJson(res, 400, { error: "slot_required" });
1000
+ return;
1001
+ }
1002
+ const row = await options.store.getCurrentSlot({
1003
+ project_id: projectId,
1004
+ subject_id: subjectId,
1005
+ slot,
1006
+ });
1007
+ if (!row) {
1008
+ sendJson(res, 404, { error: "slot_not_found", subject_id: subjectId, slot });
1009
+ return;
1010
+ }
1011
+ sendJson(res, 200, {
1012
+ subject_id: subjectId,
1013
+ project_id: projectId,
1014
+ slot: row.slot,
1015
+ active_claim_id: row.active_claim_id,
1016
+ predicate: row.predicate,
1017
+ object_value: row.object_value,
1018
+ claim_type: row.claim_type,
1019
+ confidence: row.confidence,
1020
+ updated_at: row.updated_at,
1021
+ tags: row.tags || [],
1022
+ source: {
1023
+ memory_id: row.source_memory_id || null,
1024
+ observation_id: row.source_observation_id || null,
1025
+ },
1026
+ });
1027
+ return;
1028
+ }
1029
+
1030
+ const claimsSlotsMatch = path.match(/^\/api\/v1\/claims\/subject\/([^/]+)\/slots$/);
1031
+ if (method === "GET" && claimsSlotsMatch) {
1032
+ const subjectId = decodePathPart(claimsSlotsMatch[1]);
1033
+ const limit = parseIntInRange(url.searchParams.get("limit"), 100, 1, 500);
1034
+ const rows = await options.store.getSlots({
1035
+ project_id: projectId,
1036
+ subject_id: subjectId,
1037
+ limit,
1038
+ });
1039
+ const active = rows.filter((r) => r.status === "active");
1040
+ const superseded = rows.filter((r) => r.status === "superseded");
1041
+ const other = rows.filter((r) => r.status !== "active" && r.status !== "superseded");
1042
+ sendJson(res, 200, {
1043
+ subject_id: subjectId,
1044
+ total: rows.length,
1045
+ active_count: active.length,
1046
+ slots: {
1047
+ active,
1048
+ superseded,
1049
+ other,
1050
+ },
1051
+ });
1052
+ return;
1053
+ }
1054
+
1055
+ const claimsGraphMatch = path.match(/^\/api\/v1\/claims\/subject\/([^/]+)\/graph$/);
1056
+ if (method === "GET" && claimsGraphMatch) {
1057
+ const subjectId = decodePathPart(claimsGraphMatch[1]);
1058
+ const limit = parseIntInRange(url.searchParams.get("limit"), 50, 1, 200);
1059
+ const graph = await options.store.getClaimGraph({
1060
+ project_id: projectId,
1061
+ subject_id: subjectId,
1062
+ limit,
1063
+ });
1064
+ const edgesByType: Record<string, number> = {};
1065
+ for (const edge of graph.edges) {
1066
+ edgesByType[edge.edge_type] = (edgesByType[edge.edge_type] || 0) + 1;
1067
+ }
1068
+ sendJson(res, 200, {
1069
+ subject_id: subjectId,
1070
+ claims_count: graph.claims.length,
1071
+ edges_count: graph.edges.length,
1072
+ edges_by_type: edgesByType,
1073
+ claims: graph.claims,
1074
+ edges: graph.edges,
1075
+ });
1076
+ return;
1077
+ }
1078
+
1079
+ const claimsHistoryMatch = path.match(/^\/api\/v1\/claims\/subject\/([^/]+)\/history$/);
1080
+ if (method === "GET" && claimsHistoryMatch) {
1081
+ const subjectId = decodePathPart(claimsHistoryMatch[1]);
1082
+ const slot = String(url.searchParams.get("slot") || "").trim() || null;
1083
+ const limit = parseIntInRange(url.searchParams.get("limit"), 100, 1, 500);
1084
+ const history = await options.store.getClaimHistory({
1085
+ project_id: projectId,
1086
+ subject_id: subjectId,
1087
+ slot,
1088
+ limit,
1089
+ });
1090
+ sendJson(res, 200, {
1091
+ subject_id: subjectId,
1092
+ project_id: projectId,
1093
+ slot_filter: slot,
1094
+ by_slot: history.by_slot,
1095
+ edges: history.edges,
1096
+ total_claims: history.claims.length,
1097
+ });
1098
+ return;
1099
+ }
1100
+
1101
+ const claimIdMatch = path.match(/^\/api\/v1\/claims\/([^/]+)$/);
1102
+ if (method === "GET" && claimIdMatch) {
1103
+ const claimId = decodePathPart(claimIdMatch[1]);
1104
+ const claim = await options.store.getClaim({ project_id: projectId, claim_id: claimId });
1105
+ if (!claim) {
1106
+ sendJson(res, 404, { error: "claim_not_found" });
1107
+ return;
1108
+ }
1109
+ const assertions = await options.store.getAssertionsForClaim({
1110
+ project_id: projectId,
1111
+ claim_id: claimId,
1112
+ });
1113
+ const edges = await options.store.getEdgesForClaim({
1114
+ project_id: projectId,
1115
+ claim_id: claimId,
1116
+ });
1117
+ const supersessionChain = edges.filter((e) => e.edge_type === "supersedes");
1118
+ const { embedding: _embedding, ...claimWithoutEmbedding } = claim as Record<string, any>;
1119
+ sendJson(res, 200, {
1120
+ claim: claimWithoutEmbedding,
1121
+ assertions,
1122
+ edges,
1123
+ supersession_chain: supersessionChain,
1124
+ });
1125
+ return;
1126
+ }
1127
+
1128
+ sendJson(res, 404, { error: "not_found", path, method });
1129
+ debugLog("request.end", { method, path, status: 404, duration_ms: Date.now() - startedAt });
1130
+ } catch (err: any) {
1131
+ const errMsg = toErrorMessage(err);
1132
+ console.error("[core] request error", {
1133
+ method,
1134
+ path,
1135
+ projectId,
1136
+ message: errMsg,
1137
+ });
1138
+ if (debugEnabled) {
1139
+ console.error("[core][debug] stack", err?.stack || "(no stack)");
1140
+ }
1141
+ sendJson(res, 500, {
1142
+ error: "server_error",
1143
+ message: errMsg,
1144
+ });
1145
+ debugLog("request.end", {
1146
+ method,
1147
+ path,
1148
+ status: 500,
1149
+ error: errMsg,
1150
+ duration_ms: Date.now() - startedAt,
1151
+ });
1152
+ }
1153
+ });
1154
+ }