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.
- package/README.md +205 -0
- package/app/cli/bin/recallx-mcp.js +2 -0
- package/app/cli/bin/recallx.js +8 -0
- package/app/cli/src/cli.js +808 -0
- package/app/cli/src/format.js +242 -0
- package/app/cli/src/http.js +35 -0
- package/app/mcp/api-client.js +101 -0
- package/app/mcp/index.js +128 -0
- package/app/mcp/server.js +786 -0
- package/app/server/app.js +2263 -0
- package/app/server/config.js +27 -0
- package/app/server/db.js +399 -0
- package/app/server/errors.js +17 -0
- package/app/server/governance.js +466 -0
- package/app/server/index.js +26 -0
- package/app/server/inferred-relations.js +247 -0
- package/app/server/observability.js +495 -0
- package/app/server/project-graph.js +199 -0
- package/app/server/relation-scoring.js +59 -0
- package/app/server/repositories.js +2992 -0
- package/app/server/retrieval.js +486 -0
- package/app/server/semantic/chunker.js +85 -0
- package/app/server/semantic/provider.js +124 -0
- package/app/server/semantic/types.js +1 -0
- package/app/server/semantic/vector-store.js +169 -0
- package/app/server/utils.js +43 -0
- package/app/server/workspace-session.js +128 -0
- package/app/server/workspace.js +79 -0
- package/app/shared/contracts.js +268 -0
- package/app/shared/request-runtime.js +30 -0
- package/app/shared/types.js +1 -0
- package/app/shared/version.js +1 -0
- package/dist/renderer/assets/ProjectGraphCanvas-BMvz9DmE.js +312 -0
- package/dist/renderer/assets/index-C2-KXqBO.css +1 -0
- package/dist/renderer/assets/index-CrDu22h7.js +76 -0
- package/dist/renderer/index.html +13 -0
- 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
|
+
}
|