recallx 1.1.0 → 1.2.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/app/server/app.js CHANGED
@@ -4,13 +4,14 @@ import { fileURLToPath } from "node:url";
4
4
  import cors from "cors";
5
5
  import express from "express";
6
6
  import mime from "mime-types";
7
- import { activitySearchSchema, appendActivitySchema, appendRelationUsageEventSchema, appendSearchFeedbackSchema, attachArtifactSchema, buildContextBundleSchema, captureMemorySchema, createWorkspaceSchema, createNodeSchema, createNodesSchema, createRelationSchema, governanceIssuesQuerySchema, nodeSearchSchema, openWorkspaceSchema, reindexInferredRelationsSchema, recomputeGovernanceSchema, recomputeInferredRelationsSchema, relationTypes, registerIntegrationSchema, sourceSchema, upsertInferredRelationSchema, updateIntegrationSchema, updateNodeSchema, updateRelationSchema, updateSettingsSchema, workspaceSearchSchema } from "../shared/contracts.js";
7
+ import { activitySearchSchema, appendActivitySchema, appendRelationUsageEventSchema, appendSearchFeedbackSchema, attachArtifactSchema, buildContextBundleSchema, captureMemorySchema, createWorkspaceBackupSchema, createWorkspaceSchema, createNodeSchema, createNodesSchema, createRelationSchema, exportWorkspaceSchema, governanceEventsQuerySchema, governanceNodeActionSchema, governanceRelationActionSchema, governanceIssuesQuerySchema, importWorkspacePreviewSchema, importWorkspaceSchema, nodeSearchSchema, openWorkspaceSchema, reindexInferredRelationsSchema, recomputeGovernanceSchema, recomputeInferredRelationsSchema, relationTypes, registerIntegrationSchema, restoreWorkspaceBackupSchema, sourceSchema, upsertInferredRelationSchema, updateIntegrationSchema, updateNodeSchema, updateRelationSchema, updateSettingsSchema, workspaceSearchSchema } from "../shared/contracts.js";
8
8
  import { AppError } from "./errors.js";
9
9
  import { isShortLogLikeAgentNodeInput, maybeCreatePromotionCandidate, recomputeAutomaticGovernance, resolveGovernancePolicy, resolveNodeGovernance, resolveRelationStatus, shouldPromoteActivitySummary } from "./governance.js";
10
10
  import { refreshAutomaticInferredRelationsForNode, reindexAutomaticInferredRelations } from "./inferred-relations.js";
11
11
  import { appendCurrentTelemetryDetails, createObservabilityWriter, summarizePayloadShape } from "./observability.js";
12
12
  import { buildSemanticCandidateBonusMap, buildCandidateRelationBonusMap, buildContextBundle, buildNeighborhoodItems, buildNeighborhoodItemsBatch, buildTargetRelatedRetrievalItems, bundleAsMarkdown, computeRankCandidateScore, selectSemanticCandidateIds, shouldUseSemanticCandidateAugmentation } from "./retrieval.js";
13
13
  import { buildProjectGraph } from "./project-graph.js";
14
+ import { isReadonlySqliteWriteError } from "./sqlite-errors.js";
14
15
  import { createId, isPathWithinRoot } from "./utils.js";
15
16
  const relationTypeSet = new Set(relationTypes);
16
17
  const allowedLoopbackHostnames = new Set(["127.0.0.1", "localhost", "::1", "[::1]"]);
@@ -494,6 +495,31 @@ function buildServiceIndex(workspaceInfo) {
494
495
  requestExample: {
495
496
  rootPath: "/Users/name/Documents/RecallX-Personal"
496
497
  }
498
+ },
499
+ {
500
+ method: "POST",
501
+ path: "/api/v1/workspaces/backups",
502
+ purpose: "Create a manual workspace snapshot before risky changes.",
503
+ requestExample: {
504
+ label: "before-upgrade"
505
+ }
506
+ },
507
+ {
508
+ method: "POST",
509
+ path: "/api/v1/workspaces/restore",
510
+ purpose: "Restore a snapshot into a new workspace root and switch to it.",
511
+ requestExample: {
512
+ backupId: "20260330123000-before-upgrade",
513
+ targetRootPath: "/Users/name/Documents/RecallX-Restore"
514
+ }
515
+ },
516
+ {
517
+ method: "POST",
518
+ path: "/api/v1/workspaces/export",
519
+ purpose: "Export the active workspace to a portable file under exports/.",
520
+ requestExample: {
521
+ format: "json"
522
+ }
497
523
  }
498
524
  ],
499
525
  references: {
@@ -1008,7 +1034,9 @@ export function createRecallXApp(params) {
1008
1034
  }
1009
1035
  }
1010
1036
  catch (error) {
1011
- console.error(`Failed to refresh inferred relations for node ${nodeId}`, error);
1037
+ if (!isReadonlySqliteWriteError(error)) {
1038
+ console.error(`Failed to refresh inferred relations for node ${nodeId}`, error);
1039
+ }
1012
1040
  }
1013
1041
  }
1014
1042
  if (processedNodes > 0) {
@@ -1180,7 +1208,13 @@ export function createRecallXApp(params) {
1180
1208
  };
1181
1209
  }
1182
1210
  function createDurableNodeResponse(repository, input) {
1183
- const governance = resolveNodeGovernance(input, resolveGovernancePolicy(repository.getSettings(["review.autoApproveLowRisk", "review.trustedSourceToolNames"])));
1211
+ const governancePolicy = resolveGovernancePolicy(repository.getSettings(["review.autoApproveLowRisk", "review.trustedSourceToolNames"]));
1212
+ const rawProjectId = typeof input.metadata.projectId === "string" ? input.metadata.projectId.trim() : "";
1213
+ const linkedProject = rawProjectId ? repository.getNode(rawProjectId) : null;
1214
+ if (linkedProject && (linkedProject.type !== "project" || linkedProject.status === "archived")) {
1215
+ throw new AppError(400, "INVALID_INPUT", "metadata.projectId must reference an active project node.");
1216
+ }
1217
+ const governance = resolveNodeGovernance(input, governancePolicy);
1184
1218
  const node = repository.createNode({
1185
1219
  ...input,
1186
1220
  resolvedCanonicality: governance.canonicality,
@@ -1196,7 +1230,48 @@ export function createRecallXApp(params) {
1196
1230
  }
1197
1231
  });
1198
1232
  const governanceResult = recomputeGovernanceForEntities("node", [node.id]);
1199
- queueInferredRefresh(node.id, "node-write");
1233
+ if (linkedProject) {
1234
+ if (linkedProject.id !== node.id) {
1235
+ const relationInput = {
1236
+ fromNodeId: node.id,
1237
+ toNodeId: linkedProject.id,
1238
+ relationType: "relevant_to",
1239
+ source: input.source,
1240
+ metadata: {
1241
+ createdFrom: "project-aware-capture",
1242
+ projectId: linkedProject.id,
1243
+ },
1244
+ };
1245
+ const relationGovernance = resolveRelationStatus(relationInput, governancePolicy);
1246
+ const relation = repository.createRelation({
1247
+ ...relationInput,
1248
+ resolvedStatus: relationGovernance.status,
1249
+ });
1250
+ repository.recordProvenance({
1251
+ entityType: "relation",
1252
+ entityId: relation.id,
1253
+ operationType: "create",
1254
+ source: input.source,
1255
+ metadata: {
1256
+ reason: relationGovernance.reason,
1257
+ createdFrom: "project-aware-capture",
1258
+ },
1259
+ });
1260
+ recomputeGovernanceForEntities("relation", [relation.id]);
1261
+ queueInferredRefreshForNodes([node.id, linkedProject.id], "node-write");
1262
+ broadcastWorkspaceEvent({
1263
+ reason: "relation.created",
1264
+ entityType: "relation",
1265
+ entityId: relation.id,
1266
+ });
1267
+ }
1268
+ else {
1269
+ queueInferredRefresh(node.id, "node-write");
1270
+ }
1271
+ }
1272
+ else {
1273
+ queueInferredRefresh(node.id, "node-write");
1274
+ }
1200
1275
  scheduleAutoSemanticIndex();
1201
1276
  broadcastWorkspaceEvent({
1202
1277
  reason: "node.created",
@@ -1403,6 +1478,50 @@ export function createRecallXApp(params) {
1403
1478
  const workspace = params.workspaceSessionManager.openWorkspace(input.rootPath);
1404
1479
  commitWorkspaceMutation(response, workspace, "workspace.opened");
1405
1480
  });
1481
+ app.get("/api/v1/workspaces/backups", (_request, response) => {
1482
+ response.json(envelope(response.locals.requestId, {
1483
+ items: params.workspaceSessionManager.listBackups()
1484
+ }));
1485
+ });
1486
+ app.post("/api/v1/workspaces/backups", (request, response) => {
1487
+ const input = createWorkspaceBackupSchema.parse(request.body ?? {});
1488
+ const backup = params.workspaceSessionManager.createBackup(input.label);
1489
+ response.status(201).json(envelope(response.locals.requestId, { backup }));
1490
+ });
1491
+ app.post("/api/v1/workspaces/export", (request, response) => {
1492
+ const input = exportWorkspaceSchema.parse(request.body ?? {});
1493
+ const exportRecord = params.workspaceSessionManager.exportWorkspace(input.format);
1494
+ response.status(201).json(envelope(response.locals.requestId, { export: exportRecord }));
1495
+ });
1496
+ app.post("/api/v1/workspaces/import/preview", (request, response) => {
1497
+ const input = importWorkspacePreviewSchema.parse(request.body ?? {});
1498
+ const preview = params.workspaceSessionManager.previewImportWorkspace(input.format, input.sourcePath, input.label, input.options);
1499
+ response.json(envelope(response.locals.requestId, { preview }));
1500
+ });
1501
+ app.post("/api/v1/workspaces/import", (request, response) => {
1502
+ const input = importWorkspaceSchema.parse(request.body ?? {});
1503
+ const importRecord = params.workspaceSessionManager.importWorkspace(input.format, input.sourcePath, input.label, input.options);
1504
+ refreshWorkspaceState();
1505
+ broadcastWorkspaceEvent({
1506
+ reason: "workspace.imported",
1507
+ entityType: "workspace"
1508
+ });
1509
+ response.status(201).json(envelope(response.locals.requestId, { import: importRecord }));
1510
+ });
1511
+ app.post("/api/v1/workspaces/restore", (request, response) => {
1512
+ const input = restoreWorkspaceBackupSchema.parse(request.body ?? {});
1513
+ const result = params.workspaceSessionManager.restoreBackup(input.backupId, input.targetRootPath, input.workspaceName);
1514
+ refreshWorkspaceState();
1515
+ broadcastWorkspaceEvent({
1516
+ reason: "workspace.restored",
1517
+ entityType: "workspace"
1518
+ });
1519
+ response.status(201).json(envelope(response.locals.requestId, {
1520
+ restoredBackupId: input.backupId,
1521
+ autoBackup: result.autoBackup,
1522
+ ...buildWorkspaceMutationPayload(result.workspace)
1523
+ }));
1524
+ });
1406
1525
  app.get("/api/v1/observability/summary", handleAsyncRoute(async (request, response) => {
1407
1526
  const summary = await observability.summarize({
1408
1527
  since: readRequestParam(request.query.since),
@@ -1476,6 +1595,21 @@ export function createRecallXApp(params) {
1476
1595
  scopes: input.scopes
1477
1596
  }, async (span) => {
1478
1597
  const searchResult = await currentRepository().searchWorkspace(input, {
1598
+ runSearchStageSpan: async (operation, details, callback) => runObservedSpan(operation, details, async (stageSpan) => {
1599
+ const stageResult = await callback();
1600
+ if (stageResult &&
1601
+ typeof stageResult === "object" &&
1602
+ "items" in stageResult &&
1603
+ Array.isArray(stageResult.items) &&
1604
+ "total" in stageResult &&
1605
+ typeof stageResult.total === "number") {
1606
+ stageSpan.addDetails({
1607
+ resultCount: stageResult.items.length,
1608
+ totalCount: stageResult.total
1609
+ });
1610
+ }
1611
+ return stageResult;
1612
+ }),
1479
1613
  runSemanticFallbackSpan: async (details, callback) => runObservedSpan("workspace.search.semantic_fallback", {
1480
1614
  ...details,
1481
1615
  mcpTool: request.header("x-recallx-mcp-tool") ?? null
@@ -1723,6 +1857,157 @@ export function createRecallXApp(params) {
1723
1857
  governance: buildGovernancePayload(repository, "node", node.id, governanceResult.items[0] ?? repository.getGovernanceStateNullable("node", node.id))
1724
1858
  }));
1725
1859
  });
1860
+ app.post("/api/v1/nodes/:id/governance-action", (request, response) => {
1861
+ const repository = currentRepository();
1862
+ const input = governanceNodeActionSchema.parse(request.body ?? {});
1863
+ const note = input.note?.trim() ? input.note.trim() : null;
1864
+ const nodeId = request.params.id;
1865
+ const beforeNode = repository.getNode(nodeId);
1866
+ const previousState = repository.getGovernanceStateNullable("node", nodeId);
1867
+ const now = new Date().toISOString();
1868
+ let node = beforeNode;
1869
+ let nextState = previousState?.state ?? "low_confidence";
1870
+ let confidence = previousState?.confidence ?? 0.5;
1871
+ let eventType;
1872
+ let operationType;
1873
+ let reason;
1874
+ switch (input.action) {
1875
+ case "promote": {
1876
+ if (beforeNode.status === "archived") {
1877
+ throw new AppError(409, "INVALID_STATE", "Archived nodes cannot be promoted.");
1878
+ }
1879
+ node = repository.updateNode(nodeId, {
1880
+ status: "active",
1881
+ metadata: {
1882
+ manualGovernanceAction: "promote",
1883
+ manualGovernanceAt: now
1884
+ }
1885
+ });
1886
+ node = repository.setNodeCanonicality(nodeId, "canonical");
1887
+ nextState = "healthy";
1888
+ confidence = Math.max(previousState?.confidence ?? 0, 0.96);
1889
+ eventType = "promoted";
1890
+ operationType = "promote";
1891
+ reason = note
1892
+ ? `Human promoted this node to canonical. ${note}`
1893
+ : "Human promoted this node to canonical from the governance surface.";
1894
+ break;
1895
+ }
1896
+ case "contest": {
1897
+ if (beforeNode.status === "archived") {
1898
+ throw new AppError(409, "INVALID_STATE", "Archived nodes cannot be contested.");
1899
+ }
1900
+ node = repository.updateNode(nodeId, {
1901
+ status: "contested",
1902
+ metadata: {
1903
+ manualGovernanceAction: "contest",
1904
+ manualGovernanceAt: now
1905
+ }
1906
+ });
1907
+ nextState = "contested";
1908
+ confidence = Math.min(previousState?.confidence ?? 0.4, 0.32);
1909
+ eventType = "contested";
1910
+ operationType = "contest";
1911
+ reason = note
1912
+ ? `Human marked this node contested. ${note}`
1913
+ : "Human marked this node contested from the governance surface.";
1914
+ break;
1915
+ }
1916
+ case "archive": {
1917
+ if (beforeNode.status === "archived") {
1918
+ throw new AppError(409, "INVALID_STATE", "Node is already archived.");
1919
+ }
1920
+ node = repository.archiveNode(nodeId);
1921
+ nextState = previousState?.state === "contested" ? "contested" : "low_confidence";
1922
+ confidence = Math.min(previousState?.confidence ?? 0.4, 0.2);
1923
+ eventType = "demoted";
1924
+ operationType = "archive";
1925
+ reason = note
1926
+ ? `Human archived this node from governance. ${note}`
1927
+ : "Human archived this node from the governance surface.";
1928
+ break;
1929
+ }
1930
+ }
1931
+ repository.recordProvenance({
1932
+ entityType: "node",
1933
+ entityId: node.id,
1934
+ operationType,
1935
+ source: input.source,
1936
+ metadata: {
1937
+ action: input.action,
1938
+ note,
1939
+ previousStatus: beforeNode.status,
1940
+ nextStatus: node.status,
1941
+ previousCanonicality: beforeNode.canonicality,
1942
+ nextCanonicality: node.canonicality,
1943
+ ...input.metadata
1944
+ }
1945
+ });
1946
+ const activity = repository.appendActivity({
1947
+ targetNodeId: node.id,
1948
+ activityType: "review_action",
1949
+ body: reason,
1950
+ source: input.source,
1951
+ metadata: {
1952
+ action: input.action,
1953
+ previousState: previousState?.state ?? "none",
1954
+ nextState,
1955
+ previousStatus: beforeNode.status,
1956
+ nextStatus: node.status,
1957
+ previousCanonicality: beforeNode.canonicality,
1958
+ nextCanonicality: node.canonicality,
1959
+ note: note ?? "",
1960
+ ...input.metadata
1961
+ }
1962
+ });
1963
+ const governanceState = repository.upsertGovernanceState({
1964
+ entityType: "node",
1965
+ entityId: node.id,
1966
+ state: nextState,
1967
+ confidence,
1968
+ reasons: [reason],
1969
+ lastEvaluatedAt: now,
1970
+ metadata: {
1971
+ manualAction: input.action,
1972
+ note: note ?? "",
1973
+ source: input.source.actorLabel
1974
+ },
1975
+ previousState
1976
+ });
1977
+ repository.appendGovernanceEvent({
1978
+ entityType: "node",
1979
+ entityId: node.id,
1980
+ eventType,
1981
+ previousState: previousState?.state ?? null,
1982
+ nextState,
1983
+ confidence,
1984
+ reason,
1985
+ metadata: {
1986
+ manualAction: input.action,
1987
+ note: note ?? "",
1988
+ previousStatus: beforeNode.status,
1989
+ nextStatus: node.status,
1990
+ previousCanonicality: beforeNode.canonicality,
1991
+ nextCanonicality: node.canonicality
1992
+ }
1993
+ });
1994
+ queueInferredRefresh(node.id, "node-write");
1995
+ scheduleAutoSemanticIndex();
1996
+ broadcastWorkspaceEvent({
1997
+ reason: input.action === "promote"
1998
+ ? "node.promoted"
1999
+ : input.action === "contest"
2000
+ ? "node.contested"
2001
+ : "node.archived",
2002
+ entityType: "node",
2003
+ entityId: node.id
2004
+ });
2005
+ response.json(envelope(response.locals.requestId, {
2006
+ node,
2007
+ activity,
2008
+ governance: buildGovernancePayload(repository, "node", node.id, governanceState)
2009
+ }));
2010
+ });
1726
2011
  app.post("/api/v1/nodes/:id/archive", (request, response) => {
1727
2012
  const repository = currentRepository();
1728
2013
  const source = sourceSchema.parse(request.body?.source ?? request.body ?? {});
@@ -2014,6 +2299,117 @@ export function createRecallXApp(params) {
2014
2299
  governance: buildGovernancePayload(repository, "relation", relation.id, governanceResult.items[0] ?? repository.getGovernanceStateNullable("relation", relation.id))
2015
2300
  }));
2016
2301
  });
2302
+ app.get("/api/v1/relations/:id", (request, response) => {
2303
+ const repository = currentRepository();
2304
+ const relation = repository.getRelation(request.params.id);
2305
+ response.json(envelope(response.locals.requestId, {
2306
+ relation,
2307
+ fromNode: repository.getNode(relation.fromNodeId),
2308
+ toNode: repository.getNode(relation.toNodeId),
2309
+ governance: buildGovernancePayload(repository, "relation", relation.id)
2310
+ }));
2311
+ });
2312
+ app.post("/api/v1/relations/:id/governance-action", (request, response) => {
2313
+ const repository = currentRepository();
2314
+ const input = governanceRelationActionSchema.parse(request.body ?? {});
2315
+ const note = input.note?.trim() ? input.note.trim() : null;
2316
+ const relationId = request.params.id;
2317
+ const beforeRelation = repository.getRelation(relationId);
2318
+ const previousState = repository.getGovernanceStateNullable("relation", relationId);
2319
+ if (beforeRelation.status === "archived") {
2320
+ throw new AppError(409, "INVALID_STATE", "Archived relations cannot be changed with governance actions.");
2321
+ }
2322
+ let nextStatus;
2323
+ let confidence = 0.92;
2324
+ let eventType;
2325
+ let operationType;
2326
+ let reason;
2327
+ switch (input.action) {
2328
+ case "accept":
2329
+ nextStatus = "active";
2330
+ confidence = Math.max(previousState?.confidence ?? 0, 0.94);
2331
+ eventType = "promoted";
2332
+ operationType = "approve";
2333
+ reason = note
2334
+ ? `Human accepted this relation. ${note}`
2335
+ : "Human accepted this relation from the governance surface.";
2336
+ break;
2337
+ case "reject":
2338
+ nextStatus = "rejected";
2339
+ confidence = Math.max(previousState?.confidence ?? 0, 0.9);
2340
+ eventType = "demoted";
2341
+ operationType = "reject";
2342
+ reason = note
2343
+ ? `Human rejected this relation. ${note}`
2344
+ : "Human rejected this relation from the governance surface.";
2345
+ break;
2346
+ case "archive":
2347
+ nextStatus = "archived";
2348
+ confidence = Math.max(previousState?.confidence ?? 0, 0.88);
2349
+ eventType = "demoted";
2350
+ operationType = "archive";
2351
+ reason = note
2352
+ ? `Human archived this relation from governance. ${note}`
2353
+ : "Human archived this relation from the governance surface.";
2354
+ break;
2355
+ }
2356
+ const relation = repository.updateRelationStatus(relationId, nextStatus);
2357
+ repository.recordProvenance({
2358
+ entityType: "relation",
2359
+ entityId: relation.id,
2360
+ operationType,
2361
+ source: input.source,
2362
+ metadata: {
2363
+ action: input.action,
2364
+ note,
2365
+ previousStatus: beforeRelation.status,
2366
+ nextStatus: relation.status,
2367
+ ...input.metadata
2368
+ }
2369
+ });
2370
+ const governanceState = repository.upsertGovernanceState({
2371
+ entityType: "relation",
2372
+ entityId: relation.id,
2373
+ state: "healthy",
2374
+ confidence,
2375
+ reasons: [reason],
2376
+ metadata: {
2377
+ manualAction: input.action,
2378
+ note: note ?? "",
2379
+ source: input.source.actorLabel
2380
+ },
2381
+ previousState
2382
+ });
2383
+ repository.appendGovernanceEvent({
2384
+ entityType: "relation",
2385
+ entityId: relation.id,
2386
+ eventType,
2387
+ previousState: previousState?.state ?? null,
2388
+ nextState: "healthy",
2389
+ confidence,
2390
+ reason,
2391
+ metadata: {
2392
+ manualAction: input.action,
2393
+ note: note ?? "",
2394
+ previousStatus: beforeRelation.status,
2395
+ nextStatus: relation.status
2396
+ }
2397
+ });
2398
+ queueInferredRefreshForNodes([relation.fromNodeId, relation.toNodeId], "node-write");
2399
+ broadcastWorkspaceEvent({
2400
+ reason: input.action === "accept"
2401
+ ? "relation.accepted"
2402
+ : input.action === "reject"
2403
+ ? "relation.rejected"
2404
+ : "relation.archived",
2405
+ entityType: "relation",
2406
+ entityId: relation.id
2407
+ });
2408
+ response.json(envelope(response.locals.requestId, {
2409
+ relation,
2410
+ governance: buildGovernancePayload(repository, "relation", relation.id, governanceState)
2411
+ }));
2412
+ });
2017
2413
  app.get("/api/v1/nodes/:id/activities", (request, response) => {
2018
2414
  const limit = Number(request.query.limit ?? 20);
2019
2415
  response.json(envelope(response.locals.requestId, {
@@ -2233,6 +2629,18 @@ export function createRecallXApp(params) {
2233
2629
  items: currentRepository().listGovernanceIssues(input.limit, input.states)
2234
2630
  }));
2235
2631
  });
2632
+ app.get("/api/v1/governance/events", (request, response) => {
2633
+ const entityTypes = parseCommaSeparatedValues(request.query.entity_types)?.filter((entityType) => entityType === "node" || entityType === "relation");
2634
+ const actions = parseCommaSeparatedValues(request.query.actions)?.filter((action) => action === "promote" || action === "contest" || action === "archive" || action === "accept" || action === "reject");
2635
+ const input = governanceEventsQuerySchema.parse({
2636
+ entityTypes,
2637
+ actions,
2638
+ limit: Number(request.query.limit ?? 12)
2639
+ });
2640
+ response.json(envelope(response.locals.requestId, {
2641
+ items: currentRepository().listRecentGovernanceEvents(input)
2642
+ }));
2643
+ });
2236
2644
  app.get("/api/v1/governance/state/:entityType/:id", (request, response) => {
2237
2645
  const entityType = readRequestParam(request.params.entityType);
2238
2646
  if (entityType !== "node" && entityType !== "relation") {
@@ -15,13 +15,15 @@ export function ensureApiToken(config) {
15
15
  }
16
16
  return randomBytes(24).toString("hex");
17
17
  }
18
- export function workspaceInfo(rootPath, config, authMode) {
18
+ export function workspaceInfo(rootPath, config, authMode, paths, safety) {
19
19
  return {
20
20
  rootPath,
21
21
  workspaceName: config.workspaceName,
22
22
  schemaVersion: getSchemaVersion(),
23
23
  bindAddress: `${config.bindAddress}:${config.port}`,
24
24
  enabledIntegrationModes: ["read-only", "append-only"],
25
- authMode
25
+ authMode,
26
+ paths,
27
+ safety,
26
28
  };
27
29
  }
@@ -11,7 +11,18 @@ const app = createRecallXApp({
11
11
  workspaceSessionManager,
12
12
  apiToken: config.apiToken ? apiToken : null
13
13
  });
14
- createServer(app).listen(config.port, config.bindAddress, () => {
14
+ const server = createServer(app);
15
+ function shutdown() {
16
+ try {
17
+ workspaceSessionManager.shutdown();
18
+ }
19
+ finally {
20
+ process.exit(0);
21
+ }
22
+ }
23
+ process.once("SIGINT", shutdown);
24
+ process.once("SIGTERM", shutdown);
25
+ server.listen(config.port, config.bindAddress, () => {
15
26
  console.log(`RecallX API listening on http://${config.bindAddress}:${config.port}`);
16
27
  if (resolveRendererDistDir()) {
17
28
  console.log(`RecallX UI available at http://${config.bindAddress}:${config.port}/`);
@@ -6,6 +6,9 @@ const DEFAULT_PROJECT_INFERRED_LIMIT = 60;
6
6
  function relationLabel(value) {
7
7
  return value.replaceAll("_", " ");
8
8
  }
9
+ function relationEdgeKey(sourceNodeId, targetNodeId) {
10
+ return `${sourceNodeId}:${targetNodeId}`;
11
+ }
9
12
  export function buildProjectGraph(repository, projectId, options) {
10
13
  const project = repository.getNode(projectId);
11
14
  if (project.type !== "project") {
@@ -37,11 +40,11 @@ export function buildProjectGraph(repository, projectId, options) {
37
40
  .items
38
41
  .filter((item) => item.id !== projectId)
39
42
  .map((item) => item.id)
40
- .filter((nodeId, index, items) => items.indexOf(nodeId) === index)
41
43
  : [];
42
- const fallbackNodeMap = fallbackNodeIds.length > 0 ? repository.getNodesByIds(fallbackNodeIds) : new Map();
43
- const fallbackNodes = fallbackNodeIds.length > 0
44
- ? fallbackNodeIds
44
+ const uniqueFallbackNodeIds = Array.from(new Set(fallbackNodeIds));
45
+ const fallbackNodeMap = uniqueFallbackNodeIds.length > 0 ? repository.getNodesByIds(uniqueFallbackNodeIds) : new Map();
46
+ const fallbackNodes = uniqueFallbackNodeIds.length > 0
47
+ ? uniqueFallbackNodeIds
45
48
  .map((nodeId) => fallbackNodeMap.get(nodeId))
46
49
  .filter((node) => Boolean(node))
47
50
  : [];
@@ -54,9 +57,13 @@ export function buildProjectGraph(repository, projectId, options) {
54
57
  canonicalEdges = repository.listRelationsBetweenNodeIds(expandedScopedNodeIds);
55
58
  inferredEdges = includeInferred ? repository.listInferredRelationsBetweenNodeIds(expandedScopedNodeIds, inferredLimit) : [];
56
59
  }
57
- const directEdgeKeys = new Set(canonicalEdges.map((edge) => `${edge.fromNodeId}:${edge.toNodeId}`));
60
+ const connectedEdgeKeys = new Set([
61
+ ...canonicalEdges.map((edge) => relationEdgeKey(edge.fromNodeId, edge.toNodeId)),
62
+ ...inferredEdges.map((edge) => relationEdgeKey(edge.fromNodeId, edge.toNodeId)),
63
+ ]);
58
64
  const syntheticFallbackEdges = fallbackNodes
59
- .filter((node) => !directEdgeKeys.has(`${node.id}:${projectId}`) && !directEdgeKeys.has(`${projectId}:${node.id}`))
65
+ .filter((node) => !connectedEdgeKeys.has(relationEdgeKey(node.id, projectId)) &&
66
+ !connectedEdgeKeys.has(relationEdgeKey(projectId, node.id)))
60
67
  .map((node) => ({
61
68
  id: `project-map-fallback:${projectId}:${node.id}`,
62
69
  source: projectId,