atlas-mcp 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.
Files changed (54) hide show
  1. package/.env.example +32 -0
  2. package/README.md +282 -0
  3. package/package.json +72 -0
  4. package/public/app/assets/app-CxbS1w9p.js +3981 -0
  5. package/public/app/assets/index-BA6nxCuI.css +1 -0
  6. package/public/app/assets/index-BXmIRrQH.js +177 -0
  7. package/public/app/index.html +27 -0
  8. package/public/assets/brain-atlas.LICENSE.txt +16 -0
  9. package/public/assets/brain-atlas.glb +0 -0
  10. package/public/assets/brain.obj +27282 -0
  11. package/public/fonts/DepartureMono-Regular.woff +0 -0
  12. package/public/fonts/DepartureMono-Regular.woff2 +0 -0
  13. package/scripts/sync-memory-vectors.js +46 -0
  14. package/src/audit.js +9 -0
  15. package/src/cli/args.js +87 -0
  16. package/src/cli/commands/add.js +103 -0
  17. package/src/cli/commands/config.js +228 -0
  18. package/src/cli/commands/delete.js +75 -0
  19. package/src/cli/commands/entities.js +39 -0
  20. package/src/cli/commands/entity.js +47 -0
  21. package/src/cli/commands/get.js +46 -0
  22. package/src/cli/commands/list.js +53 -0
  23. package/src/cli/commands/related.js +56 -0
  24. package/src/cli/commands/search.js +68 -0
  25. package/src/cli/commands/update.js +58 -0
  26. package/src/cli/deps.js +114 -0
  27. package/src/cli/env-file.js +44 -0
  28. package/src/cli/format.js +246 -0
  29. package/src/cli.js +187 -0
  30. package/src/cognitive-worker.js +381 -0
  31. package/src/db.js +2674 -0
  32. package/src/extraction-context.js +31 -0
  33. package/src/ingestion-service.js +387 -0
  34. package/src/ingestion-worker.js +225 -0
  35. package/src/llm-config.js +31 -0
  36. package/src/llm.js +789 -0
  37. package/src/logger.js +51 -0
  38. package/src/mcp-server.js +577 -0
  39. package/src/memory-comparison.js +421 -0
  40. package/src/related-memories.js +232 -0
  41. package/src/run-cognitive-worker.js +12 -0
  42. package/src/run-ingestion-worker.js +13 -0
  43. package/src/run-vector-worker.js +12 -0
  44. package/src/schemas.js +413 -0
  45. package/src/semantic-validation.js +430 -0
  46. package/src/server.js +827 -0
  47. package/src/shared/brain-regions.js +61 -0
  48. package/src/shared/entity-lens.js +249 -0
  49. package/src/shared/memory-placement.js +171 -0
  50. package/src/shared/memory-search.js +55 -0
  51. package/src/shared/region-anchors.js +112 -0
  52. package/src/shared/region-mapper.js +247 -0
  53. package/src/vector-store.js +546 -0
  54. package/src/vector-worker.js +71 -0
package/src/server.js ADDED
@@ -0,0 +1,827 @@
1
+ import "dotenv/config";
2
+ import express from "express";
3
+ import { fileURLToPath } from "url";
4
+ import { dirname, join, resolve } from "path";
5
+ import { pathToFileURL } from "url";
6
+ import { existsSync } from "fs";
7
+ import { randomUUID } from "node:crypto";
8
+ import { z } from "zod";
9
+ import { compareMemories, decideMemoryWrite, extractAtomicMemories, extractMemory } from "./llm.js";
10
+ import { model } from "./llm-config.js";
11
+ import {
12
+ MEMORY_COMPARISON_SCHEMA_VERSION,
13
+ MemoryComparisonRequest,
14
+ MemoryRequest,
15
+ SummaryRequest,
16
+ } from "./schemas.js";
17
+ import {
18
+ buildMemoryStructuralDiff,
19
+ hashMemoryComparisonInput,
20
+ } from "./memory-comparison.js";
21
+ import { getRelatedMemories as deriveRelatedMemories } from "./related-memories.js";
22
+ import { retrieveExtractionContext } from "./extraction-context.js";
23
+ import createLogger from "./logger.js";
24
+ import { createIngestionService } from "./ingestion-service.js";
25
+ import {
26
+ assertAtlasModeSupported,
27
+ deleteAllMemoryVectors,
28
+ deleteMemoryVector,
29
+ hybridSearchMemories,
30
+ indexMemoryVector,
31
+ searchMemoryVectors,
32
+ } from "./vector-store.js";
33
+ import {
34
+ getDb,
35
+ getMemory,
36
+ getMemories,
37
+ getMemoriesByIds,
38
+ updateMemorySummary,
39
+ storeMemory,
40
+ getLatestExtraction,
41
+ getEntity,
42
+ getEntityAliases,
43
+ getEntityCatalog,
44
+ getEntityResolutionSuggestions,
45
+ getEntitiesForMemory,
46
+ getMemoriesForEntity,
47
+ getMemoryCatalog,
48
+ getMemoryComparison,
49
+ getRelationshipsForMemory,
50
+ getRelationshipsForEntity,
51
+ getStructuralMemoryLinks,
52
+ getRegionActivations,
53
+ backfillRegionActivations,
54
+ findEntities,
55
+ getGraphData,
56
+ saveMemoryComparison,
57
+ resolveEntityResolutionSuggestion,
58
+ searchMemoriesFts,
59
+ deleteAllMemories,
60
+ deleteAllEntities,
61
+ deleteMemory,
62
+ claimAnnotationJob,
63
+ completeAnnotationJob,
64
+ createMemorySource,
65
+ createSourceRevision,
66
+ enqueueAnnotationJob,
67
+ enqueueVectorIndexJob,
68
+ failAnnotationJob,
69
+ getAnnotationStatus,
70
+ getMemorySource,
71
+ getSourceMemoryLinks,
72
+ getVectorIndexStatus,
73
+ linkSourceMemory,
74
+ recoverAnnotationJobs,
75
+ retryAnnotationJob,
76
+ saveCognitiveAnnotation,
77
+ updateMemoryGraph,
78
+ updateMemorySourceStatus,
79
+ withTransaction,
80
+ } from "./db.js";
81
+
82
+ const log = createLogger("server");
83
+ const __dirname = dirname(fileURLToPath(import.meta.url));
84
+ const publicDir = join(__dirname, "..", "public");
85
+ const PORT = process.env.PORT || 3001;
86
+ const zSourceRevision = z.object({
87
+ text: z.string().min(1).max(2000),
88
+ author: z.string().trim().min(1).max(200).optional(),
89
+ reason: z.string().trim().min(1).max(500).optional(),
90
+ metadata: z.record(z.string(), z.unknown()).optional().default({}),
91
+ }).strict();
92
+
93
+ // The Vite-built React SPA (npm run build:web) emits here. React Router owns all
94
+ // client routes; the SPA is the only frontend (run the build before starting).
95
+ const spaDir = join(publicDir, "app");
96
+ const spaIndex = join(spaDir, "index.html");
97
+ const hasSpa = existsSync(spaIndex);
98
+
99
+ const defaultDependencies = {
100
+ backfillRegionActivations,
101
+ compareMemories,
102
+ decideMemoryWrite,
103
+ deleteAllMemoryVectors,
104
+ deleteAllMemories,
105
+ deleteAllEntities,
106
+ deleteMemoryVector,
107
+ deleteMemory,
108
+ extractAtomicMemories,
109
+ extractMemory,
110
+ findEntities,
111
+ getDb,
112
+ getEntitiesForMemory,
113
+ getEntity,
114
+ getEntityAliases,
115
+ getEntityCatalog,
116
+ getEntityResolutionSuggestions,
117
+ getGraphData,
118
+ getLatestExtraction,
119
+ getMemories,
120
+ getMemoriesByIds,
121
+ getMemoryCatalog,
122
+ getMemoryComparison,
123
+ getMemoriesForEntity,
124
+ getMemory,
125
+ getRegionActivations,
126
+ getRelationshipsForEntity,
127
+ getRelationshipsForMemory,
128
+ getStructuralMemoryLinks,
129
+ model,
130
+ indexMemoryVector,
131
+ searchMemoryVectors,
132
+ saveMemoryComparison,
133
+ resolveEntityResolutionSuggestion,
134
+ storeMemory,
135
+ updateMemorySummary,
136
+ updateMemoryGraph,
137
+ createMemorySource,
138
+ createSourceRevision,
139
+ updateMemorySourceStatus,
140
+ getMemorySource,
141
+ getSourceMemoryLinks,
142
+ linkSourceMemory,
143
+ enqueueAnnotationJob,
144
+ enqueueVectorIndexJob,
145
+ getAnnotationStatus,
146
+ getVectorIndexStatus,
147
+ withTransaction,
148
+ };
149
+
150
+ export function createAtlasApp(overrides = {}) {
151
+ const dependencies = { ...defaultDependencies, ...overrides };
152
+ dependencies.serializeMemory ||= (memory) => serializeMemory(memory, dependencies, {
153
+ includeRelationships: true,
154
+ });
155
+ dependencies.ingestionService ||= createIngestionService(dependencies);
156
+ const app = express();
157
+
158
+ dependencies.getDb();
159
+ dependencies.backfillRegionActivations();
160
+ app.use(express.json());
161
+ // Serve the Vite-built SPA assets first so /assets/index-<hash>.js resolves
162
+ // from public/app/assets, then fall through to public/assets for static files
163
+ // the SPA loads by absolute path (e.g. /assets/brain.obj, /fonts/*). Mounting
164
+ // the app dir first keeps ordering correct (app/assets wins; misses fall
165
+ // through to public/assets).
166
+ app.use(express.static(spaDir, { index: false }));
167
+ // Do not auto-serve index.html at "/"; the SPA owns that route.
168
+ app.use(express.static(publicDir, { index: false }));
169
+ app.set("etag", false);
170
+
171
+ app.use((req, res, next) => {
172
+ const start = Date.now();
173
+ res.on("finish", () => {
174
+ log.info("request", {
175
+ method: req.method,
176
+ path: req.originalUrl,
177
+ status: res.statusCode,
178
+ ms: Date.now() - start,
179
+ });
180
+ });
181
+ next();
182
+ });
183
+
184
+ app.post("/api/memories", async (req, res) => {
185
+ const parsed = MemoryRequest.safeParse(req.body);
186
+ if (!parsed.success) {
187
+ return res.status(400).json({ error: parsed.error.issues });
188
+ }
189
+
190
+ const { text, ingestionDate } = parsed.data;
191
+ const date = ingestionDate || new Date().toISOString();
192
+
193
+ try {
194
+ const result = await dependencies.ingestionService.ingest({
195
+ text,
196
+ ingestionDate: date,
197
+ source: "ui",
198
+ });
199
+ log.info("source ingested", { sourceId: result.sourceId, count: result.memories.length });
200
+ res.status(201).json(result);
201
+ } catch (error) {
202
+ log.error("ingestion failed", { sourceId: error.sourceId, error: error.message });
203
+ res.status(502).json({
204
+ error: "Ingestion failed",
205
+ code: error.code || "INGESTION_FAILED",
206
+ sourceId: error.sourceId,
207
+ detail: error.message,
208
+ });
209
+ }
210
+ });
211
+
212
+ app.get("/api/memories", (req, res) => {
213
+ const limit = parseInt(req.query.limit) || 100;
214
+ const offset = parseInt(req.query.offset) || 0;
215
+ const source = req.query.source || undefined;
216
+ const rows = dependencies.getMemories({ limit, offset, source });
217
+ res.json(rows.map((row) => serializeMemory(row, dependencies)));
218
+ });
219
+
220
+ app.get("/api/sources/:id", (req, res) => {
221
+ const source = dependencies.getMemorySource(req.params.id, {
222
+ includeRevisions: true,
223
+ });
224
+ if (!source) return res.status(404).json({ error: "Source not found" });
225
+ res.json({ ...source, links: dependencies.getSourceMemoryLinks(source.id) });
226
+ });
227
+
228
+ app.post("/api/sources/:id/revisions", async (req, res) => {
229
+ const source = dependencies.getMemorySource(req.params.id);
230
+ if (!source) return res.status(404).json({ error: "Source not found" });
231
+ const parsed = zSourceRevision.safeParse(req.body);
232
+ if (!parsed.success) return res.status(400).json({ error: parsed.error.issues });
233
+ const revision = dependencies.createSourceRevision({
234
+ id: randomUUID(),
235
+ sourceId: source.id,
236
+ ...parsed.data,
237
+ });
238
+ try {
239
+ const result = await dependencies.ingestionService.reprocess(source.id, {
240
+ metadata: parsed.data.metadata,
241
+ });
242
+ res.status(201).json({ revision, ...result });
243
+ } catch (error) {
244
+ res.status(502).json({
245
+ error: "Reprocessing failed",
246
+ code: error.code || "INGESTION_FAILED",
247
+ sourceId: source.id,
248
+ revisionId: revision.id,
249
+ detail: error.message,
250
+ });
251
+ }
252
+ });
253
+
254
+ app.get("/api/catalog/memories", (req, res) => {
255
+ const parsed = parseCatalogQuery(req.query, {
256
+ defaultSort: "created_at",
257
+ defaultOrder: "desc",
258
+ sorts: ["title", "type", "source", "confidence", "created_at", "linked"],
259
+ filters: {
260
+ source: ["ui", "mcp"],
261
+ type: null,
262
+ },
263
+ });
264
+ if (!parsed.ok) return res.status(400).json({ error: parsed.error });
265
+ res.json(dependencies.getMemoryCatalog(parsed.value));
266
+ });
267
+
268
+ app.get("/api/catalog/entities", (req, res) => {
269
+ const parsed = parseCatalogQuery(req.query, {
270
+ defaultSort: "canonical_name",
271
+ defaultOrder: "asc",
272
+ sorts: [
273
+ "canonical_name",
274
+ "kind",
275
+ "memory_count",
276
+ "relationship_count",
277
+ "created_at",
278
+ ],
279
+ filters: {
280
+ kind: ["person", "place", "object", "concept", "organization"],
281
+ },
282
+ });
283
+ if (!parsed.ok) return res.status(400).json({ error: parsed.error });
284
+ res.json(dependencies.getEntityCatalog(parsed.value));
285
+ });
286
+
287
+ app.get("/api/memories/search", async (req, res) => {
288
+ const query = String(req.query.q || "").trim();
289
+ if (!query) {
290
+ return res.status(400).json({ error: "q query param required" });
291
+ }
292
+
293
+ const limit = clampInteger(req.query.limit, 10, 1, 100);
294
+ const scoreThreshold = parseOptionalNumber(req.query.scoreThreshold);
295
+ const strategy = String(req.query.strategy || "hybrid");
296
+ const validStrategies = ["hybrid", "vector", "bm25"];
297
+ if (!validStrategies.includes(strategy)) {
298
+ return res.status(400).json({
299
+ error: `strategy must be one of: ${validStrategies.join(", ")}`,
300
+ });
301
+ }
302
+
303
+ try {
304
+ const hits = await hybridSearchMemories(query, {
305
+ limit,
306
+ scoreThreshold,
307
+ strategy,
308
+ searchMemoriesFts,
309
+ });
310
+ const hitIds = hits.map(({ id }) => id);
311
+ const memoriesById = new Map(
312
+ dependencies.getMemoriesByIds(hitIds).map((m) => [m.id, m]),
313
+ );
314
+ const memories = hits.flatMap(({ id, score }) => {
315
+ const memory = memoriesById.get(id);
316
+ return memory
317
+ ? [{ ...serializeMemory(memory, dependencies), rrfScore: score }]
318
+ : [];
319
+ });
320
+ res.json({ query, strategy, memories });
321
+ } catch (error) {
322
+ log.error("search failed", { error: error.message });
323
+ res.status(503).json({
324
+ error: "Search unavailable",
325
+ detail: error.message,
326
+ });
327
+ }
328
+ });
329
+
330
+ app.get("/api/memories/:id", (req, res) => {
331
+ const memory = dependencies.getMemory(req.params.id);
332
+ if (!memory) return res.status(404).json({ error: "Memory not found" });
333
+
334
+ res.json(
335
+ serializeMemory(memory, dependencies, {
336
+ includeRelationships: true,
337
+ }),
338
+ );
339
+ });
340
+
341
+ app.get("/api/memories/:id/links", async (req, res) => {
342
+ const limit = parseStrictInteger(req.query.limit, 5, 1, 20, "limit");
343
+ if (!limit.ok) return res.status(400).json({ error: limit.error });
344
+ const scoreThreshold = parseStrictNumber(
345
+ req.query.scoreThreshold,
346
+ 0.65,
347
+ -1,
348
+ 1,
349
+ "scoreThreshold",
350
+ );
351
+ if (!scoreThreshold.ok) {
352
+ return res.status(400).json({ error: scoreThreshold.error });
353
+ }
354
+
355
+ const result = await deriveRelatedMemories(
356
+ req.params.id,
357
+ {
358
+ getMemory: dependencies.getMemory,
359
+ getStructuralMemoryLinks: dependencies.getStructuralMemoryLinks,
360
+ searchMemoryVectors: dependencies.searchMemoryVectors,
361
+ searchMemoriesFts,
362
+ serializeMemory: (memory) =>
363
+ serializeMemory(memory, dependencies, {
364
+ includeRelationships: true,
365
+ }),
366
+ },
367
+ {
368
+ limit: limit.value,
369
+ scoreThreshold: scoreThreshold.value,
370
+ },
371
+ );
372
+ if (!result) return res.status(404).json({ error: "Memory not found" });
373
+ res.json(result);
374
+ });
375
+
376
+ app.post("/api/memory-comparisons", async (req, res) => {
377
+ const parsed = MemoryComparisonRequest.safeParse(req.body);
378
+ if (!parsed.success) {
379
+ return res.status(400).json({ error: parsed.error.issues });
380
+ }
381
+
382
+ const { leftMemoryId, rightMemoryId, regenerate } = parsed.data;
383
+ const leftMemory = dependencies.getMemory(leftMemoryId);
384
+ const rightMemory = dependencies.getMemory(rightMemoryId);
385
+ if (!leftMemory || !rightMemory) {
386
+ return res.status(404).json({
387
+ error: "Memory not found",
388
+ missing: [
389
+ ...(!leftMemory ? [leftMemoryId] : []),
390
+ ...(!rightMemory ? [rightMemoryId] : []),
391
+ ],
392
+ });
393
+ }
394
+
395
+ const left = serializeMemory(leftMemory, dependencies, {
396
+ includeRelationships: true,
397
+ });
398
+ const right = serializeMemory(rightMemory, dependencies, {
399
+ includeRelationships: true,
400
+ });
401
+ const structuralDiff = buildMemoryStructuralDiff(left, right);
402
+ const inputHash = hashMemoryComparisonInput(left, right);
403
+ const cacheKey = {
404
+ leftMemoryId,
405
+ rightMemoryId,
406
+ inputHash,
407
+ model: dependencies.model,
408
+ schemaVersion: MEMORY_COMPARISON_SCHEMA_VERSION,
409
+ };
410
+
411
+ if (!regenerate) {
412
+ const cached = dependencies.getMemoryComparison(cacheKey);
413
+ if (cached) {
414
+ return res.json(
415
+ comparisonResponse({
416
+ left,
417
+ right,
418
+ structuralDiff,
419
+ analysis: cached.comparison_json,
420
+ cache: cached,
421
+ cached: true,
422
+ inputHash,
423
+ model: dependencies.model,
424
+ }),
425
+ );
426
+ }
427
+ }
428
+
429
+ try {
430
+ const analysis = await dependencies.compareMemories(left, right);
431
+ const cache = dependencies.saveMemoryComparison({
432
+ ...cacheKey,
433
+ comparison: analysis,
434
+ });
435
+ res.json(
436
+ comparisonResponse({
437
+ left,
438
+ right,
439
+ structuralDiff,
440
+ analysis,
441
+ cache,
442
+ cached: false,
443
+ inputHash,
444
+ model: dependencies.model,
445
+ }),
446
+ );
447
+ } catch (error) {
448
+ log.error("memory comparison failed", {
449
+ leftMemoryId,
450
+ rightMemoryId,
451
+ error: error.message,
452
+ });
453
+ res.status(502).json({
454
+ error: "Comparison generation failed",
455
+ detail: error.message,
456
+ left,
457
+ right,
458
+ structuralDiff,
459
+ analysis: null,
460
+ generation: {
461
+ cached: false,
462
+ saved: false,
463
+ model: dependencies.model,
464
+ schemaVersion: MEMORY_COMPARISON_SCHEMA_VERSION,
465
+ inputHash,
466
+ generatedAt: null,
467
+ },
468
+ });
469
+ }
470
+ });
471
+
472
+ app.delete("/api/memories", async (_req, res) => {
473
+ dependencies.deleteAllMemories();
474
+ await syncVectorIndex(
475
+ () => dependencies.deleteAllMemoryVectors(),
476
+ { action: "delete-all" },
477
+ );
478
+ log.info("all memories deleted");
479
+ res.json({ ok: true });
480
+ });
481
+
482
+ app.delete("/api/memories/:id", async (req, res) => {
483
+ const memory = dependencies.getMemory(req.params.id);
484
+ if (!memory) return res.status(404).json({ error: "Memory not found" });
485
+ dependencies.deleteMemory(req.params.id);
486
+ await syncVectorIndex(
487
+ () => dependencies.deleteMemoryVector(req.params.id),
488
+ { action: "delete", id: req.params.id },
489
+ );
490
+ log.info("memory deleted", { id: req.params.id });
491
+ res.json({ ok: true });
492
+ });
493
+
494
+ app.patch("/api/memories/:id/summary", async (req, res) => {
495
+ const parsed = SummaryRequest.safeParse(req.body);
496
+ if (!parsed.success) {
497
+ return res.status(400).json({ error: parsed.error.issues });
498
+ }
499
+
500
+ dependencies.updateMemorySummary(req.params.id, parsed.data.summary);
501
+ const memory = dependencies.getMemory(req.params.id);
502
+ if (memory) {
503
+ await syncVectorIndex(
504
+ () => dependencies.indexMemoryVector(memory),
505
+ { action: "reindex", id: req.params.id },
506
+ );
507
+ }
508
+ log.info("summary updated", { id: req.params.id });
509
+ res.json({ ok: true });
510
+ });
511
+
512
+ app.get("/api/entities", (req, res) => {
513
+ const q = req.query.q;
514
+ if (!q) return res.status(400).json({ error: "q query param required" });
515
+ res.json(dependencies.findEntities(q));
516
+ });
517
+
518
+ app.delete("/api/entities", async (_req, res) => {
519
+ dependencies.deleteAllEntities();
520
+ log.info("all entities deleted");
521
+ res.json({ ok: true });
522
+ });
523
+
524
+ app.get("/api/graph", (_req, res) => {
525
+ try {
526
+ res.json(dependencies.getGraphData());
527
+ } catch (err) {
528
+ console.error("Graph data error:", err);
529
+ res.status(500).json({ error: "Failed to load graph data" });
530
+ }
531
+ });
532
+
533
+ app.get("/api/entities/:id/graph", (req, res) => {
534
+ const entityId = Number.parseInt(req.params.id, 10);
535
+ const entity = Number.isInteger(entityId)
536
+ ? dependencies.getEntity(entityId)
537
+ : null;
538
+ if (!entity) return res.status(404).json({ error: "Entity not found" });
539
+
540
+ const memories = dependencies
541
+ .getMemoriesForEntity(entityId)
542
+ .map((memory) => serializeMemory(memory, dependencies));
543
+ const relationships = dependencies
544
+ .getRelationshipsForEntity(entityId)
545
+ .map(serializeRelationship);
546
+ const aliases = dependencies.getEntityAliases(entityId);
547
+ const suggestions = dependencies
548
+ .getEntityResolutionSuggestions({ status: "pending" })
549
+ .filter(
550
+ (suggestion) =>
551
+ Number(suggestion.source_entity_id) === entityId
552
+ || Number(suggestion.target_entity_id) === entityId,
553
+ );
554
+ res.json({ entity, aliases, memories, relationships, suggestions });
555
+ });
556
+
557
+ app.get("/api/entities/:id/memories", (req, res) => {
558
+ const memories = dependencies.getMemoriesForEntity(
559
+ Number.parseInt(req.params.id, 10),
560
+ );
561
+ res.json(memories);
562
+ });
563
+
564
+ app.get("/api/entity-resolution/suggestions", (req, res) => {
565
+ const status = String(req.query.status || "pending");
566
+ if (!["pending", "merged", "rejected"].includes(status)) {
567
+ return res.status(400).json({ error: `Invalid status: ${status}` });
568
+ }
569
+ res.json({
570
+ suggestions: dependencies.getEntityResolutionSuggestions({ status }),
571
+ });
572
+ });
573
+
574
+ app.patch("/api/entity-resolution/suggestions/:id", (req, res) => {
575
+ const suggestionId = Number(req.params.id);
576
+ const decision = req.body?.decision;
577
+ if (!Number.isSafeInteger(suggestionId) || suggestionId <= 0) {
578
+ return res.status(400).json({ error: "Invalid suggestion ID" });
579
+ }
580
+ if (!["merge", "reject"].includes(decision)) {
581
+ return res.status(400).json({
582
+ error: "decision must be merge or reject",
583
+ });
584
+ }
585
+
586
+ const result = dependencies.resolveEntityResolutionSuggestion(
587
+ suggestionId,
588
+ decision,
589
+ );
590
+ if (!result) {
591
+ return res.status(404).json({ error: "Suggestion not found" });
592
+ }
593
+ res.json(result);
594
+ });
595
+
596
+ app.post("/api/extract", async (req, res) => {
597
+ const parsed = MemoryRequest.safeParse(req.body);
598
+ if (!parsed.success) {
599
+ return res.status(400).json({ error: parsed.error.issues });
600
+ }
601
+
602
+ const { text, ingestionDate } = parsed.data;
603
+
604
+ try {
605
+ const context = await retrieveExtractionContext(text, dependencies)
606
+ .catch(() => ({ entities: [] }));
607
+ const extraction = await dependencies.extractAtomicMemories(
608
+ text,
609
+ ingestionDate || new Date().toISOString(),
610
+ context,
611
+ );
612
+ res.json(extraction);
613
+ } catch (error) {
614
+ log.error("extraction failed (legacy)", { error: error.message });
615
+ res.status(502).json({
616
+ error: "Extraction failed",
617
+ detail: error.message,
618
+ });
619
+ }
620
+ });
621
+
622
+ // All client routes are owned by the React SPA: serve the built shell and let
623
+ // React Router resolve the view. Requires `npm run build:web` (public/app/).
624
+ if (hasSpa) {
625
+ const spaRoutes = [
626
+ "/",
627
+ "/landing",
628
+ "/atlas",
629
+ "/memories",
630
+ "/entities",
631
+ "/memories/compare",
632
+ "/graph",
633
+ ];
634
+ app.get(spaRoutes, (_req, res) => {
635
+ res.sendFile(spaIndex);
636
+ });
637
+
638
+ // Deep-link fallback: any other non-API GET that accepts HTML gets the SPA
639
+ // shell so client-side routes work on refresh. Excludes API/static prefixes.
640
+ app.get("*", (req, res, next) => {
641
+ if (
642
+ req.method !== "GET"
643
+ || req.path.startsWith("/api")
644
+ || req.path.startsWith("/js")
645
+ || req.path.startsWith("/assets")
646
+ || req.path.startsWith("/fonts")
647
+ ) {
648
+ return next();
649
+ }
650
+ if (!req.accepts("html")) return next();
651
+ res.sendFile(spaIndex);
652
+ });
653
+ }
654
+
655
+ return app;
656
+ }
657
+
658
+ async function syncVectorIndex(operation, context) {
659
+ try {
660
+ await operation();
661
+ return true;
662
+ } catch (error) {
663
+ log.warn("vector index sync failed", { ...context, error: error.message });
664
+ return false;
665
+ }
666
+ }
667
+
668
+ function clampInteger(value, fallback, minimum, maximum) {
669
+ const parsed = Number.parseInt(value, 10);
670
+ if (!Number.isInteger(parsed)) return fallback;
671
+ return Math.min(Math.max(parsed, minimum), maximum);
672
+ }
673
+
674
+ function parseOptionalNumber(value) {
675
+ if (value === undefined) return undefined;
676
+ const parsed = Number(value);
677
+ return Number.isFinite(parsed) ? parsed : undefined;
678
+ }
679
+
680
+ function parseCatalogQuery(
681
+ query,
682
+ { defaultSort, defaultOrder, sorts, filters },
683
+ ) {
684
+ const limit = parseStrictInteger(query.limit, 25, 1, 100, "limit");
685
+ if (!limit.ok) return limit;
686
+ const offset = parseStrictInteger(
687
+ query.offset,
688
+ 0,
689
+ 0,
690
+ Number.MAX_SAFE_INTEGER,
691
+ "offset",
692
+ );
693
+ if (!offset.ok) return offset;
694
+
695
+ const sort = query.sort === undefined ? defaultSort : String(query.sort);
696
+ if (!sorts.includes(sort)) {
697
+ return { ok: false, error: `Invalid sort: ${sort}` };
698
+ }
699
+ const order = query.order === undefined
700
+ ? defaultOrder
701
+ : String(query.order).toLowerCase();
702
+ if (!["asc", "desc"].includes(order)) {
703
+ return { ok: false, error: `Invalid order: ${query.order}` };
704
+ }
705
+
706
+ const value = {
707
+ q: String(query.q || "").trim(),
708
+ limit: limit.value,
709
+ offset: offset.value,
710
+ sort,
711
+ order,
712
+ };
713
+ for (const [name, allowed] of Object.entries(filters)) {
714
+ if (query[name] === undefined || query[name] === "") continue;
715
+ const filterValue = String(query[name]);
716
+ if (allowed && !allowed.includes(filterValue)) {
717
+ return { ok: false, error: `Invalid ${name}: ${filterValue}` };
718
+ }
719
+ value[name] = filterValue;
720
+ }
721
+ return { ok: true, value };
722
+ }
723
+
724
+ function parseStrictInteger(value, fallback, minimum, maximum, name) {
725
+ if (value === undefined) return { ok: true, value: fallback };
726
+ if (!/^\d+$/.test(String(value))) {
727
+ return { ok: false, error: `Invalid ${name}: ${value}` };
728
+ }
729
+ const parsed = Number(value);
730
+ if (!Number.isSafeInteger(parsed) || parsed < minimum || parsed > maximum) {
731
+ return { ok: false, error: `Invalid ${name}: ${value}` };
732
+ }
733
+ return { ok: true, value: parsed };
734
+ }
735
+
736
+ function parseStrictNumber(value, fallback, minimum, maximum, name) {
737
+ if (value === undefined) return { ok: true, value: fallback };
738
+ const parsed = Number(value);
739
+ if (
740
+ !Number.isFinite(parsed)
741
+ || parsed < minimum
742
+ || parsed > maximum
743
+ ) {
744
+ return { ok: false, error: `Invalid ${name}: ${value}` };
745
+ }
746
+ return { ok: true, value: parsed };
747
+ }
748
+
749
+ function serializeMemory(
750
+ memory,
751
+ dependencies,
752
+ { extraction, includeRelationships = false } = {},
753
+ ) {
754
+ return {
755
+ ...memory,
756
+ extraction:
757
+ extraction === undefined
758
+ ? dependencies.getLatestExtraction(memory.id)
759
+ : extraction,
760
+ entities: dependencies.getEntitiesForMemory(memory.id),
761
+ relationships: includeRelationships
762
+ ? dependencies.getRelationshipsForMemory(memory.id)
763
+ : [],
764
+ regions: dependencies.getRegionActivations(memory.id),
765
+ };
766
+ }
767
+
768
+ function serializeRelationship(relationship) {
769
+ return {
770
+ id: relationship.id,
771
+ predicate: relationship.predicate,
772
+ memory_id: relationship.memory_id,
773
+ confidence: relationship.confidence,
774
+ evidence: relationship.evidence,
775
+ created_at: relationship.created_at,
776
+ source: {
777
+ id: relationship.source_entity_id,
778
+ canonical_name: relationship.source_name,
779
+ kind: relationship.source_kind,
780
+ },
781
+ target: {
782
+ id: relationship.target_entity_id,
783
+ canonical_name: relationship.target_name,
784
+ kind: relationship.target_kind,
785
+ },
786
+ };
787
+ }
788
+
789
+ function comparisonResponse({
790
+ left,
791
+ right,
792
+ structuralDiff,
793
+ analysis,
794
+ cache,
795
+ cached,
796
+ inputHash,
797
+ model,
798
+ }) {
799
+ return {
800
+ left,
801
+ right,
802
+ structuralDiff,
803
+ analysis,
804
+ generation: {
805
+ cached,
806
+ saved: true,
807
+ model,
808
+ schemaVersion: MEMORY_COMPARISON_SCHEMA_VERSION,
809
+ inputHash,
810
+ generatedAt: cache.generated_at,
811
+ },
812
+ };
813
+ }
814
+
815
+ export function startAtlasServer(port = PORT) {
816
+ assertAtlasModeSupported();
817
+ const app = createAtlasApp();
818
+ return app.listen(port, () => {
819
+ log.info("server started", { port, url: `http://localhost:${port}` });
820
+ });
821
+ }
822
+
823
+ const isMainModule =
824
+ process.argv[1] &&
825
+ import.meta.url === pathToFileURL(resolve(process.argv[1])).href;
826
+
827
+ if (isMainModule) startAtlasServer();