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.
- package/.env.example +32 -0
- package/README.md +282 -0
- package/package.json +72 -0
- package/public/app/assets/app-CxbS1w9p.js +3981 -0
- package/public/app/assets/index-BA6nxCuI.css +1 -0
- package/public/app/assets/index-BXmIRrQH.js +177 -0
- package/public/app/index.html +27 -0
- package/public/assets/brain-atlas.LICENSE.txt +16 -0
- package/public/assets/brain-atlas.glb +0 -0
- package/public/assets/brain.obj +27282 -0
- package/public/fonts/DepartureMono-Regular.woff +0 -0
- package/public/fonts/DepartureMono-Regular.woff2 +0 -0
- package/scripts/sync-memory-vectors.js +46 -0
- package/src/audit.js +9 -0
- package/src/cli/args.js +87 -0
- package/src/cli/commands/add.js +103 -0
- package/src/cli/commands/config.js +228 -0
- package/src/cli/commands/delete.js +75 -0
- package/src/cli/commands/entities.js +39 -0
- package/src/cli/commands/entity.js +47 -0
- package/src/cli/commands/get.js +46 -0
- package/src/cli/commands/list.js +53 -0
- package/src/cli/commands/related.js +56 -0
- package/src/cli/commands/search.js +68 -0
- package/src/cli/commands/update.js +58 -0
- package/src/cli/deps.js +114 -0
- package/src/cli/env-file.js +44 -0
- package/src/cli/format.js +246 -0
- package/src/cli.js +187 -0
- package/src/cognitive-worker.js +381 -0
- package/src/db.js +2674 -0
- package/src/extraction-context.js +31 -0
- package/src/ingestion-service.js +387 -0
- package/src/ingestion-worker.js +225 -0
- package/src/llm-config.js +31 -0
- package/src/llm.js +789 -0
- package/src/logger.js +51 -0
- package/src/mcp-server.js +577 -0
- package/src/memory-comparison.js +421 -0
- package/src/related-memories.js +232 -0
- package/src/run-cognitive-worker.js +12 -0
- package/src/run-ingestion-worker.js +13 -0
- package/src/run-vector-worker.js +12 -0
- package/src/schemas.js +413 -0
- package/src/semantic-validation.js +430 -0
- package/src/server.js +827 -0
- package/src/shared/brain-regions.js +61 -0
- package/src/shared/entity-lens.js +249 -0
- package/src/shared/memory-placement.js +171 -0
- package/src/shared/memory-search.js +55 -0
- package/src/shared/region-anchors.js +112 -0
- package/src/shared/region-mapper.js +247 -0
- package/src/vector-store.js +546 -0
- 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();
|