recallx 1.0.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 (37) hide show
  1. package/README.md +205 -0
  2. package/app/cli/bin/recallx-mcp.js +2 -0
  3. package/app/cli/bin/recallx.js +8 -0
  4. package/app/cli/src/cli.js +808 -0
  5. package/app/cli/src/format.js +242 -0
  6. package/app/cli/src/http.js +35 -0
  7. package/app/mcp/api-client.js +101 -0
  8. package/app/mcp/index.js +128 -0
  9. package/app/mcp/server.js +786 -0
  10. package/app/server/app.js +2263 -0
  11. package/app/server/config.js +27 -0
  12. package/app/server/db.js +399 -0
  13. package/app/server/errors.js +17 -0
  14. package/app/server/governance.js +466 -0
  15. package/app/server/index.js +26 -0
  16. package/app/server/inferred-relations.js +247 -0
  17. package/app/server/observability.js +495 -0
  18. package/app/server/project-graph.js +199 -0
  19. package/app/server/relation-scoring.js +59 -0
  20. package/app/server/repositories.js +2992 -0
  21. package/app/server/retrieval.js +486 -0
  22. package/app/server/semantic/chunker.js +85 -0
  23. package/app/server/semantic/provider.js +124 -0
  24. package/app/server/semantic/types.js +1 -0
  25. package/app/server/semantic/vector-store.js +169 -0
  26. package/app/server/utils.js +43 -0
  27. package/app/server/workspace-session.js +128 -0
  28. package/app/server/workspace.js +79 -0
  29. package/app/shared/contracts.js +268 -0
  30. package/app/shared/request-runtime.js +30 -0
  31. package/app/shared/types.js +1 -0
  32. package/app/shared/version.js +1 -0
  33. package/dist/renderer/assets/ProjectGraphCanvas-BMvz9DmE.js +312 -0
  34. package/dist/renderer/assets/index-C2-KXqBO.css +1 -0
  35. package/dist/renderer/assets/index-CrDu22h7.js +76 -0
  36. package/dist/renderer/index.html +13 -0
  37. package/package.json +49 -0
@@ -0,0 +1,2263 @@
1
+ import { existsSync, lstatSync, realpathSync, statSync } from "node:fs";
2
+ import path from "node:path";
3
+ import { fileURLToPath } from "node:url";
4
+ import cors from "cors";
5
+ import express from "express";
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";
8
+ import { AppError } from "./errors.js";
9
+ import { isShortLogLikeAgentNodeInput, maybeCreatePromotionCandidate, recomputeAutomaticGovernance, resolveGovernancePolicy, resolveNodeGovernance, resolveRelationStatus, shouldPromoteActivitySummary } from "./governance.js";
10
+ import { refreshAutomaticInferredRelationsForNode, reindexAutomaticInferredRelations } from "./inferred-relations.js";
11
+ import { createObservabilityWriter, summarizePayloadShape } from "./observability.js";
12
+ import { buildSemanticCandidateBonusMap, buildCandidateRelationBonusMap, buildContextBundle, buildNeighborhoodItems, buildTargetRelatedRetrievalItems, bundleAsMarkdown, computeRankCandidateScore, shouldUseSemanticCandidateAugmentation } from "./retrieval.js";
13
+ import { buildProjectGraph } from "./project-graph.js";
14
+ import { createId, isPathWithinRoot } from "./utils.js";
15
+ const relationTypeSet = new Set(relationTypes);
16
+ const allowedLoopbackHostnames = new Set(["127.0.0.1", "localhost", "::1", "[::1]"]);
17
+ const isDesktopManagedApi = process.env.ELECTRON_RUN_AS_NODE === "1";
18
+ const updateNodeRequestSchema = updateNodeSchema.extend({
19
+ source: sourceSchema
20
+ });
21
+ const defaultCaptureSource = {
22
+ actorType: "system",
23
+ actorLabel: "RecallX API",
24
+ toolName: "recallx-api"
25
+ };
26
+ export function resolveRendererDistDir() {
27
+ const configured = process.env.RECALLX_RENDERER_DIST_PATH;
28
+ if (typeof configured === "string" && configured.trim()) {
29
+ const resolved = path.resolve(configured.trim());
30
+ return existsSync(path.join(resolved, "index.html")) ? resolved : null;
31
+ }
32
+ const moduleDir = path.dirname(fileURLToPath(import.meta.url));
33
+ const candidates = [
34
+ path.resolve(moduleDir, "../../../renderer"),
35
+ path.resolve(moduleDir, "../../dist/renderer")
36
+ ];
37
+ for (const candidate of candidates) {
38
+ if (existsSync(path.join(candidate, "index.html"))) {
39
+ return candidate;
40
+ }
41
+ }
42
+ return null;
43
+ }
44
+ export function resolveRendererIndexPath() {
45
+ const rendererDistDir = resolveRendererDistDir();
46
+ if (!rendererDistDir) {
47
+ return null;
48
+ }
49
+ const indexPath = path.join(rendererDistDir, "index.html");
50
+ return existsSync(indexPath) ? indexPath : null;
51
+ }
52
+ function formatApiBaseUrl(bindAddress) {
53
+ if (bindAddress.startsWith("[")) {
54
+ return `http://${bindAddress}/api/v1`;
55
+ }
56
+ const colonCount = (bindAddress.match(/:/g) ?? []).length;
57
+ if (colonCount > 1) {
58
+ const lastColonIndex = bindAddress.lastIndexOf(":");
59
+ const host = bindAddress.slice(0, lastColonIndex);
60
+ const port = bindAddress.slice(lastColonIndex + 1);
61
+ if (host && /^\d+$/.test(port)) {
62
+ return `http://[${host}]:${port}/api/v1`;
63
+ }
64
+ }
65
+ return `http://${bindAddress}/api/v1`;
66
+ }
67
+ function parseRelationTypesQuery(value) {
68
+ const items = parseCommaSeparatedValues(value)?.filter((item) => relationTypeSet.has(item));
69
+ return items?.length ? items : undefined;
70
+ }
71
+ function isAllowedBrowserOrigin(origin) {
72
+ try {
73
+ const url = new URL(origin);
74
+ return (url.protocol === "http:" || url.protocol === "https:") && allowedLoopbackHostnames.has(url.hostname);
75
+ }
76
+ catch {
77
+ return false;
78
+ }
79
+ }
80
+ function parseCommaSeparatedValues(value) {
81
+ if (typeof value !== "string" || !value.trim()) {
82
+ return undefined;
83
+ }
84
+ const items = value
85
+ .split(",")
86
+ .map((item) => item.trim())
87
+ .filter(Boolean);
88
+ return items.length ? items : undefined;
89
+ }
90
+ function parseClampedNumber(value, fallback, min, max) {
91
+ const numericValue = typeof value === "number" ? value : typeof value === "string" ? Number.parseInt(value, 10) : Number.NaN;
92
+ if (!Number.isFinite(numericValue)) {
93
+ return fallback;
94
+ }
95
+ return Math.min(Math.max(Math.trunc(numericValue), min), max);
96
+ }
97
+ function parseSemanticIssueStatuses(value) {
98
+ const statuses = parseCommaSeparatedValues(value)?.filter((status) => status === "pending" || status === "stale" || status === "failed");
99
+ return statuses?.length ? statuses : undefined;
100
+ }
101
+ function readRequestParam(value) {
102
+ if (typeof value === "string") {
103
+ return value;
104
+ }
105
+ if (Array.isArray(value)) {
106
+ const first = value[0];
107
+ return typeof first === "string" ? first : "";
108
+ }
109
+ return "";
110
+ }
111
+ function normalizeArtifactRelativePath(value) {
112
+ const withForwardSlashes = value
113
+ .replace(/^\//, "")
114
+ .replace(/[\\/]+/g, "/");
115
+ const normalized = path.posix.normalize(withForwardSlashes);
116
+ return normalized === "." ? "" : normalized;
117
+ }
118
+ function readBearerToken(request) {
119
+ const header = request.header("authorization");
120
+ return header?.startsWith("Bearer ") ? header.slice("Bearer ".length) : null;
121
+ }
122
+ function isAuthenticatedApiRequest(request, apiToken) {
123
+ return !apiToken || readBearerToken(request) === apiToken;
124
+ }
125
+ function resolveRegisteredArtifactPath(workspaceRoot, artifactsDir, artifactRelativePath) {
126
+ const artifactPath = path.resolve(workspaceRoot, artifactRelativePath);
127
+ if (!isPathWithinRoot(artifactsDir, artifactPath)) {
128
+ throw new AppError(403, "FORBIDDEN", "Artifact path escapes workspace root.");
129
+ }
130
+ try {
131
+ const entryStats = lstatSync(artifactPath);
132
+ if (entryStats.isSymbolicLink()) {
133
+ throw new AppError(404, "NOT_FOUND", "Artifact not found.");
134
+ }
135
+ const resolvedArtifactRoot = realpathSync(artifactsDir);
136
+ const resolvedArtifactPath = realpathSync(artifactPath);
137
+ if (!isPathWithinRoot(resolvedArtifactRoot, resolvedArtifactPath)) {
138
+ throw new AppError(404, "NOT_FOUND", "Artifact not found.");
139
+ }
140
+ const artifactStats = statSync(resolvedArtifactPath);
141
+ if (!artifactStats.isFile()) {
142
+ throw new AppError(404, "NOT_FOUND", "Artifact not found.");
143
+ }
144
+ return resolvedArtifactPath;
145
+ }
146
+ catch (error) {
147
+ if (error instanceof AppError) {
148
+ throw error;
149
+ }
150
+ if (error instanceof Error &&
151
+ "code" in error &&
152
+ (error.code === "ENOENT" || error.code === "ENOTDIR" || error.code === "ELOOP")) {
153
+ throw new AppError(404, "NOT_FOUND", "Artifact not found.");
154
+ }
155
+ throw error;
156
+ }
157
+ }
158
+ function deriveCaptureTitle(body) {
159
+ const normalized = body.replace(/\s+/g, " ").trim();
160
+ if (!normalized) {
161
+ return "Captured note";
162
+ }
163
+ const firstSentence = normalized.match(/^(.{1,80}?[.!?])(?:\s|$)/)?.[1]?.trim();
164
+ if (firstSentence) {
165
+ return firstSentence;
166
+ }
167
+ return normalized.length > 80 ? `${normalized.slice(0, 77).trimEnd()}...` : normalized;
168
+ }
169
+ function parseBooleanSetting(value, fallback) {
170
+ return typeof value === "boolean" ? value : fallback;
171
+ }
172
+ function parseNumberSetting(value, fallback) {
173
+ return typeof value === "number" && Number.isFinite(value) ? value : fallback;
174
+ }
175
+ function normalizeApiRequestPath(value) {
176
+ return value
177
+ .replace(/^\/artifacts\/.+$/g, "/artifacts/:path")
178
+ .replace(/\/nodes\/[^/]+\/neighborhood/g, "/nodes/:id/neighborhood")
179
+ .replace(/\/nodes\/[^/]+\/activities/g, "/nodes/:id/activities")
180
+ .replace(/\/nodes\/[^/]+\/artifacts/g, "/nodes/:id/artifacts")
181
+ .replace(/\/nodes\/[^/]+/g, "/nodes/:id")
182
+ .replace(/\/semantic\/reindex\/[^/]+/g, "/semantic/reindex/:nodeId")
183
+ .replace(/\/governance\/state\/[^/]+\/[^/]+/g, "/governance/state/:entityType/:id");
184
+ }
185
+ function envelope(requestId, data) {
186
+ return {
187
+ ok: true,
188
+ data,
189
+ meta: {
190
+ requestId,
191
+ apiVersion: "v1"
192
+ }
193
+ };
194
+ }
195
+ function toBatchErrorPayload(error) {
196
+ return {
197
+ code: error.code,
198
+ message: error.message,
199
+ details: error.details
200
+ };
201
+ }
202
+ function errorEnvelope(requestId, error) {
203
+ return {
204
+ ok: false,
205
+ error: {
206
+ code: error.code,
207
+ message: error.message,
208
+ details: error.details
209
+ },
210
+ meta: {
211
+ requestId,
212
+ apiVersion: "v1"
213
+ }
214
+ };
215
+ }
216
+ function buildServiceIndex(workspaceInfo) {
217
+ const apiBaseUrl = formatApiBaseUrl(workspaceInfo.bindAddress);
218
+ return {
219
+ service: {
220
+ name: "RecallX",
221
+ description: "Local-first personal knowledge layer for humans and agents.",
222
+ apiVersion: "v1",
223
+ baseUrl: apiBaseUrl,
224
+ authMode: workspaceInfo.authMode,
225
+ workspaceName: workspaceInfo.workspaceName,
226
+ workspaceRoot: workspaceInfo.rootPath
227
+ },
228
+ startHere: [
229
+ {
230
+ method: "GET",
231
+ path: "/api/v1",
232
+ purpose: "Discover service capabilities, important endpoints, and request examples."
233
+ },
234
+ {
235
+ method: "GET",
236
+ path: "/api/v1/health",
237
+ purpose: "Check whether the running local RecallX service is healthy."
238
+ },
239
+ {
240
+ method: "GET",
241
+ path: "/api/v1/workspace",
242
+ purpose: "Read the currently active workspace identity and configuration."
243
+ },
244
+ {
245
+ method: "GET",
246
+ path: "/api/v1/workspaces",
247
+ purpose: "List known workspaces and the currently active one."
248
+ }
249
+ ],
250
+ capabilities: [
251
+ "search nodes by keyword and structured filters",
252
+ "read node detail, related nodes, activities, artifacts, and governance summaries",
253
+ "create nodes, relations, activities, capture entries, and artifacts with provenance",
254
+ "upsert inferred relations and append relation usage signals for retrieval feedback",
255
+ "append search-result usefulness feedback for future ranking and governance",
256
+ "recompute automatic governance and inspect contested or low-confidence items",
257
+ "recompute inferred relation scores in an explicit maintenance pass",
258
+ "inspect semantic indexing status and queue bounded reindex passes",
259
+ "build compact context bundles for coding/research/writing",
260
+ "create or open workspaces without restarting the server"
261
+ ],
262
+ cli: {
263
+ binary: "recallx",
264
+ examples: [
265
+ `recallx health --api ${apiBaseUrl}`,
266
+ `recallx search --api ${apiBaseUrl} "agent memory"`,
267
+ `recallx search workspace --api ${apiBaseUrl} "cleanup"`,
268
+ `recallx create --api ${apiBaseUrl} --type note --title "Idea" --body "..."`,
269
+ `recallx context --api ${apiBaseUrl} <node-id> --mode compact --preset for-coding`,
270
+ `recallx governance issues --api ${apiBaseUrl}`,
271
+ `recallx workspace list --api ${apiBaseUrl}`,
272
+ `recallx observability summary --api ${apiBaseUrl} --since 24h`
273
+ ]
274
+ },
275
+ mcp: {
276
+ transport: "stdio",
277
+ command: "node dist/server/app/mcp/index.js",
278
+ env: {
279
+ RECALLX_API_URL: apiBaseUrl,
280
+ RECALLX_API_TOKEN: workspaceInfo.authMode === "bearer" ? "<set the active bearer token here>" : null
281
+ },
282
+ docs: "docs/mcp.md"
283
+ },
284
+ endpoints: [
285
+ {
286
+ method: "POST",
287
+ path: "/api/v1/nodes/search",
288
+ purpose: "Search nodes by keyword and filters.",
289
+ requestExample: {
290
+ query: "agent memory",
291
+ filters: {},
292
+ limit: 10,
293
+ offset: 0,
294
+ sort: "relevance"
295
+ }
296
+ },
297
+ {
298
+ method: "POST",
299
+ path: "/api/v1/nodes",
300
+ purpose: "Create a durable node.",
301
+ requestExample: {
302
+ type: "note",
303
+ title: "Example note",
304
+ body: "Shared memory for agents.",
305
+ tags: [],
306
+ source: {
307
+ actorType: "agent",
308
+ actorLabel: "Claude Code",
309
+ toolName: "claude-code"
310
+ },
311
+ metadata: {}
312
+ }
313
+ },
314
+ {
315
+ method: "POST",
316
+ path: "/api/v1/nodes/batch",
317
+ purpose: "Create multiple durable nodes with per-item landing or error details.",
318
+ requestExample: {
319
+ nodes: [
320
+ {
321
+ type: "note",
322
+ title: "Example note",
323
+ body: "Shared memory for agents.",
324
+ tags: [],
325
+ source: {
326
+ actorType: "agent",
327
+ actorLabel: "Claude Code",
328
+ toolName: "claude-code"
329
+ },
330
+ metadata: {}
331
+ }
332
+ ]
333
+ }
334
+ },
335
+ {
336
+ method: "POST",
337
+ path: "/api/v1/capture",
338
+ purpose: "Safely capture agent or system memory and let the server route it to activity or durable storage.",
339
+ requestExample: {
340
+ mode: "auto",
341
+ body: "Finished the MCP validation fix and updated the tests.",
342
+ metadata: {},
343
+ source: {
344
+ actorType: "agent",
345
+ actorLabel: "Claude Code",
346
+ toolName: "claude-code"
347
+ }
348
+ }
349
+ },
350
+ {
351
+ method: "GET",
352
+ path: "/api/v1/nodes/:id/neighborhood",
353
+ purpose: "Fetch lightweight canonical plus inferred neighborhood items for a node."
354
+ },
355
+ {
356
+ method: "GET",
357
+ path: "/api/v1/projects/:id/graph",
358
+ purpose: "Fetch a bounded project-scoped graph payload for the renderer project map."
359
+ },
360
+ {
361
+ method: "POST",
362
+ path: "/api/v1/nodes/:id/refresh-summary",
363
+ purpose: "Refresh a node summary locally using the deterministic stableSummary helper."
364
+ },
365
+ {
366
+ method: "POST",
367
+ path: "/api/v1/relations",
368
+ purpose: "Create a relation between two nodes.",
369
+ requestExample: {
370
+ fromNodeId: "node_...",
371
+ toNodeId: "node_...",
372
+ relationType: "supports",
373
+ status: "suggested",
374
+ source: {
375
+ actorType: "agent",
376
+ actorLabel: "Claude Code",
377
+ toolName: "claude-code"
378
+ },
379
+ metadata: {}
380
+ }
381
+ },
382
+ {
383
+ method: "POST",
384
+ path: "/api/v1/inferred-relations",
385
+ purpose: "Upsert a lightweight inferred relation for retrieval and graph expansion."
386
+ },
387
+ {
388
+ method: "POST",
389
+ path: "/api/v1/relation-usage-events",
390
+ purpose: "Append a lightweight usage signal for canonical or inferred relations."
391
+ },
392
+ {
393
+ method: "POST",
394
+ path: "/api/v1/search-feedback-events",
395
+ purpose: "Append a usefulness signal for a search result after it helped or failed a task."
396
+ },
397
+ {
398
+ method: "POST",
399
+ path: "/api/v1/activities/search",
400
+ purpose: "Search activities by keyword, provenance, target node, or time window."
401
+ },
402
+ {
403
+ method: "POST",
404
+ path: "/api/v1/search",
405
+ purpose: "Search nodes, activities, or both through a single workspace-wide endpoint."
406
+ },
407
+ {
408
+ method: "POST",
409
+ path: "/api/v1/inferred-relations/recompute",
410
+ purpose: "Run an explicit maintenance pass to refresh inferred relation scores from usage events."
411
+ },
412
+ {
413
+ method: "POST",
414
+ path: "/api/v1/inferred-relations/reindex",
415
+ purpose: "Backfill deterministic inferred relations across the active workspace."
416
+ },
417
+ {
418
+ method: "GET",
419
+ path: "/api/v1/semantic/status",
420
+ purpose: "Read semantic indexing provider settings and pending or stale queue counts."
421
+ },
422
+ {
423
+ method: "GET",
424
+ path: "/api/v1/semantic/issues?limit=5",
425
+ purpose: "Read a capped list of pending, stale, or failed semantic indexing items and their reasons."
426
+ },
427
+ {
428
+ method: "POST",
429
+ path: "/api/v1/semantic/reindex",
430
+ purpose: "Queue semantic reindexing for a bounded set of active workspace nodes.",
431
+ requestExample: {
432
+ limit: 250
433
+ }
434
+ },
435
+ {
436
+ method: "GET",
437
+ path: "/api/v1/governance/issues?limit=20",
438
+ purpose: "Read contested or low-confidence governance issues."
439
+ },
440
+ {
441
+ method: "GET",
442
+ path: "/api/v1/governance/state/node/:id",
443
+ purpose: "Read the current automatic governance state for a node."
444
+ },
445
+ {
446
+ method: "GET",
447
+ path: "/api/v1/observability/summary?since=24h",
448
+ purpose: "Read latency, error rate, fallback, and auto-job summaries from local telemetry logs."
449
+ },
450
+ {
451
+ method: "GET",
452
+ path: "/api/v1/observability/errors?since=24h&surface=mcp",
453
+ purpose: "Inspect recent telemetry errors for the API, MCP bridge, or desktop shell."
454
+ },
455
+ {
456
+ method: "POST",
457
+ path: "/api/v1/governance/recompute",
458
+ purpose: "Run a bounded automatic governance recompute pass."
459
+ },
460
+ {
461
+ method: "POST",
462
+ path: "/api/v1/context/bundles",
463
+ purpose: "Build compact context bundles for downstream agents.",
464
+ requestExample: {
465
+ target: {
466
+ id: "node_..."
467
+ },
468
+ mode: "compact",
469
+ preset: "for-coding",
470
+ options: {
471
+ includeRelated: true,
472
+ includeRecentActivities: true,
473
+ includeDecisions: true,
474
+ includeOpenQuestions: true,
475
+ maxItems: 12
476
+ }
477
+ }
478
+ },
479
+ {
480
+ method: "POST",
481
+ path: "/api/v1/workspaces",
482
+ purpose: "Create and switch to a new workspace at runtime.",
483
+ requestExample: {
484
+ rootPath: "/Users/name/Documents/RecallX-Work",
485
+ workspaceName: "Work"
486
+ }
487
+ },
488
+ {
489
+ method: "POST",
490
+ path: "/api/v1/workspaces/open",
491
+ purpose: "Switch the running service to another existing workspace.",
492
+ requestExample: {
493
+ rootPath: "/Users/name/Documents/RecallX-Personal"
494
+ }
495
+ }
496
+ ],
497
+ references: {
498
+ readme: "README.md",
499
+ cliGuide: "app/cli/README.md",
500
+ fullApiContract: "docs/api.md"
501
+ },
502
+ notes: [
503
+ "Do not expect GET search endpoints. Search is POST-based for nodes, activities, and workspace-wide queries.",
504
+ "Reuse the existing running local service instead of starting a second instance when possible.",
505
+ "All durable writes should include a source object for provenance.",
506
+ "Semantic reindex endpoints only queue work. They do not generate embeddings inline on the write path."
507
+ ]
508
+ };
509
+ }
510
+ export function createRecallXApp(params) {
511
+ const app = express();
512
+ const rendererDistDir = resolveRendererDistDir();
513
+ const rendererIndexPath = rendererDistDir ? path.join(rendererDistDir, "index.html") : null;
514
+ app.use((request, _response, next) => {
515
+ const origin = request.header("origin");
516
+ if (origin && !isAllowedBrowserOrigin(origin)) {
517
+ next(new AppError(403, "FORBIDDEN", "Browser origin is not allowed."));
518
+ return;
519
+ }
520
+ next();
521
+ });
522
+ app.use(cors({
523
+ origin(origin, callback) {
524
+ if (!origin) {
525
+ callback(null, false);
526
+ return;
527
+ }
528
+ callback(null, isAllowedBrowserOrigin(origin) ? origin : false);
529
+ }
530
+ }));
531
+ const currentSession = () => params.workspaceSessionManager.getCurrent();
532
+ const currentRepository = () => currentSession().repository;
533
+ const currentWorkspaceInfo = () => currentSession().workspaceInfo;
534
+ const currentWorkspaceRoot = () => currentSession().workspaceRoot;
535
+ const currentObservabilityConfig = () => {
536
+ const settings = currentRepository().getSettings([
537
+ "observability.enabled",
538
+ "observability.retentionDays",
539
+ "observability.slowRequestMs",
540
+ "observability.capturePayloadShape"
541
+ ]);
542
+ return {
543
+ enabled: isDesktopManagedApi ? parseBooleanSetting(settings["observability.enabled"], false) : true,
544
+ workspaceRoot: currentWorkspaceRoot(),
545
+ workspaceName: currentWorkspaceInfo().workspaceName,
546
+ retentionDays: Math.max(1, parseNumberSetting(settings["observability.retentionDays"], 14)),
547
+ slowRequestMs: Math.max(1, parseNumberSetting(settings["observability.slowRequestMs"], 250)),
548
+ capturePayloadShape: parseBooleanSetting(settings["observability.capturePayloadShape"], true)
549
+ };
550
+ };
551
+ const observability = createObservabilityWriter({
552
+ getState: currentObservabilityConfig
553
+ });
554
+ async function runObservedSpan(operation, details, callback) {
555
+ const span = observability.startSpan({
556
+ surface: "api",
557
+ operation,
558
+ details
559
+ });
560
+ try {
561
+ const result = await span.run(() => callback(span));
562
+ await span.finish({ outcome: "success" });
563
+ return result;
564
+ }
565
+ catch (error) {
566
+ const appError = error instanceof AppError
567
+ ? {
568
+ statusCode: error.statusCode,
569
+ errorCode: error.code,
570
+ errorKind: "app_error"
571
+ }
572
+ : error instanceof Error && "issues" in error
573
+ ? {
574
+ statusCode: 400,
575
+ errorCode: "INVALID_INPUT",
576
+ errorKind: "validation_error"
577
+ }
578
+ : {
579
+ statusCode: 500,
580
+ errorCode: "INTERNAL_ERROR",
581
+ errorKind: "unexpected_error"
582
+ };
583
+ await span.finish({
584
+ outcome: "error",
585
+ statusCode: appError.statusCode,
586
+ errorCode: appError.errorCode,
587
+ errorKind: appError.errorKind
588
+ });
589
+ throw error;
590
+ }
591
+ }
592
+ function handleAsyncRoute(handler) {
593
+ return (request, response, next) => {
594
+ void handler(request, response, next).catch(next);
595
+ };
596
+ }
597
+ const eventSubscribers = new Set();
598
+ const autoRecomputeState = {
599
+ workspaceRoot: null,
600
+ pendingRelationIds: new Set(),
601
+ pendingEventCount: 0,
602
+ earliestPendingEventAt: null,
603
+ latestPendingEventAt: null,
604
+ timer: null,
605
+ running: false
606
+ };
607
+ const autoRefreshState = {
608
+ workspaceRoot: null,
609
+ pendingNodeTriggers: new Map(),
610
+ earliestPendingAt: null,
611
+ latestPendingAt: null,
612
+ timer: null,
613
+ running: false
614
+ };
615
+ const autoSemanticIndexState = {
616
+ workspaceRoot: null,
617
+ timer: null,
618
+ running: false
619
+ };
620
+ function clearAutoRecomputeTimer() {
621
+ if (autoRecomputeState.timer) {
622
+ clearTimeout(autoRecomputeState.timer);
623
+ autoRecomputeState.timer = null;
624
+ }
625
+ }
626
+ function clearAutoRefreshTimer() {
627
+ if (autoRefreshState.timer) {
628
+ clearTimeout(autoRefreshState.timer);
629
+ autoRefreshState.timer = null;
630
+ }
631
+ }
632
+ function clearAutoSemanticIndexTimer() {
633
+ if (autoSemanticIndexState.timer) {
634
+ clearTimeout(autoSemanticIndexState.timer);
635
+ autoSemanticIndexState.timer = null;
636
+ }
637
+ }
638
+ function resetAutoRecomputeState(workspaceRoot) {
639
+ clearAutoRecomputeTimer();
640
+ autoRecomputeState.workspaceRoot = workspaceRoot;
641
+ autoRecomputeState.pendingRelationIds = new Set();
642
+ autoRecomputeState.pendingEventCount = 0;
643
+ autoRecomputeState.earliestPendingEventAt = null;
644
+ autoRecomputeState.latestPendingEventAt = null;
645
+ autoRecomputeState.running = false;
646
+ }
647
+ function resetAutoRefreshState(workspaceRoot) {
648
+ clearAutoRefreshTimer();
649
+ autoRefreshState.workspaceRoot = workspaceRoot;
650
+ autoRefreshState.pendingNodeTriggers = new Map();
651
+ autoRefreshState.earliestPendingAt = null;
652
+ autoRefreshState.latestPendingAt = null;
653
+ autoRefreshState.running = false;
654
+ }
655
+ function resetAutoSemanticIndexState(workspaceRoot) {
656
+ clearAutoSemanticIndexTimer();
657
+ autoSemanticIndexState.workspaceRoot = workspaceRoot;
658
+ autoSemanticIndexState.running = false;
659
+ }
660
+ function readAutoRecomputeConfig() {
661
+ const settings = currentRepository().getSettings([
662
+ "relations.autoRecompute.enabled",
663
+ "relations.autoRecompute.eventThreshold",
664
+ "relations.autoRecompute.debounceMs",
665
+ "relations.autoRecompute.maxStalenessMs",
666
+ "relations.autoRecompute.batchLimit",
667
+ "relations.autoRecompute.lastRunAt"
668
+ ]);
669
+ return {
670
+ enabled: parseBooleanSetting(settings["relations.autoRecompute.enabled"], true),
671
+ eventThreshold: parseNumberSetting(settings["relations.autoRecompute.eventThreshold"], 12),
672
+ debounceMs: parseNumberSetting(settings["relations.autoRecompute.debounceMs"], 30_000),
673
+ maxStalenessMs: parseNumberSetting(settings["relations.autoRecompute.maxStalenessMs"], 300_000),
674
+ batchLimit: parseNumberSetting(settings["relations.autoRecompute.batchLimit"], 100),
675
+ lastRunAt: typeof settings["relations.autoRecompute.lastRunAt"] === "string"
676
+ ? String(settings["relations.autoRecompute.lastRunAt"])
677
+ : null
678
+ };
679
+ }
680
+ function readAutoRefreshConfig() {
681
+ const settings = currentRepository().getSettings([
682
+ "relations.autoRefresh.enabled",
683
+ "relations.autoRefresh.debounceMs",
684
+ "relations.autoRefresh.maxStalenessMs",
685
+ "relations.autoRefresh.batchLimit"
686
+ ]);
687
+ return {
688
+ enabled: parseBooleanSetting(settings["relations.autoRefresh.enabled"], true),
689
+ debounceMs: parseNumberSetting(settings["relations.autoRefresh.debounceMs"], 150),
690
+ maxStalenessMs: parseNumberSetting(settings["relations.autoRefresh.maxStalenessMs"], 2_000),
691
+ batchLimit: parseNumberSetting(settings["relations.autoRefresh.batchLimit"], 24)
692
+ };
693
+ }
694
+ function readAutoSemanticIndexConfig() {
695
+ const settings = currentRepository().getSettings([
696
+ "search.semantic.autoIndex.enabled",
697
+ "search.semantic.autoIndex.debounceMs",
698
+ "search.semantic.autoIndex.batchLimit",
699
+ "search.semantic.autoIndex.lastRunAt"
700
+ ]);
701
+ return {
702
+ enabled: parseBooleanSetting(settings["search.semantic.autoIndex.enabled"], true),
703
+ debounceMs: Math.max(100, parseNumberSetting(settings["search.semantic.autoIndex.debounceMs"], 1_500)),
704
+ batchLimit: Math.max(1, parseNumberSetting(settings["search.semantic.autoIndex.batchLimit"], 20)),
705
+ lastRunAt: typeof settings["search.semantic.autoIndex.lastRunAt"] === "string"
706
+ ? String(settings["search.semantic.autoIndex.lastRunAt"])
707
+ : null
708
+ };
709
+ }
710
+ function buildAutoRecomputeStatus() {
711
+ const config = readAutoRecomputeConfig();
712
+ return {
713
+ enabled: config.enabled,
714
+ eventThreshold: config.eventThreshold,
715
+ debounceMs: config.debounceMs,
716
+ maxStalenessMs: config.maxStalenessMs,
717
+ batchLimit: config.batchLimit,
718
+ lastRunAt: config.lastRunAt,
719
+ pendingEventCount: autoRecomputeState.pendingEventCount,
720
+ pendingRelationCount: autoRecomputeState.pendingRelationIds.size,
721
+ earliestPendingEventAt: autoRecomputeState.earliestPendingEventAt,
722
+ latestPendingEventAt: autoRecomputeState.latestPendingEventAt,
723
+ running: autoRecomputeState.running
724
+ };
725
+ }
726
+ function buildAutoRefreshStatus() {
727
+ const config = readAutoRefreshConfig();
728
+ return {
729
+ enabled: config.enabled,
730
+ debounceMs: config.debounceMs,
731
+ maxStalenessMs: config.maxStalenessMs,
732
+ batchLimit: config.batchLimit,
733
+ pendingNodeCount: autoRefreshState.pendingNodeTriggers.size,
734
+ earliestPendingAt: autoRefreshState.earliestPendingAt,
735
+ latestPendingAt: autoRefreshState.latestPendingAt,
736
+ running: autoRefreshState.running
737
+ };
738
+ }
739
+ function buildAutoSemanticIndexStatus() {
740
+ const config = readAutoSemanticIndexConfig();
741
+ return {
742
+ enabled: config.enabled,
743
+ debounceMs: config.debounceMs,
744
+ batchLimit: config.batchLimit,
745
+ lastRunAt: config.lastRunAt,
746
+ running: autoSemanticIndexState.running
747
+ };
748
+ }
749
+ function markPendingRelationUsage(params) {
750
+ const workspaceRoot = currentWorkspaceRoot();
751
+ if (autoRecomputeState.workspaceRoot !== workspaceRoot) {
752
+ resetAutoRecomputeState(workspaceRoot);
753
+ }
754
+ autoRecomputeState.pendingRelationIds.add(params.relationId);
755
+ autoRecomputeState.pendingEventCount += 1;
756
+ if (!autoRecomputeState.earliestPendingEventAt || params.createdAt < autoRecomputeState.earliestPendingEventAt) {
757
+ autoRecomputeState.earliestPendingEventAt = params.createdAt;
758
+ }
759
+ if (!autoRecomputeState.latestPendingEventAt || params.createdAt > autoRecomputeState.latestPendingEventAt) {
760
+ autoRecomputeState.latestPendingEventAt = params.createdAt;
761
+ }
762
+ }
763
+ function mergeRefreshTrigger(current, next) {
764
+ if (current === "activity-append" || next === "activity-append") {
765
+ return "activity-append";
766
+ }
767
+ return "node-write";
768
+ }
769
+ function markPendingInferredRefresh(nodeId, trigger) {
770
+ const workspaceRoot = currentWorkspaceRoot();
771
+ if (autoRefreshState.workspaceRoot !== workspaceRoot) {
772
+ resetAutoRefreshState(workspaceRoot);
773
+ }
774
+ const now = new Date().toISOString();
775
+ autoRefreshState.pendingNodeTriggers.set(nodeId, mergeRefreshTrigger(autoRefreshState.pendingNodeTriggers.get(nodeId), trigger));
776
+ if (!autoRefreshState.earliestPendingAt || now < autoRefreshState.earliestPendingAt) {
777
+ autoRefreshState.earliestPendingAt = now;
778
+ }
779
+ if (!autoRefreshState.latestPendingAt || now > autoRefreshState.latestPendingAt) {
780
+ autoRefreshState.latestPendingAt = now;
781
+ }
782
+ }
783
+ function scheduleAutoRecompute() {
784
+ const config = readAutoRecomputeConfig();
785
+ clearAutoRecomputeTimer();
786
+ if (!config.enabled || autoRecomputeState.running || autoRecomputeState.pendingEventCount === 0) {
787
+ return;
788
+ }
789
+ const now = Date.now();
790
+ const latestMs = autoRecomputeState.latestPendingEventAt ? Date.parse(autoRecomputeState.latestPendingEventAt) : null;
791
+ const earliestMs = autoRecomputeState.earliestPendingEventAt ? Date.parse(autoRecomputeState.earliestPendingEventAt) : null;
792
+ const thresholdReached = autoRecomputeState.pendingEventCount >= config.eventThreshold;
793
+ const dueDebounceMs = thresholdReached && latestMs ? Math.max(0, latestMs + config.debounceMs - now) : Number.POSITIVE_INFINITY;
794
+ const dueStalenessMs = earliestMs ? Math.max(0, earliestMs + config.maxStalenessMs - now) : Number.POSITIVE_INFINITY;
795
+ const nextDelayMs = Math.min(dueDebounceMs, dueStalenessMs);
796
+ if (!Number.isFinite(nextDelayMs)) {
797
+ return;
798
+ }
799
+ autoRecomputeState.timer = setTimeout(() => {
800
+ void runAutoRecompute("auto");
801
+ }, nextDelayMs);
802
+ autoRecomputeState.timer.unref?.();
803
+ }
804
+ function scheduleAutoRefresh() {
805
+ const config = readAutoRefreshConfig();
806
+ clearAutoRefreshTimer();
807
+ if (!config.enabled || autoRefreshState.running || autoRefreshState.pendingNodeTriggers.size === 0) {
808
+ return;
809
+ }
810
+ const now = Date.now();
811
+ const latestMs = autoRefreshState.latestPendingAt ? Date.parse(autoRefreshState.latestPendingAt) : null;
812
+ const earliestMs = autoRefreshState.earliestPendingAt ? Date.parse(autoRefreshState.earliestPendingAt) : null;
813
+ const dueDebounceMs = latestMs ? Math.max(0, latestMs + config.debounceMs - now) : Number.POSITIVE_INFINITY;
814
+ const dueStalenessMs = earliestMs ? Math.max(0, earliestMs + config.maxStalenessMs - now) : Number.POSITIVE_INFINITY;
815
+ const nextDelayMs = Math.min(dueDebounceMs, dueStalenessMs);
816
+ if (!Number.isFinite(nextDelayMs)) {
817
+ return;
818
+ }
819
+ autoRefreshState.timer = setTimeout(() => {
820
+ void runAutoRefresh();
821
+ }, nextDelayMs);
822
+ autoRefreshState.timer.unref?.();
823
+ }
824
+ function scheduleAutoSemanticIndex() {
825
+ const config = readAutoSemanticIndexConfig();
826
+ clearAutoSemanticIndexTimer();
827
+ const semanticStatus = currentRepository().getSemanticStatus();
828
+ const pendingCount = semanticStatus.counts.pending + semanticStatus.counts.stale;
829
+ const workerActive = semanticStatus.enabled || semanticStatus.chunkEnabled;
830
+ if (!config.enabled || !workerActive || autoSemanticIndexState.running || pendingCount === 0) {
831
+ return;
832
+ }
833
+ autoSemanticIndexState.timer = setTimeout(() => {
834
+ void runAutoSemanticIndex();
835
+ }, config.debounceMs);
836
+ autoSemanticIndexState.timer.unref?.();
837
+ }
838
+ function hydrateAutoRecomputeState() {
839
+ const workspaceRoot = currentWorkspaceRoot();
840
+ if (autoRecomputeState.workspaceRoot !== workspaceRoot) {
841
+ resetAutoRecomputeState(workspaceRoot);
842
+ }
843
+ const config = readAutoRecomputeConfig();
844
+ if (!config.enabled) {
845
+ clearAutoRecomputeTimer();
846
+ autoRecomputeState.pendingRelationIds.clear();
847
+ autoRecomputeState.pendingEventCount = 0;
848
+ autoRecomputeState.earliestPendingEventAt = null;
849
+ autoRecomputeState.latestPendingEventAt = null;
850
+ return;
851
+ }
852
+ const pending = currentRepository().getPendingRelationUsageStats(config.lastRunAt);
853
+ autoRecomputeState.pendingRelationIds = new Set(pending.relationIds);
854
+ autoRecomputeState.pendingEventCount = pending.eventCount;
855
+ autoRecomputeState.earliestPendingEventAt = pending.earliestEventAt;
856
+ autoRecomputeState.latestPendingEventAt = pending.latestEventAt;
857
+ scheduleAutoRecompute();
858
+ }
859
+ function hydrateAutoRefreshState() {
860
+ const workspaceRoot = currentWorkspaceRoot();
861
+ if (autoRefreshState.workspaceRoot !== workspaceRoot) {
862
+ resetAutoRefreshState(workspaceRoot);
863
+ }
864
+ const config = readAutoRefreshConfig();
865
+ if (!config.enabled) {
866
+ clearAutoRefreshTimer();
867
+ autoRefreshState.pendingNodeTriggers.clear();
868
+ autoRefreshState.earliestPendingAt = null;
869
+ autoRefreshState.latestPendingAt = null;
870
+ return;
871
+ }
872
+ scheduleAutoRefresh();
873
+ }
874
+ function hydrateAutoSemanticIndexState() {
875
+ const workspaceRoot = currentWorkspaceRoot();
876
+ if (autoSemanticIndexState.workspaceRoot !== workspaceRoot) {
877
+ resetAutoSemanticIndexState(workspaceRoot);
878
+ }
879
+ const config = readAutoSemanticIndexConfig();
880
+ if (!config.enabled) {
881
+ clearAutoSemanticIndexTimer();
882
+ return;
883
+ }
884
+ scheduleAutoSemanticIndex();
885
+ }
886
+ async function runAutoRecompute(reason) {
887
+ if (autoRecomputeState.running) {
888
+ return;
889
+ }
890
+ const config = readAutoRecomputeConfig();
891
+ if (!config.enabled && reason === "auto") {
892
+ return;
893
+ }
894
+ clearAutoRecomputeTimer();
895
+ autoRecomputeState.running = true;
896
+ const startedAt = new Date().toISOString();
897
+ try {
898
+ return await runObservedSpan("auto.recompute_inferred_relations", {
899
+ trigger: reason,
900
+ batchLimit: config.batchLimit
901
+ }, (span) => {
902
+ const pending = currentRepository().getPendingRelationUsageStats(config.lastRunAt);
903
+ const aggregate = {
904
+ updatedCount: 0,
905
+ expiredCount: 0,
906
+ items: []
907
+ };
908
+ const appendResult = (result) => {
909
+ aggregate.updatedCount += result.updatedCount;
910
+ aggregate.expiredCount += result.expiredCount;
911
+ aggregate.items.push(...result.items);
912
+ };
913
+ if (reason === "manual" && pending.relationIds.length === 0) {
914
+ const totalRelations = currentRepository().countInferredRelations("active");
915
+ if (totalRelations === 0) {
916
+ currentRepository().setSetting("relations.autoRecompute.lastRunAt", startedAt);
917
+ span.addDetails({
918
+ pendingRelationCount: 0,
919
+ processedRelationCount: 0,
920
+ batchCount: 0
921
+ });
922
+ return aggregate;
923
+ }
924
+ const totalBatches = Math.ceil(totalRelations / config.batchLimit);
925
+ for (let batchIndex = 0; batchIndex < totalBatches; batchIndex += 1) {
926
+ appendResult(currentRepository().recomputeInferredRelationScores({
927
+ limit: config.batchLimit
928
+ }));
929
+ }
930
+ currentRepository().setSetting("relations.autoRecompute.lastRunAt", startedAt);
931
+ broadcastWorkspaceEvent({
932
+ reason: "inferred-relation.recomputed",
933
+ entityType: "relation"
934
+ });
935
+ span.addDetails({
936
+ pendingRelationCount: totalRelations,
937
+ processedRelationCount: aggregate.items.length,
938
+ batchCount: totalBatches
939
+ });
940
+ return aggregate;
941
+ }
942
+ if (pending.relationIds.length === 0) {
943
+ currentRepository().setSetting("relations.autoRecompute.lastRunAt", startedAt);
944
+ span.addDetails({
945
+ pendingRelationCount: 0,
946
+ processedRelationCount: 0,
947
+ batchCount: 0
948
+ });
949
+ return aggregate;
950
+ }
951
+ for (let index = 0; index < pending.relationIds.length; index += config.batchLimit) {
952
+ appendResult(currentRepository().recomputeInferredRelationScores({
953
+ relationIds: pending.relationIds.slice(index, index + config.batchLimit),
954
+ limit: config.batchLimit
955
+ }));
956
+ }
957
+ currentRepository().setSetting("relations.autoRecompute.lastRunAt", startedAt);
958
+ broadcastWorkspaceEvent({
959
+ reason: reason === "auto" ? "inferred-relation.auto-recomputed" : "inferred-relation.recomputed",
960
+ entityType: "relation"
961
+ });
962
+ span.addDetails({
963
+ pendingRelationCount: pending.relationIds.length,
964
+ processedRelationCount: aggregate.items.length,
965
+ batchCount: Math.ceil(pending.relationIds.length / config.batchLimit)
966
+ });
967
+ return aggregate;
968
+ });
969
+ }
970
+ finally {
971
+ autoRecomputeState.running = false;
972
+ hydrateAutoRecomputeState();
973
+ }
974
+ }
975
+ async function runAutoRefresh() {
976
+ if (autoRefreshState.running) {
977
+ return;
978
+ }
979
+ const config = readAutoRefreshConfig();
980
+ if (!config.enabled || autoRefreshState.pendingNodeTriggers.size === 0) {
981
+ return;
982
+ }
983
+ clearAutoRefreshTimer();
984
+ autoRefreshState.running = true;
985
+ try {
986
+ await runObservedSpan("auto.refresh_inferred_relations", {
987
+ batchLimit: config.batchLimit
988
+ }, (span) => {
989
+ const batch = Array.from(autoRefreshState.pendingNodeTriggers.entries()).slice(0, config.batchLimit);
990
+ for (const [nodeId] of batch) {
991
+ autoRefreshState.pendingNodeTriggers.delete(nodeId);
992
+ }
993
+ if (autoRefreshState.pendingNodeTriggers.size === 0) {
994
+ autoRefreshState.earliestPendingAt = null;
995
+ autoRefreshState.latestPendingAt = null;
996
+ }
997
+ const repository = currentRepository();
998
+ const touchedRelationIds = new Set();
999
+ let processedNodes = 0;
1000
+ for (const [nodeId, trigger] of batch) {
1001
+ try {
1002
+ const result = refreshAutomaticInferredRelationsForNode(repository, nodeId, trigger);
1003
+ processedNodes += 1;
1004
+ for (const relationId of result.relationIds) {
1005
+ touchedRelationIds.add(relationId);
1006
+ }
1007
+ }
1008
+ catch (error) {
1009
+ console.error(`Failed to refresh inferred relations for node ${nodeId}`, error);
1010
+ }
1011
+ }
1012
+ if (processedNodes > 0) {
1013
+ broadcastWorkspaceEvent({
1014
+ reason: "inferred-relation.auto-refreshed",
1015
+ entityType: "relation"
1016
+ });
1017
+ }
1018
+ if (touchedRelationIds.size > 0) {
1019
+ scheduleAutoRecompute();
1020
+ }
1021
+ span.addDetails({
1022
+ batchSize: batch.length,
1023
+ processedNodes,
1024
+ touchedRelationCount: touchedRelationIds.size
1025
+ });
1026
+ });
1027
+ }
1028
+ finally {
1029
+ autoRefreshState.running = false;
1030
+ hydrateAutoRefreshState();
1031
+ }
1032
+ }
1033
+ async function runAutoSemanticIndex() {
1034
+ if (autoSemanticIndexState.running) {
1035
+ return;
1036
+ }
1037
+ const config = readAutoSemanticIndexConfig();
1038
+ if (!config.enabled) {
1039
+ return;
1040
+ }
1041
+ clearAutoSemanticIndexTimer();
1042
+ autoSemanticIndexState.running = true;
1043
+ const startedAt = new Date().toISOString();
1044
+ let shouldHydrate = true;
1045
+ try {
1046
+ try {
1047
+ return await runObservedSpan("auto.semantic_index", {
1048
+ batchLimit: config.batchLimit
1049
+ }, async (span) => {
1050
+ const result = await currentRepository().processPendingSemanticIndex(config.batchLimit);
1051
+ currentRepository().setSetting("search.semantic.autoIndex.lastRunAt", startedAt);
1052
+ if (result.processedCount > 0) {
1053
+ broadcastWorkspaceEvent({
1054
+ reason: "semantic.auto-indexed",
1055
+ entityType: "settings"
1056
+ });
1057
+ }
1058
+ if (result.remainingCount > 0) {
1059
+ scheduleAutoSemanticIndex();
1060
+ }
1061
+ span.addDetails({
1062
+ batchSize: config.batchLimit,
1063
+ processedCount: result.processedCount,
1064
+ remainingCount: result.remainingCount
1065
+ });
1066
+ return result;
1067
+ });
1068
+ }
1069
+ catch (error) {
1070
+ shouldHydrate = false;
1071
+ console.error("Failed to process semantic index backlog", error);
1072
+ }
1073
+ }
1074
+ finally {
1075
+ autoSemanticIndexState.running = false;
1076
+ if (shouldHydrate) {
1077
+ hydrateAutoSemanticIndexState();
1078
+ }
1079
+ else {
1080
+ clearAutoSemanticIndexTimer();
1081
+ }
1082
+ }
1083
+ }
1084
+ function queueInferredRefresh(nodeId, trigger) {
1085
+ markPendingInferredRefresh(nodeId, trigger);
1086
+ scheduleAutoRefresh();
1087
+ }
1088
+ function queueInferredRefreshForNodes(nodeIds, trigger) {
1089
+ for (const nodeId of new Set(nodeIds)) {
1090
+ queueInferredRefresh(nodeId, trigger);
1091
+ }
1092
+ }
1093
+ function broadcastWorkspaceEvent(event) {
1094
+ const payload = {
1095
+ type: "workspace.updated",
1096
+ workspaceRoot: currentWorkspaceRoot(),
1097
+ at: new Date().toISOString(),
1098
+ ...event
1099
+ };
1100
+ for (const subscriber of eventSubscribers) {
1101
+ try {
1102
+ subscriber.write(`event: workspace.updated\n`);
1103
+ subscriber.write(`data: ${JSON.stringify(payload)}\n\n`);
1104
+ }
1105
+ catch {
1106
+ eventSubscribers.delete(subscriber);
1107
+ }
1108
+ }
1109
+ }
1110
+ function refreshWorkspaceState() {
1111
+ hydrateAutoRecomputeState();
1112
+ hydrateAutoRefreshState();
1113
+ hydrateAutoSemanticIndexState();
1114
+ }
1115
+ function buildWorkspaceMutationPayload(workspace) {
1116
+ return {
1117
+ workspace,
1118
+ current: currentWorkspaceInfo(),
1119
+ items: params.workspaceSessionManager.listWorkspaces()
1120
+ };
1121
+ }
1122
+ function commitWorkspaceMutation(response, workspace, reason, statusCode = 200) {
1123
+ refreshWorkspaceState();
1124
+ broadcastWorkspaceEvent({
1125
+ reason,
1126
+ entityType: "workspace"
1127
+ });
1128
+ response.status(statusCode).json(envelope(response.locals.requestId, buildWorkspaceMutationPayload(workspace)));
1129
+ }
1130
+ function recomputeGovernanceForEntities(entityType, entityIds) {
1131
+ const repository = currentRepository();
1132
+ if (!entityIds.length) {
1133
+ return {
1134
+ updatedCount: 0,
1135
+ promotedCount: 0,
1136
+ contestedCount: 0,
1137
+ items: []
1138
+ };
1139
+ }
1140
+ return recomputeAutomaticGovernance(repository, {
1141
+ entityType,
1142
+ entityIds,
1143
+ limit: Math.max(entityIds.length, 1)
1144
+ });
1145
+ }
1146
+ function buildGovernancePayload(repository, entityType, entityId, preferredState) {
1147
+ return {
1148
+ state: preferredState ?? repository.getGovernanceStateNullable(entityType, entityId),
1149
+ events: repository.listGovernanceEvents(entityType, entityId, 10)
1150
+ };
1151
+ }
1152
+ function buildLandingPayload(input) {
1153
+ return {
1154
+ storedAs: input.storedAs,
1155
+ canonicality: input.canonicality,
1156
+ status: input.status,
1157
+ governanceState: input.governanceState,
1158
+ reason: input.reason
1159
+ };
1160
+ }
1161
+ function createDurableNodeResponse(repository, input) {
1162
+ const governance = resolveNodeGovernance(input, resolveGovernancePolicy(repository.getSettings(["review.autoApproveLowRisk", "review.trustedSourceToolNames"])));
1163
+ const node = repository.createNode({
1164
+ ...input,
1165
+ resolvedCanonicality: governance.canonicality,
1166
+ resolvedStatus: governance.status
1167
+ });
1168
+ repository.recordProvenance({
1169
+ entityType: "node",
1170
+ entityId: node.id,
1171
+ operationType: "create",
1172
+ source: input.source,
1173
+ metadata: {
1174
+ reason: governance.reason
1175
+ }
1176
+ });
1177
+ const governanceResult = recomputeGovernanceForEntities("node", [node.id]);
1178
+ queueInferredRefresh(node.id, "node-write");
1179
+ scheduleAutoSemanticIndex();
1180
+ broadcastWorkspaceEvent({
1181
+ reason: "node.created",
1182
+ entityType: "node",
1183
+ entityId: node.id
1184
+ });
1185
+ const storedNode = governanceResult.updatedNodes?.get(node.id) ?? node;
1186
+ const governancePayload = buildGovernancePayload(repository, "node", node.id, governanceResult.items[0] ?? repository.getGovernanceStateNullable("node", node.id));
1187
+ return {
1188
+ node: storedNode,
1189
+ governance: governancePayload,
1190
+ landing: buildLandingPayload({
1191
+ storedAs: "node",
1192
+ canonicality: storedNode.canonicality,
1193
+ status: storedNode.status,
1194
+ governanceState: governancePayload.state?.state ?? null,
1195
+ reason: governance.reason
1196
+ })
1197
+ };
1198
+ }
1199
+ hydrateAutoRecomputeState();
1200
+ hydrateAutoRefreshState();
1201
+ hydrateAutoSemanticIndexState();
1202
+ app.use((request, response, next) => {
1203
+ const requestId = createId("req");
1204
+ const traceId = request.header("x-recallx-trace-id")?.trim() || createId("trace");
1205
+ const operation = `${request.method.toUpperCase()} ${normalizeApiRequestPath(request.path)}`;
1206
+ const observabilityState = currentObservabilityConfig();
1207
+ const requestSpan = observability.startSpan({
1208
+ surface: "api",
1209
+ operation,
1210
+ requestId,
1211
+ traceId,
1212
+ details: {
1213
+ ...(observabilityState.capturePayloadShape ? summarizePayloadShape(request.body) : {}),
1214
+ mcpTool: request.header("x-recallx-mcp-tool") ?? null
1215
+ }
1216
+ });
1217
+ response.locals.requestId = requestId;
1218
+ response.locals.traceId = traceId;
1219
+ response.locals.telemetryRequestSpan = requestSpan;
1220
+ response.setHeader("x-recallx-request-id", requestId);
1221
+ response.setHeader("x-recallx-trace-id", traceId);
1222
+ response.on("finish", () => {
1223
+ void requestSpan.finish({
1224
+ outcome: response.statusCode >= 400 ? "error" : "success",
1225
+ statusCode: response.statusCode,
1226
+ errorCode: response.locals.telemetryErrorCode ?? null,
1227
+ errorKind: response.locals.telemetryErrorKind ?? null
1228
+ });
1229
+ });
1230
+ observability.withContext({
1231
+ traceId,
1232
+ requestId,
1233
+ workspaceRoot: currentWorkspaceRoot(),
1234
+ workspaceName: currentWorkspaceInfo().workspaceName,
1235
+ toolName: request.header("x-recallx-mcp-tool") ?? null,
1236
+ surface: "api"
1237
+ }, next);
1238
+ });
1239
+ app.use(express.json({ limit: "2mb" }));
1240
+ app.use("/api/v1", (request, response, next) => {
1241
+ if (!params.apiToken) {
1242
+ next();
1243
+ return;
1244
+ }
1245
+ if (request.path === "/health" || request.path === "/bootstrap") {
1246
+ next();
1247
+ return;
1248
+ }
1249
+ const providedToken = readBearerToken(request);
1250
+ if (providedToken !== params.apiToken) {
1251
+ next(new AppError(401, "UNAUTHORIZED", "Missing or invalid bearer token."));
1252
+ return;
1253
+ }
1254
+ next();
1255
+ });
1256
+ app.get("/api/v1/health", (_request, response) => {
1257
+ const workspaceInfo = currentWorkspaceInfo();
1258
+ const authenticated = isAuthenticatedApiRequest(_request, params.apiToken);
1259
+ if (!authenticated && params.apiToken) {
1260
+ response.json(envelope(response.locals.requestId, {
1261
+ status: "ok",
1262
+ workspaceLoaded: true,
1263
+ authMode: workspaceInfo.authMode
1264
+ }));
1265
+ return;
1266
+ }
1267
+ response.json(envelope(response.locals.requestId, {
1268
+ status: "ok",
1269
+ workspaceLoaded: true,
1270
+ workspaceRoot: workspaceInfo.rootPath,
1271
+ schemaVersion: workspaceInfo.schemaVersion,
1272
+ autoRecompute: buildAutoRecomputeStatus(),
1273
+ autoRefresh: buildAutoRefreshStatus(),
1274
+ autoSemanticIndex: buildAutoSemanticIndexStatus(),
1275
+ semantic: currentRepository().getSemanticStatus()
1276
+ }));
1277
+ });
1278
+ app.get("/api/v1", (_request, response) => {
1279
+ response.json(envelope(response.locals.requestId, buildServiceIndex(currentWorkspaceInfo())));
1280
+ });
1281
+ app.get("/api/v1/workspace", (_request, response) => {
1282
+ response.json(envelope(response.locals.requestId, currentWorkspaceInfo()));
1283
+ });
1284
+ app.get("/api/v1/bootstrap", (request, response) => {
1285
+ const workspaceInfo = currentWorkspaceInfo();
1286
+ const authenticated = isAuthenticatedApiRequest(request, params.apiToken);
1287
+ if (!authenticated && params.apiToken) {
1288
+ response.json(envelope(response.locals.requestId, {
1289
+ workspace: {
1290
+ workspaceName: workspaceInfo.workspaceName,
1291
+ bindAddress: workspaceInfo.bindAddress,
1292
+ authMode: workspaceInfo.authMode,
1293
+ enabledIntegrationModes: workspaceInfo.enabledIntegrationModes,
1294
+ workspaceKey: currentRepository().getWorkspaceKey()
1295
+ },
1296
+ authMode: workspaceInfo.authMode
1297
+ }));
1298
+ return;
1299
+ }
1300
+ response.json(envelope(response.locals.requestId, {
1301
+ workspace: workspaceInfo,
1302
+ authMode: workspaceInfo.authMode,
1303
+ autoRecompute: buildAutoRecomputeStatus(),
1304
+ autoRefresh: buildAutoRefreshStatus(),
1305
+ autoSemanticIndex: buildAutoSemanticIndexStatus(),
1306
+ semantic: currentRepository().getSemanticStatus()
1307
+ }));
1308
+ });
1309
+ app.get("/api/v1/semantic/status", (_request, response) => {
1310
+ response.json(envelope(response.locals.requestId, currentRepository().getSemanticStatus()));
1311
+ });
1312
+ app.get("/api/v1/semantic/issues", (request, response) => {
1313
+ const limit = parseClampedNumber(request.query.limit, 5, 1, 25);
1314
+ const cursor = typeof request.query.cursor === "string" && request.query.cursor.trim() ? request.query.cursor : null;
1315
+ const statuses = parseSemanticIssueStatuses(request.query.statuses);
1316
+ response.json(envelope(response.locals.requestId, currentRepository().listSemanticIssues({
1317
+ limit,
1318
+ cursor,
1319
+ statuses
1320
+ })));
1321
+ });
1322
+ app.post("/api/v1/semantic/reindex", (request, response) => {
1323
+ const limit = parseClampedNumber(request.body?.limit, 250, 1, 1000);
1324
+ const result = currentRepository().queueSemanticReindex(limit);
1325
+ broadcastWorkspaceEvent({
1326
+ reason: "semantic.reindex_queued",
1327
+ entityType: "settings"
1328
+ });
1329
+ scheduleAutoSemanticIndex();
1330
+ response.json(envelope(response.locals.requestId, result));
1331
+ });
1332
+ app.post("/api/v1/semantic/reindex/:nodeId", (request, response) => {
1333
+ const node = currentRepository().queueSemanticReindexForNode(request.params.nodeId);
1334
+ broadcastWorkspaceEvent({
1335
+ reason: "semantic.node_reindex_queued",
1336
+ entityType: "node",
1337
+ entityId: node.id
1338
+ });
1339
+ scheduleAutoSemanticIndex();
1340
+ response.json(envelope(response.locals.requestId, {
1341
+ nodeId: node.id,
1342
+ queued: true
1343
+ }));
1344
+ });
1345
+ app.get("/api/v1/events", (request, response) => {
1346
+ if (params.apiToken && !isAuthenticatedApiRequest(request, params.apiToken)) {
1347
+ response.status(401).json(errorEnvelope(response.locals.requestId, new AppError(401, "UNAUTHORIZED", "Missing or invalid bearer token.")));
1348
+ return;
1349
+ }
1350
+ response.setHeader("Content-Type", "text/event-stream");
1351
+ response.setHeader("Cache-Control", "no-cache, no-transform");
1352
+ response.setHeader("Connection", "keep-alive");
1353
+ response.setHeader("X-Accel-Buffering", "no");
1354
+ response.flushHeaders?.();
1355
+ response.write("retry: 3000\n");
1356
+ response.write(": connected\n\n");
1357
+ const heartbeatId = setInterval(() => {
1358
+ response.write(": keep-alive\n\n");
1359
+ }, 25000);
1360
+ eventSubscribers.add(response);
1361
+ request.on("close", () => {
1362
+ clearInterval(heartbeatId);
1363
+ eventSubscribers.delete(response);
1364
+ });
1365
+ });
1366
+ app.get("/api/v1/workspaces", (_request, response) => {
1367
+ response.json(envelope(response.locals.requestId, {
1368
+ current: currentWorkspaceInfo(),
1369
+ items: params.workspaceSessionManager.listWorkspaces()
1370
+ }));
1371
+ });
1372
+ app.post("/api/v1/workspaces", (request, response) => {
1373
+ const input = createWorkspaceSchema.parse(request.body ?? {});
1374
+ const workspace = params.workspaceSessionManager.createWorkspace(input.rootPath, input.workspaceName);
1375
+ commitWorkspaceMutation(response, workspace, "workspace.created", 201);
1376
+ });
1377
+ app.post("/api/v1/workspaces/open", (request, response) => {
1378
+ const input = openWorkspaceSchema.parse(request.body ?? {});
1379
+ const workspace = params.workspaceSessionManager.openWorkspace(input.rootPath);
1380
+ commitWorkspaceMutation(response, workspace, "workspace.opened");
1381
+ });
1382
+ app.get("/api/v1/observability/summary", handleAsyncRoute(async (request, response) => {
1383
+ const summary = await observability.summarize({
1384
+ since: readRequestParam(request.query.since),
1385
+ surface: "all"
1386
+ });
1387
+ response.json(envelope(response.locals.requestId, summary));
1388
+ }));
1389
+ app.get("/api/v1/observability/errors", handleAsyncRoute(async (request, response) => {
1390
+ const surface = readRequestParam(request.query.surface);
1391
+ const normalizedSurface = surface === "api" || surface === "mcp" || surface === "desktop" ? surface : "all";
1392
+ const errors = await observability.listErrors({
1393
+ since: readRequestParam(request.query.since),
1394
+ surface: normalizedSurface,
1395
+ limit: parseClampedNumber(request.query.limit, 50, 1, 200)
1396
+ });
1397
+ response.json(envelope(response.locals.requestId, errors));
1398
+ }));
1399
+ app.post("/api/v1/nodes/search", handleAsyncRoute(async (request, response) => {
1400
+ const input = nodeSearchSchema.parse(request.body ?? {});
1401
+ const result = await runObservedSpan("nodes.search", {
1402
+ queryPresent: Boolean(input.query.trim()),
1403
+ limit: input.limit,
1404
+ offset: input.offset,
1405
+ sort: input.sort
1406
+ }, (span) => {
1407
+ const searchResult = currentRepository().searchNodes(input);
1408
+ span.addDetails({
1409
+ resultCount: searchResult.items.length,
1410
+ totalCount: searchResult.total
1411
+ });
1412
+ return searchResult;
1413
+ });
1414
+ response.json(envelope(response.locals.requestId, result));
1415
+ }));
1416
+ app.post("/api/v1/activities/search", handleAsyncRoute(async (request, response) => {
1417
+ const input = activitySearchSchema.parse(request.body ?? {});
1418
+ const result = await runObservedSpan("activities.search", {
1419
+ queryPresent: Boolean(input.query.trim()),
1420
+ limit: input.limit,
1421
+ offset: input.offset,
1422
+ sort: input.sort
1423
+ }, (span) => {
1424
+ const searchResult = currentRepository().searchActivities(input);
1425
+ span.addDetails({
1426
+ resultCount: searchResult.items.length,
1427
+ totalCount: searchResult.total
1428
+ });
1429
+ return searchResult;
1430
+ });
1431
+ response.json(envelope(response.locals.requestId, result));
1432
+ }));
1433
+ app.post("/api/v1/search", handleAsyncRoute(async (request, response) => {
1434
+ const input = workspaceSearchSchema.parse(request.body ?? {});
1435
+ const result = await runObservedSpan("workspace.search", {
1436
+ queryPresent: Boolean(input.query.trim()),
1437
+ limit: input.limit,
1438
+ offset: input.offset,
1439
+ sort: input.sort,
1440
+ scopes: input.scopes
1441
+ }, async (span) => {
1442
+ const searchResult = await currentRepository().searchWorkspace(input, {
1443
+ runSemanticFallbackSpan: async (details, callback) => runObservedSpan("workspace.search.semantic_fallback", {
1444
+ ...details,
1445
+ mcpTool: request.header("x-recallx-mcp-tool") ?? null
1446
+ }, async (semanticSpan) => {
1447
+ const semanticResult = await callback();
1448
+ if (semanticResult &&
1449
+ typeof semanticResult === "object" &&
1450
+ "resultCount" in semanticResult &&
1451
+ typeof semanticResult.resultCount === "number") {
1452
+ semanticSpan.addDetails({
1453
+ semanticFallbackResultCount: semanticResult.resultCount
1454
+ });
1455
+ }
1456
+ return semanticResult;
1457
+ })
1458
+ });
1459
+ span.addDetails({
1460
+ resultCount: searchResult.items.length,
1461
+ totalCount: searchResult.total
1462
+ });
1463
+ return {
1464
+ items: searchResult.items,
1465
+ total: searchResult.total
1466
+ };
1467
+ });
1468
+ response.json(envelope(response.locals.requestId, result));
1469
+ }));
1470
+ app.get("/api/v1/nodes/:id", (request, response) => {
1471
+ const repository = currentRepository();
1472
+ const node = repository.getNode(request.params.id);
1473
+ response.json(envelope(response.locals.requestId, {
1474
+ node,
1475
+ related: repository.listRelatedNodes(node.id),
1476
+ activities: repository.listNodeActivities(node.id, 10),
1477
+ artifacts: repository.listArtifacts(node.id),
1478
+ provenance: repository.listProvenance("node", node.id),
1479
+ governance: buildGovernancePayload(repository, "node", node.id)
1480
+ }));
1481
+ });
1482
+ app.post("/api/v1/nodes", (request, response) => {
1483
+ const repository = currentRepository();
1484
+ const input = createNodeSchema.parse(request.body ?? {});
1485
+ response.status(201).json(envelope(response.locals.requestId, createDurableNodeResponse(repository, input)));
1486
+ });
1487
+ app.post("/api/v1/nodes/batch", (request, response) => {
1488
+ const repository = currentRepository();
1489
+ const input = createNodesSchema.parse(request.body ?? {});
1490
+ const items = input.nodes.map((nodeInput, index) => {
1491
+ try {
1492
+ return {
1493
+ ok: true,
1494
+ index,
1495
+ ...createDurableNodeResponse(repository, nodeInput)
1496
+ };
1497
+ }
1498
+ catch (error) {
1499
+ if (error instanceof AppError) {
1500
+ return {
1501
+ ok: false,
1502
+ index,
1503
+ error: toBatchErrorPayload(error)
1504
+ };
1505
+ }
1506
+ throw error;
1507
+ }
1508
+ });
1509
+ const successCount = items.filter((item) => item.ok).length;
1510
+ const errorCount = items.length - successCount;
1511
+ response.status(errorCount > 0 ? 207 : 201).json(envelope(response.locals.requestId, {
1512
+ items,
1513
+ summary: {
1514
+ requestedCount: items.length,
1515
+ successCount,
1516
+ errorCount
1517
+ }
1518
+ }));
1519
+ });
1520
+ app.post("/api/v1/capture", (request, response) => {
1521
+ const repository = currentRepository();
1522
+ const input = captureMemorySchema.parse(request.body ?? {});
1523
+ const source = input.source ?? defaultCaptureSource;
1524
+ const title = input.title ?? deriveCaptureTitle(input.body);
1525
+ const baseNodeInput = {
1526
+ type: input.mode === "decision" ? "decision" : input.nodeType,
1527
+ title,
1528
+ body: input.body,
1529
+ summary: undefined,
1530
+ canonicality: undefined,
1531
+ status: undefined,
1532
+ tags: input.tags,
1533
+ source,
1534
+ metadata: input.metadata
1535
+ };
1536
+ const storedAsNode = input.mode === "node" ||
1537
+ input.mode === "decision" ||
1538
+ (input.mode === "auto" &&
1539
+ (baseNodeInput.type === "decision" ||
1540
+ Boolean(input.metadata.reusable || input.metadata.durable || input.metadata.promoteCandidate) ||
1541
+ !isShortLogLikeAgentNodeInput(baseNodeInput)));
1542
+ if (storedAsNode) {
1543
+ const governance = resolveNodeGovernance(baseNodeInput, resolveGovernancePolicy(repository.getSettings(["review.autoApproveLowRisk", "review.trustedSourceToolNames"])));
1544
+ const node = repository.createNode({
1545
+ ...baseNodeInput,
1546
+ resolvedCanonicality: governance.canonicality,
1547
+ resolvedStatus: governance.status
1548
+ });
1549
+ repository.recordProvenance({
1550
+ entityType: "node",
1551
+ entityId: node.id,
1552
+ operationType: "create",
1553
+ source,
1554
+ metadata: {
1555
+ reason: governance.reason,
1556
+ captureMode: input.mode
1557
+ }
1558
+ });
1559
+ const governanceResult = recomputeGovernanceForEntities("node", [node.id]);
1560
+ queueInferredRefresh(node.id, "node-write");
1561
+ scheduleAutoSemanticIndex();
1562
+ broadcastWorkspaceEvent({
1563
+ reason: "node.created",
1564
+ entityType: "node",
1565
+ entityId: node.id
1566
+ });
1567
+ response.status(201).json(envelope(response.locals.requestId, (() => {
1568
+ const storedNode = governanceResult.updatedNodes?.get(node.id) ?? node;
1569
+ const governancePayload = buildGovernancePayload(repository, "node", node.id, governanceResult.items[0] ?? repository.getGovernanceStateNullable("node", node.id));
1570
+ return {
1571
+ storedAs: "node",
1572
+ node: storedNode,
1573
+ governance: governancePayload,
1574
+ landing: buildLandingPayload({
1575
+ storedAs: "node",
1576
+ canonicality: storedNode.canonicality,
1577
+ status: storedNode.status,
1578
+ governanceState: governancePayload.state?.state ?? null,
1579
+ reason: governance.reason
1580
+ })
1581
+ };
1582
+ })()));
1583
+ return;
1584
+ }
1585
+ const targetNode = input.targetNodeId ? repository.getNode(input.targetNodeId) : repository.ensureWorkspaceInboxNode();
1586
+ const activity = repository.appendActivity({
1587
+ targetNodeId: targetNode.id,
1588
+ activityType: "agent_run_summary",
1589
+ body: input.body,
1590
+ source,
1591
+ metadata: {
1592
+ ...input.metadata,
1593
+ captureMode: input.mode,
1594
+ capturedTitle: title
1595
+ }
1596
+ });
1597
+ repository.recordProvenance({
1598
+ entityType: "activity",
1599
+ entityId: activity.id,
1600
+ operationType: "append",
1601
+ source,
1602
+ metadata: {
1603
+ captureMode: input.mode,
1604
+ targetNodeId: targetNode.id
1605
+ }
1606
+ });
1607
+ queueInferredRefresh(activity.targetNodeId, "activity-append");
1608
+ scheduleAutoSemanticIndex();
1609
+ broadcastWorkspaceEvent({
1610
+ reason: "activity.appended",
1611
+ entityType: "activity",
1612
+ entityId: activity.id
1613
+ });
1614
+ response.status(201).json(envelope(response.locals.requestId, {
1615
+ storedAs: "activity",
1616
+ activity,
1617
+ targetNode,
1618
+ governance: null,
1619
+ landing: buildLandingPayload({
1620
+ storedAs: "activity",
1621
+ status: "recorded",
1622
+ governanceState: null,
1623
+ reason: input.mode === "activity"
1624
+ ? "Capture was explicitly routed to the activity timeline."
1625
+ : "Short log-like capture was routed to the activity timeline."
1626
+ })
1627
+ }));
1628
+ });
1629
+ app.patch("/api/v1/nodes/:id", (request, response) => {
1630
+ const repository = currentRepository();
1631
+ const body = updateNodeRequestSchema.parse(request.body ?? {});
1632
+ const { source, ...input } = body;
1633
+ const node = repository.updateNode(request.params.id, input);
1634
+ repository.recordProvenance({
1635
+ entityType: "node",
1636
+ entityId: node.id,
1637
+ operationType: "update",
1638
+ source,
1639
+ metadata: {
1640
+ fields: Object.keys(input).filter((key) => key !== "source")
1641
+ }
1642
+ });
1643
+ const governanceResult = recomputeGovernanceForEntities("node", [node.id]);
1644
+ queueInferredRefresh(node.id, "node-write");
1645
+ scheduleAutoSemanticIndex();
1646
+ broadcastWorkspaceEvent({
1647
+ reason: "node.updated",
1648
+ entityType: "node",
1649
+ entityId: node.id
1650
+ });
1651
+ response.json(envelope(response.locals.requestId, {
1652
+ node: governanceResult.updatedNodes?.get(node.id) ?? node,
1653
+ governance: buildGovernancePayload(repository, "node", node.id, governanceResult.items[0] ?? repository.getGovernanceStateNullable("node", node.id))
1654
+ }));
1655
+ });
1656
+ app.post("/api/v1/nodes/:id/refresh-summary", (request, response) => {
1657
+ const repository = currentRepository();
1658
+ const source = sourceSchema.parse(request.body?.source ?? request.body ?? {});
1659
+ const node = repository.refreshNodeSummary(request.params.id);
1660
+ repository.recordProvenance({
1661
+ entityType: "node",
1662
+ entityId: node.id,
1663
+ operationType: "update",
1664
+ source,
1665
+ metadata: {
1666
+ fields: ["summary"],
1667
+ reason: "summary.refreshed"
1668
+ }
1669
+ });
1670
+ const governanceResult = recomputeGovernanceForEntities("node", [node.id]);
1671
+ queueInferredRefresh(node.id, "node-write");
1672
+ scheduleAutoSemanticIndex();
1673
+ broadcastWorkspaceEvent({
1674
+ reason: "node.summary_refreshed",
1675
+ entityType: "node",
1676
+ entityId: node.id
1677
+ });
1678
+ response.json(envelope(response.locals.requestId, {
1679
+ node: governanceResult.updatedNodes?.get(node.id) ?? node,
1680
+ governance: buildGovernancePayload(repository, "node", node.id, governanceResult.items[0] ?? repository.getGovernanceStateNullable("node", node.id))
1681
+ }));
1682
+ });
1683
+ app.post("/api/v1/nodes/:id/archive", (request, response) => {
1684
+ const repository = currentRepository();
1685
+ const source = sourceSchema.parse(request.body?.source ?? request.body ?? {});
1686
+ const node = repository.archiveNode(request.params.id);
1687
+ repository.recordProvenance({
1688
+ entityType: "node",
1689
+ entityId: node.id,
1690
+ operationType: "archive",
1691
+ source
1692
+ });
1693
+ const governanceResult = recomputeGovernanceForEntities("node", [node.id]);
1694
+ queueInferredRefresh(node.id, "node-write");
1695
+ scheduleAutoSemanticIndex();
1696
+ broadcastWorkspaceEvent({
1697
+ reason: "node.archived",
1698
+ entityType: "node",
1699
+ entityId: node.id
1700
+ });
1701
+ response.json(envelope(response.locals.requestId, {
1702
+ node: governanceResult.updatedNodes?.get(node.id) ?? node,
1703
+ governance: buildGovernancePayload(repository, "node", node.id, governanceResult.items[0] ?? repository.getGovernanceStateNullable("node", node.id))
1704
+ }));
1705
+ });
1706
+ app.get("/api/v1/nodes/:id/related", (request, response) => {
1707
+ const depth = Number(request.query.depth ?? 1);
1708
+ if (depth !== 1) {
1709
+ throw new AppError(400, "INVALID_INPUT", "Only depth=1 is supported in the hot path.");
1710
+ }
1711
+ const types = parseRelationTypesQuery(request.query.types);
1712
+ const includeInferred = request.query.include_inferred === "1" ||
1713
+ request.query.include_inferred === "true" ||
1714
+ request.query.include_inferred === undefined;
1715
+ const maxInferred = parseClampedNumber(request.query.max_inferred, 4, 0, 10);
1716
+ const items = buildNeighborhoodItems(currentRepository(), request.params.id, {
1717
+ relationTypes: types,
1718
+ includeInferred,
1719
+ maxInferred
1720
+ });
1721
+ response.json(envelope(response.locals.requestId, { items }));
1722
+ });
1723
+ app.get("/api/v1/nodes/:id/neighborhood", handleAsyncRoute(async (request, response) => {
1724
+ const depth = Number(request.query.depth ?? 1);
1725
+ if (depth !== 1 && depth !== 2) {
1726
+ throw new AppError(400, "INVALID_INPUT", "Only depth=1 or depth=2 is supported in the hot path.");
1727
+ }
1728
+ const types = parseRelationTypesQuery(request.query.types);
1729
+ const includeInferred = request.query.include_inferred === "1" || request.query.include_inferred === "true" || request.query.include_inferred === undefined;
1730
+ const maxInferred = parseClampedNumber(request.query.max_inferred, 4, 0, 10);
1731
+ const items = await runObservedSpan("nodes.neighborhood", {
1732
+ relationTypeCount: types?.length ?? 0,
1733
+ includeInferred,
1734
+ maxInferred,
1735
+ depth
1736
+ }, (span) => {
1737
+ const repository = currentRepository();
1738
+ const nodeId = readRequestParam(request.params.id);
1739
+ const result = buildNeighborhoodItems(repository, nodeId, {
1740
+ relationTypes: types,
1741
+ includeInferred,
1742
+ maxInferred
1743
+ });
1744
+ const expanded = depth === 2
1745
+ ? (() => {
1746
+ const seen = new Set(result.map((item) => `${item.edge.relationId}:${item.node.id}:1`));
1747
+ const secondHop = result.flatMap((item) => buildNeighborhoodItems(repository, item.node.id, {
1748
+ relationTypes: types,
1749
+ includeInferred,
1750
+ maxInferred
1751
+ })
1752
+ .filter((nested) => nested.node.id !== nodeId)
1753
+ .map((nested) => ({
1754
+ ...nested,
1755
+ edge: {
1756
+ ...nested.edge,
1757
+ hop: 2
1758
+ },
1759
+ viaNodeId: item.node.id,
1760
+ viaNodeTitle: item.node.title
1761
+ }))
1762
+ .filter((nested) => {
1763
+ const key = `${nested.edge.relationId}:${nested.node.id}:${nested.viaNodeId ?? "focus"}`;
1764
+ if (seen.has(key)) {
1765
+ return false;
1766
+ }
1767
+ seen.add(key);
1768
+ return true;
1769
+ }));
1770
+ return [...result, ...secondHop];
1771
+ })()
1772
+ : result;
1773
+ span.addDetails({
1774
+ resultCount: expanded.length
1775
+ });
1776
+ return expanded;
1777
+ });
1778
+ response.json(envelope(response.locals.requestId, { items }));
1779
+ }));
1780
+ app.get("/api/v1/projects/:id/graph", handleAsyncRoute(async (request, response) => {
1781
+ const includeInferred = request.query.include_inferred === "1" ||
1782
+ request.query.include_inferred === "true" ||
1783
+ request.query.include_inferred === undefined;
1784
+ const maxInferred = parseClampedNumber(request.query.max_inferred, 60, 0, 200);
1785
+ const memberLimit = parseClampedNumber(request.query.member_limit, 120, 1, 300);
1786
+ const activityLimit = parseClampedNumber(request.query.activity_limit, 200, 0, 400);
1787
+ const payload = await runObservedSpan("projects.graph", {
1788
+ includeInferred,
1789
+ maxInferred,
1790
+ memberLimit,
1791
+ activityLimit
1792
+ }, () => buildProjectGraph(currentRepository(), readRequestParam(request.params.id), {
1793
+ includeInferred,
1794
+ maxInferred,
1795
+ memberLimit,
1796
+ activityLimit
1797
+ }));
1798
+ response.json(envelope(response.locals.requestId, payload));
1799
+ }));
1800
+ app.post("/api/v1/relations", (request, response) => {
1801
+ const repository = currentRepository();
1802
+ const input = createRelationSchema.parse(request.body ?? {});
1803
+ const governance = resolveRelationStatus(input, resolveGovernancePolicy(repository.getSettings(["review.autoApproveLowRisk", "review.trustedSourceToolNames"])));
1804
+ const relation = repository.createRelation({
1805
+ ...input,
1806
+ resolvedStatus: governance.status
1807
+ });
1808
+ repository.recordProvenance({
1809
+ entityType: "relation",
1810
+ entityId: relation.id,
1811
+ operationType: "create",
1812
+ source: input.source
1813
+ });
1814
+ const governanceResult = recomputeGovernanceForEntities("relation", [relation.id]);
1815
+ queueInferredRefreshForNodes([relation.fromNodeId, relation.toNodeId], "node-write");
1816
+ broadcastWorkspaceEvent({
1817
+ reason: "relation.created",
1818
+ entityType: "relation",
1819
+ entityId: relation.id
1820
+ });
1821
+ response.status(201).json(envelope(response.locals.requestId, (() => {
1822
+ const storedRelation = repository.getRelation(relation.id);
1823
+ const governancePayload = buildGovernancePayload(repository, "relation", relation.id, governanceResult.items[0] ?? repository.getGovernanceStateNullable("relation", relation.id));
1824
+ return {
1825
+ relation: storedRelation,
1826
+ governance: governancePayload,
1827
+ landing: buildLandingPayload({
1828
+ storedAs: "relation",
1829
+ status: storedRelation.status,
1830
+ governanceState: governancePayload.state?.state ?? null,
1831
+ reason: governance.reason
1832
+ })
1833
+ };
1834
+ })()));
1835
+ });
1836
+ app.post("/api/v1/inferred-relations", (request, response) => {
1837
+ const relation = currentRepository().upsertInferredRelation(upsertInferredRelationSchema.parse(request.body ?? {}));
1838
+ broadcastWorkspaceEvent({
1839
+ reason: "inferred-relation.upserted",
1840
+ entityType: "relation",
1841
+ entityId: relation.id
1842
+ });
1843
+ response.status(201).json(envelope(response.locals.requestId, { relation }));
1844
+ });
1845
+ app.post("/api/v1/relation-usage-events", (request, response) => {
1846
+ const repository = currentRepository();
1847
+ const event = repository.appendRelationUsageEvent(appendRelationUsageEventSchema.parse(request.body ?? {}));
1848
+ markPendingRelationUsage({
1849
+ relationId: event.relationId,
1850
+ createdAt: event.createdAt
1851
+ });
1852
+ scheduleAutoRecompute();
1853
+ const governanceResult = recomputeGovernanceForEntities("relation", [event.relationId]);
1854
+ broadcastWorkspaceEvent({
1855
+ reason: "relation-usage.appended",
1856
+ entityType: "relation",
1857
+ entityId: event.relationId
1858
+ });
1859
+ response.status(201).json(envelope(response.locals.requestId, {
1860
+ event,
1861
+ governance: buildGovernancePayload(repository, "relation", event.relationId, governanceResult.items[0] ?? repository.getGovernanceStateNullable("relation", event.relationId))
1862
+ }));
1863
+ });
1864
+ app.post("/api/v1/search-feedback-events", (request, response) => {
1865
+ const repository = currentRepository();
1866
+ const event = repository.appendSearchFeedbackEvent(appendSearchFeedbackSchema.parse(request.body ?? {}));
1867
+ const governanceResult = event.resultType === "node"
1868
+ ? recomputeGovernanceForEntities("node", [event.resultId])
1869
+ : { items: [] };
1870
+ broadcastWorkspaceEvent({
1871
+ reason: "search-feedback.appended",
1872
+ entityType: event.resultType === "activity" ? "activity" : "node",
1873
+ entityId: event.resultId
1874
+ });
1875
+ response.status(201).json(envelope(response.locals.requestId, {
1876
+ event,
1877
+ governance: event.resultType === "node"
1878
+ ? buildGovernancePayload(repository, "node", event.resultId, governanceResult.items[0] ?? repository.getGovernanceStateNullable("node", event.resultId))
1879
+ : null
1880
+ }));
1881
+ });
1882
+ app.post("/api/v1/inferred-relations/recompute", handleAsyncRoute(async (request, response) => {
1883
+ const input = recomputeInferredRelationsSchema.parse(request.body ?? {});
1884
+ const isFullMaintenancePass = !input.generator && !input.relationIds?.length;
1885
+ if (isFullMaintenancePass) {
1886
+ const result = await runAutoRecompute("manual");
1887
+ response.json(envelope(response.locals.requestId, result));
1888
+ return;
1889
+ }
1890
+ const result = currentRepository().recomputeInferredRelationScores(input);
1891
+ broadcastWorkspaceEvent({
1892
+ reason: "inferred-relation.recomputed",
1893
+ entityType: "relation"
1894
+ });
1895
+ response.json(envelope(response.locals.requestId, result));
1896
+ }));
1897
+ app.post("/api/v1/inferred-relations/reindex", (request, response) => {
1898
+ const input = reindexInferredRelationsSchema.parse(request.body ?? {});
1899
+ const result = reindexAutomaticInferredRelations(currentRepository(), input);
1900
+ broadcastWorkspaceEvent({
1901
+ reason: "inferred-relation.reindexed",
1902
+ entityType: "relation"
1903
+ });
1904
+ response.json(envelope(response.locals.requestId, result));
1905
+ });
1906
+ app.patch("/api/v1/relations/:id", (request, response) => {
1907
+ const repository = currentRepository();
1908
+ const input = updateRelationSchema.parse(request.body ?? {});
1909
+ const relation = repository.updateRelationStatus(request.params.id, input.status);
1910
+ repository.recordProvenance({
1911
+ entityType: "relation",
1912
+ entityId: relation.id,
1913
+ operationType: input.status === "active" ? "approve" : input.status,
1914
+ source: input.source,
1915
+ metadata: input.metadata
1916
+ });
1917
+ const governanceResult = recomputeGovernanceForEntities("relation", [relation.id]);
1918
+ queueInferredRefreshForNodes([relation.fromNodeId, relation.toNodeId], "node-write");
1919
+ broadcastWorkspaceEvent({
1920
+ reason: "relation.updated",
1921
+ entityType: "relation",
1922
+ entityId: relation.id
1923
+ });
1924
+ response.json(envelope(response.locals.requestId, {
1925
+ relation: repository.getRelation(relation.id),
1926
+ governance: buildGovernancePayload(repository, "relation", relation.id, governanceResult.items[0] ?? repository.getGovernanceStateNullable("relation", relation.id))
1927
+ }));
1928
+ });
1929
+ app.get("/api/v1/nodes/:id/activities", (request, response) => {
1930
+ const limit = Number(request.query.limit ?? 20);
1931
+ response.json(envelope(response.locals.requestId, {
1932
+ items: currentRepository().listNodeActivities(request.params.id, limit)
1933
+ }));
1934
+ });
1935
+ app.post("/api/v1/activities", (request, response) => {
1936
+ const repository = currentRepository();
1937
+ const input = appendActivitySchema.parse(request.body ?? {});
1938
+ const promotion = shouldPromoteActivitySummary(input) ? maybeCreatePromotionCandidate(repository, input) : {};
1939
+ const activity = repository.appendActivity(promotion.suggestedNodeId
1940
+ ? {
1941
+ ...input,
1942
+ body: `Durable agent summary promoted to suggested node ${promotion.suggestedNodeId} for automatic governance.`,
1943
+ metadata: {
1944
+ ...input.metadata,
1945
+ promotedToSuggested: true,
1946
+ promotedNodeId: promotion.suggestedNodeId,
1947
+ rawBodyStoredInActivity: false
1948
+ }
1949
+ }
1950
+ : input);
1951
+ repository.recordProvenance({
1952
+ entityType: "activity",
1953
+ entityId: activity.id,
1954
+ operationType: "append",
1955
+ source: input.source,
1956
+ metadata: {
1957
+ promotedToSuggested: Boolean(promotion.suggestedNodeId)
1958
+ }
1959
+ });
1960
+ const governanceResult = promotion.suggestedNodeId
1961
+ ? recomputeGovernanceForEntities("node", [promotion.suggestedNodeId])
1962
+ : { items: [] };
1963
+ queueInferredRefresh(activity.targetNodeId, "activity-append");
1964
+ scheduleAutoSemanticIndex();
1965
+ broadcastWorkspaceEvent({
1966
+ reason: "activity.appended",
1967
+ entityType: "activity",
1968
+ entityId: activity.id
1969
+ });
1970
+ response.status(201).json(envelope(response.locals.requestId, {
1971
+ activity,
1972
+ promotion,
1973
+ governance: promotion.suggestedNodeId && governanceResult.items[0]
1974
+ ? buildGovernancePayload(repository, "node", promotion.suggestedNodeId, governanceResult.items[0])
1975
+ : null
1976
+ }));
1977
+ });
1978
+ app.post("/api/v1/artifacts", (request, response) => {
1979
+ const repository = currentRepository();
1980
+ const input = attachArtifactSchema.parse(request.body ?? {});
1981
+ const artifact = repository.attachArtifact({
1982
+ ...input,
1983
+ metadata: input.metadata
1984
+ });
1985
+ repository.recordProvenance({
1986
+ entityType: "artifact",
1987
+ entityId: artifact.id,
1988
+ operationType: "attach",
1989
+ source: input.source
1990
+ });
1991
+ queueInferredRefresh(artifact.nodeId, "node-write");
1992
+ scheduleAutoSemanticIndex();
1993
+ broadcastWorkspaceEvent({
1994
+ reason: "artifact.attached",
1995
+ entityType: "artifact",
1996
+ entityId: artifact.id
1997
+ });
1998
+ response.status(201).json(envelope(response.locals.requestId, { artifact }));
1999
+ });
2000
+ app.get("/api/v1/nodes/:id/artifacts", (request, response) => {
2001
+ response.json(envelope(response.locals.requestId, {
2002
+ items: currentRepository().listArtifacts(request.params.id)
2003
+ }));
2004
+ });
2005
+ app.post("/api/v1/retrieval/node-summaries", (request, response) => {
2006
+ const nodeIds = Array.isArray(request.body?.nodeIds) ? request.body.nodeIds : [];
2007
+ const repository = currentRepository();
2008
+ const nodeMap = repository.getNodesByIds(nodeIds);
2009
+ const nodes = nodeIds.map((nodeId) => {
2010
+ const node = nodeMap.get(nodeId);
2011
+ if (!node) {
2012
+ throw new AppError(404, "NOT_FOUND", `Node ${nodeId} not found`);
2013
+ }
2014
+ return node;
2015
+ });
2016
+ const items = nodes.map((node) => ({
2017
+ id: node.id,
2018
+ title: node.title,
2019
+ summary: node.summary,
2020
+ type: node.type,
2021
+ updatedAt: node.updatedAt
2022
+ }));
2023
+ response.json(envelope(response.locals.requestId, { items }));
2024
+ });
2025
+ app.get("/api/v1/retrieval/activity-digest/:targetId", (request, response) => {
2026
+ const items = currentRepository()
2027
+ .listNodeActivities(request.params.targetId, 5)
2028
+ .map((activity) => `${activity.activityType}: ${activity.body ?? "No details"}`);
2029
+ response.json(envelope(response.locals.requestId, { items }));
2030
+ });
2031
+ app.get("/api/v1/retrieval/decisions/:targetId", (request, response) => {
2032
+ const repository = currentRepository();
2033
+ const items = buildTargetRelatedRetrievalItems(repository, readRequestParam(request.params.targetId), {
2034
+ types: ["decision"],
2035
+ status: ["active", "contested"]
2036
+ });
2037
+ response.json(envelope(response.locals.requestId, { items }));
2038
+ });
2039
+ app.get("/api/v1/retrieval/open-questions/:targetId", (request, response) => {
2040
+ const repository = currentRepository();
2041
+ const items = buildTargetRelatedRetrievalItems(repository, readRequestParam(request.params.targetId), {
2042
+ types: ["question"],
2043
+ status: ["active", "draft", "contested"]
2044
+ });
2045
+ response.json(envelope(response.locals.requestId, { items }));
2046
+ });
2047
+ app.post("/api/v1/retrieval/rank-candidates", handleAsyncRoute(async (request, response) => {
2048
+ const ranked = await runObservedSpan("retrieval.rank_candidates", {
2049
+ candidateCount: Array.isArray(request.body?.candidateNodeIds) ? request.body.candidateNodeIds.length : 0,
2050
+ queryPresent: typeof request.body?.query === "string" && request.body.query.trim().length > 0
2051
+ }, async (span) => {
2052
+ const repository = currentRepository();
2053
+ const query = typeof request.body?.query === "string" ? request.body.query : "";
2054
+ const candidateNodeIds = Array.isArray(request.body?.candidateNodeIds) ? request.body.candidateNodeIds : [];
2055
+ const preset = typeof request.body?.preset === "string" ? request.body.preset : "for-assistant";
2056
+ const targetNodeId = typeof request.body?.targetNodeId === "string" ? request.body.targetNodeId : null;
2057
+ const relationBonuses = targetNodeId ? buildCandidateRelationBonusMap(repository, targetNodeId, candidateNodeIds) : new Map();
2058
+ const candidateNodeMap = repository.getNodesByIds(candidateNodeIds);
2059
+ const candidates = candidateNodeIds.map((id) => {
2060
+ const node = candidateNodeMap.get(id);
2061
+ if (!node) {
2062
+ throw new AppError(404, "NOT_FOUND", `Node ${id} not found`);
2063
+ }
2064
+ return node;
2065
+ });
2066
+ const semanticAugmentation = repository.getSemanticAugmentationSettings();
2067
+ const semanticEnabled = shouldUseSemanticCandidateAugmentation(query, candidates);
2068
+ const semanticBonuses = semanticEnabled
2069
+ ? buildSemanticCandidateBonusMap(await repository.rankSemanticCandidates(query, candidateNodeIds), semanticAugmentation)
2070
+ : new Map();
2071
+ const result = candidates
2072
+ .map((node) => {
2073
+ const relationRetrievalRank = relationBonuses.get(node.id)?.retrievalRank ?? 0;
2074
+ const semanticRetrievalRank = semanticBonuses.get(node.id)?.retrievalRank ?? 0;
2075
+ const rankingScore = computeRankCandidateScore(node, query, preset, relationRetrievalRank + semanticRetrievalRank);
2076
+ const relationReason = relationBonuses.get(node.id)?.reason ?? null;
2077
+ const semanticReason = semanticBonuses.get(node.id)?.reason ?? null;
2078
+ return {
2079
+ nodeId: node.id,
2080
+ score: rankingScore,
2081
+ retrievalRank: rankingScore,
2082
+ title: node.title,
2083
+ relationSource: relationBonuses.get(node.id)?.relationSource ?? null,
2084
+ relationType: relationBonuses.get(node.id)?.relationType ?? null,
2085
+ relationScore: relationBonuses.get(node.id)?.relationScore ?? null,
2086
+ semanticSimilarity: semanticBonuses.get(node.id)?.semanticSimilarity ?? null,
2087
+ reason: [relationReason, semanticReason].filter(Boolean).join("; ") || null
2088
+ };
2089
+ })
2090
+ .sort((left, right) => right.score - left.score);
2091
+ span.addDetails({
2092
+ resultCount: result.length,
2093
+ semanticUsed: semanticEnabled
2094
+ });
2095
+ return result;
2096
+ });
2097
+ response.json(envelope(response.locals.requestId, { items: ranked }));
2098
+ }));
2099
+ app.post("/api/v1/context/bundles", handleAsyncRoute(async (request, response) => {
2100
+ const input = buildContextBundleSchema.parse(request.body ?? {});
2101
+ const bundle = await runObservedSpan("context.bundle", {
2102
+ mode: input.mode,
2103
+ preset: input.preset,
2104
+ maxItems: input.options.maxItems,
2105
+ includeRelated: input.options.includeRelated
2106
+ }, async (span) => {
2107
+ const result = await buildContextBundle(currentRepository(), input);
2108
+ span.addDetails({
2109
+ itemCount: result.items.length,
2110
+ activityDigestCount: result.activityDigest.length
2111
+ });
2112
+ return result;
2113
+ });
2114
+ response.json(envelope(response.locals.requestId, { bundle }));
2115
+ }));
2116
+ app.post("/api/v1/context/bundles/preview", handleAsyncRoute(async (request, response) => {
2117
+ const input = buildContextBundleSchema.parse(request.body ?? {});
2118
+ const bundle = await buildContextBundle(currentRepository(), input);
2119
+ response.json(envelope(response.locals.requestId, {
2120
+ bundle,
2121
+ preview: bundleAsMarkdown(bundle)
2122
+ }));
2123
+ }));
2124
+ app.post("/api/v1/context/bundles/export", handleAsyncRoute(async (request, response) => {
2125
+ const input = buildContextBundleSchema.parse(request.body ?? {});
2126
+ const format = request.body?.format === "json" ? "json" : request.body?.format === "text" ? "text" : "markdown";
2127
+ const bundle = await buildContextBundle(currentRepository(), input);
2128
+ const output = format === "json"
2129
+ ? JSON.stringify(bundle, null, 2)
2130
+ : format === "text"
2131
+ ? bundle.items.map((item) => `${item.title ?? item.nodeId}: ${item.summary ?? "No summary"}`).join("\n")
2132
+ : bundleAsMarkdown(bundle);
2133
+ response.json(envelope(response.locals.requestId, { format, output, bundle }));
2134
+ }));
2135
+ app.get("/api/v1/governance/issues", (request, response) => {
2136
+ const states = parseCommaSeparatedValues(request.query.states)?.filter((state) => state === "healthy" || state === "low_confidence" || state === "contested");
2137
+ const input = governanceIssuesQuerySchema.parse({
2138
+ states,
2139
+ limit: Number(request.query.limit ?? 20)
2140
+ });
2141
+ response.json(envelope(response.locals.requestId, {
2142
+ items: currentRepository().listGovernanceIssues(input.limit, input.states)
2143
+ }));
2144
+ });
2145
+ app.get("/api/v1/governance/state/:entityType/:id", (request, response) => {
2146
+ const entityType = readRequestParam(request.params.entityType);
2147
+ if (entityType !== "node" && entityType !== "relation") {
2148
+ throw new AppError(400, "INVALID_INPUT", "entityType must be node or relation");
2149
+ }
2150
+ const repository = currentRepository();
2151
+ response.json(envelope(response.locals.requestId, {
2152
+ state: repository.getGovernanceStateNullable(entityType, request.params.id),
2153
+ events: repository.listGovernanceEvents(entityType, request.params.id, 20)
2154
+ }));
2155
+ });
2156
+ app.post("/api/v1/governance/recompute", (request, response) => {
2157
+ const input = recomputeGovernanceSchema.parse(request.body ?? {});
2158
+ const result = recomputeAutomaticGovernance(currentRepository(), input);
2159
+ broadcastWorkspaceEvent({
2160
+ reason: "governance.recomputed",
2161
+ entityType: input.entityType ?? "settings"
2162
+ });
2163
+ const { updatedNodes: _updatedNodes, ...payload } = result;
2164
+ response.json(envelope(response.locals.requestId, payload));
2165
+ });
2166
+ app.get("/api/v1/integrations", (_request, response) => {
2167
+ response.json(envelope(response.locals.requestId, { items: currentRepository().listIntegrations() }));
2168
+ });
2169
+ app.post("/api/v1/integrations", (request, response) => {
2170
+ const input = registerIntegrationSchema.parse(request.body ?? {});
2171
+ const integration = currentRepository().registerIntegration(input);
2172
+ broadcastWorkspaceEvent({
2173
+ reason: "integration.registered",
2174
+ entityType: "integration",
2175
+ entityId: integration.id
2176
+ });
2177
+ response.status(201).json(envelope(response.locals.requestId, { integration }));
2178
+ });
2179
+ app.patch("/api/v1/integrations/:id", (request, response) => {
2180
+ const input = updateIntegrationSchema.parse(request.body ?? {});
2181
+ const integration = currentRepository().updateIntegration(request.params.id, input);
2182
+ broadcastWorkspaceEvent({
2183
+ reason: "integration.updated",
2184
+ entityType: "integration",
2185
+ entityId: integration.id
2186
+ });
2187
+ response.json(envelope(response.locals.requestId, { integration }));
2188
+ });
2189
+ app.get("/api/v1/settings", (request, response) => {
2190
+ const keys = parseCommaSeparatedValues(request.query.keys);
2191
+ response.json(envelope(response.locals.requestId, { values: currentRepository().getSettings(keys) }));
2192
+ });
2193
+ app.patch("/api/v1/settings", (request, response) => {
2194
+ const repository = currentRepository();
2195
+ const input = updateSettingsSchema.parse(request.body ?? {});
2196
+ repository.setSettings(input.values);
2197
+ broadcastWorkspaceEvent({
2198
+ reason: "settings.updated",
2199
+ entityType: "settings"
2200
+ });
2201
+ response.json(envelope(response.locals.requestId, { values: repository.getSettings(Object.keys(input.values)) }));
2202
+ });
2203
+ app.use("/artifacts", (request, response, next) => {
2204
+ if (params.apiToken && readBearerToken(request) !== params.apiToken) {
2205
+ next(new AppError(401, "UNAUTHORIZED", "Missing or invalid bearer token."));
2206
+ return;
2207
+ }
2208
+ const session = currentSession();
2209
+ const artifactRelativePath = normalizeArtifactRelativePath(request.path);
2210
+ if (!currentRepository().hasArtifactAtPath(artifactRelativePath)) {
2211
+ next(new AppError(404, "NOT_FOUND", "Artifact not found."));
2212
+ return;
2213
+ }
2214
+ let artifactPath;
2215
+ try {
2216
+ artifactPath = resolveRegisteredArtifactPath(session.workspaceRoot, session.paths.artifactsDir, artifactRelativePath);
2217
+ }
2218
+ catch (error) {
2219
+ next(error);
2220
+ return;
2221
+ }
2222
+ response.type(mime.lookup(artifactPath) || "application/octet-stream");
2223
+ response.sendFile(artifactPath);
2224
+ });
2225
+ if (rendererDistDir && rendererIndexPath) {
2226
+ app.use(express.static(rendererDistDir, { index: false }));
2227
+ app.get(/^(?!\/api\/v1(?:\/|$)|\/artifacts(?:\/|$)).*/, (request, response, next) => {
2228
+ if (path.posix.extname(request.path)) {
2229
+ next();
2230
+ return;
2231
+ }
2232
+ response.sendFile(rendererIndexPath);
2233
+ });
2234
+ }
2235
+ else {
2236
+ app.get("/", (_request, response) => {
2237
+ response
2238
+ .type("text/plain")
2239
+ .send("RecallX headless runtime is running. Use /api/v1 for the API or install the full recallx package for the renderer.");
2240
+ });
2241
+ }
2242
+ app.use((error, _request, response, _next) => {
2243
+ if (error instanceof AppError) {
2244
+ response.locals.telemetryErrorCode = error.code;
2245
+ response.locals.telemetryErrorKind = "app_error";
2246
+ response.status(error.statusCode).json(errorEnvelope(response.locals.requestId, error));
2247
+ return;
2248
+ }
2249
+ if (error instanceof Error && "issues" in error) {
2250
+ response.locals.telemetryErrorCode = "INVALID_INPUT";
2251
+ response.locals.telemetryErrorKind = "validation_error";
2252
+ response
2253
+ .status(400)
2254
+ .json(errorEnvelope(response.locals.requestId, new AppError(400, "INVALID_INPUT", "Invalid input.", error)));
2255
+ return;
2256
+ }
2257
+ const unexpected = new AppError(500, "INTERNAL_ERROR", "Unexpected internal error.");
2258
+ response.locals.telemetryErrorCode = unexpected.code;
2259
+ response.locals.telemetryErrorKind = "unexpected_error";
2260
+ response.status(unexpected.statusCode).json(errorEnvelope(response.locals.requestId, unexpected));
2261
+ });
2262
+ return app;
2263
+ }