recallx 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (37) hide show
  1. package/README.md +205 -0
  2. package/app/cli/bin/recallx-mcp.js +2 -0
  3. package/app/cli/bin/recallx.js +8 -0
  4. package/app/cli/src/cli.js +808 -0
  5. package/app/cli/src/format.js +242 -0
  6. package/app/cli/src/http.js +35 -0
  7. package/app/mcp/api-client.js +101 -0
  8. package/app/mcp/index.js +128 -0
  9. package/app/mcp/server.js +786 -0
  10. package/app/server/app.js +2263 -0
  11. package/app/server/config.js +27 -0
  12. package/app/server/db.js +399 -0
  13. package/app/server/errors.js +17 -0
  14. package/app/server/governance.js +466 -0
  15. package/app/server/index.js +26 -0
  16. package/app/server/inferred-relations.js +247 -0
  17. package/app/server/observability.js +495 -0
  18. package/app/server/project-graph.js +199 -0
  19. package/app/server/relation-scoring.js +59 -0
  20. package/app/server/repositories.js +2992 -0
  21. package/app/server/retrieval.js +486 -0
  22. package/app/server/semantic/chunker.js +85 -0
  23. package/app/server/semantic/provider.js +124 -0
  24. package/app/server/semantic/types.js +1 -0
  25. package/app/server/semantic/vector-store.js +169 -0
  26. package/app/server/utils.js +43 -0
  27. package/app/server/workspace-session.js +128 -0
  28. package/app/server/workspace.js +79 -0
  29. package/app/shared/contracts.js +268 -0
  30. package/app/shared/request-runtime.js +30 -0
  31. package/app/shared/types.js +1 -0
  32. package/app/shared/version.js +1 -0
  33. package/dist/renderer/assets/ProjectGraphCanvas-BMvz9DmE.js +312 -0
  34. package/dist/renderer/assets/index-C2-KXqBO.css +1 -0
  35. package/dist/renderer/assets/index-CrDu22h7.js +76 -0
  36. package/dist/renderer/index.html +13 -0
  37. package/package.json +49 -0
@@ -0,0 +1,466 @@
1
+ import { AppError } from "./errors.js";
2
+ import { countTokensApprox, nowIso } from "./utils.js";
3
+ const relaxedShortFormNodeTypes = new Set(["reference", "question", "conversation"]);
4
+ function clampConfidence(value) {
5
+ return Math.min(Math.max(value, 0), 1);
6
+ }
7
+ function readTrustedSourceToolNames(value) {
8
+ if (Array.isArray(value)) {
9
+ return value.filter((item) => typeof item === "string" && item.trim().length > 0);
10
+ }
11
+ if (typeof value === "string") {
12
+ return value
13
+ .split(",")
14
+ .map((item) => item.trim())
15
+ .filter(Boolean);
16
+ }
17
+ return [];
18
+ }
19
+ function isTrustedAgentSource(toolName, policy) {
20
+ return policy.trustedSourceToolNames.includes(toolName);
21
+ }
22
+ function feedbackConfidenceBonus(summary) {
23
+ if (!summary) {
24
+ return 0;
25
+ }
26
+ return Math.min(Math.max(summary.totalDelta, -2), 2) * 0.12;
27
+ }
28
+ function stabilityBonus(timestamp) {
29
+ const ageMs = Date.now() - new Date(timestamp).getTime();
30
+ if (ageMs >= 24 * 60 * 60 * 1000)
31
+ return 0.06;
32
+ if (ageMs >= 60 * 60 * 1000)
33
+ return 0.03;
34
+ return 0;
35
+ }
36
+ function chooseNodeHealthyThreshold(node) {
37
+ if (node.canonicality === "suggested") {
38
+ return node.type === "decision" ? 0.78 : 0.72;
39
+ }
40
+ if (node.canonicality === "canonical") {
41
+ return 0.55;
42
+ }
43
+ return 0.35;
44
+ }
45
+ function chooseRelationActiveThreshold(_relation) {
46
+ return 0.72;
47
+ }
48
+ function chooseNodeBaseConfidence(node, policy) {
49
+ switch (node.sourceType) {
50
+ case "human":
51
+ return 0.95;
52
+ case "import":
53
+ return 0.84;
54
+ case "integration":
55
+ return 0.72;
56
+ case "system":
57
+ return 0.7;
58
+ case "agent":
59
+ return isTrustedAgentSource(node.sourceLabel ?? "", policy) ? 0.62 : 0.48;
60
+ default:
61
+ return 0.45;
62
+ }
63
+ }
64
+ function chooseRelationBaseConfidence(relation, policy) {
65
+ switch (relation.sourceType) {
66
+ case "human":
67
+ return 0.78;
68
+ case "import":
69
+ case "integration":
70
+ case "system":
71
+ return 0.68;
72
+ case "agent":
73
+ return isTrustedAgentSource(relation.sourceLabel ?? "", policy) ? 0.62 : 0.42;
74
+ default:
75
+ return 0.4;
76
+ }
77
+ }
78
+ function buildEventType(previousState, nextState, changedToCanonical, changedToRejected) {
79
+ if (!previousState) {
80
+ return "evaluated";
81
+ }
82
+ if (changedToCanonical) {
83
+ return "promoted";
84
+ }
85
+ if (nextState === "contested" && previousState.state !== "contested") {
86
+ return "contested";
87
+ }
88
+ if (changedToRejected) {
89
+ return "demoted";
90
+ }
91
+ return "evaluated";
92
+ }
93
+ export function resolveGovernancePolicy(settings) {
94
+ return {
95
+ autoApproveLowRisk: typeof settings?.["review.autoApproveLowRisk"] === "boolean"
96
+ ? Boolean(settings["review.autoApproveLowRisk"])
97
+ : true,
98
+ trustedSourceToolNames: readTrustedSourceToolNames(settings?.["review.trustedSourceToolNames"])
99
+ };
100
+ }
101
+ function computeNodeTokenCount(input) {
102
+ const combined = [input.title, input.summary, input.body]
103
+ .filter((value) => typeof value === "string" && value.trim().length > 0)
104
+ .join("\n\n");
105
+ return countTokensApprox(combined);
106
+ }
107
+ function hasDurableNodeSignals(input) {
108
+ return Boolean(input.metadata.reusable ||
109
+ input.metadata.durable ||
110
+ input.metadata.promoteCandidate ||
111
+ (typeof input.summary === "string" && input.summary.trim().length > 0));
112
+ }
113
+ export function isShortLogLikeAgentNodeInput(input) {
114
+ return (input.source.actorType === "agent" &&
115
+ input.type !== "decision" &&
116
+ !hasDurableNodeSignals(input) &&
117
+ !relaxedShortFormNodeTypes.has(input.type) &&
118
+ computeNodeTokenCount(input) <= 300);
119
+ }
120
+ export function resolveNodeGovernance(input, policy = resolveGovernancePolicy()) {
121
+ if (input.source.actorType === "human") {
122
+ return {
123
+ canonicality: input.canonicality ?? "canonical",
124
+ status: input.status ?? "active",
125
+ reason: "Human-authored nodes land canonical by default."
126
+ };
127
+ }
128
+ if (input.source.actorType === "import") {
129
+ return {
130
+ canonicality: "imported",
131
+ status: input.status ?? "active",
132
+ reason: "Imported material stays imported."
133
+ };
134
+ }
135
+ const tokenCount = computeNodeTokenCount(input);
136
+ const reusable = hasDurableNodeSignals(input);
137
+ const trustedAgentSource = input.source.actorType === "agent" ? isTrustedAgentSource(input.source.toolName, policy) : false;
138
+ if (isShortLogLikeAgentNodeInput(input)) {
139
+ throw new AppError(403, "FORBIDDEN", "Short log-like agent output must be appended as activity, not stored as a durable node.", {
140
+ tokenCount,
141
+ recommendation: "Use POST /api/v1/capture with mode=auto or mode=activity.",
142
+ suggestedMode: "activity",
143
+ suggestedTarget: "workspace-inbox"
144
+ });
145
+ }
146
+ if (input.type === "decision") {
147
+ return {
148
+ canonicality: "suggested",
149
+ status: "active",
150
+ reason: trustedAgentSource
151
+ ? "Trusted agent-authored decisions start suggested and can auto-promote."
152
+ : "Agent-authored decisions start suggested and await automatic confidence promotion."
153
+ };
154
+ }
155
+ if (input.source.actorType === "agent" && reusable) {
156
+ return {
157
+ canonicality: "suggested",
158
+ status: "active",
159
+ reason: "Reusable agent-authored knowledge starts suggested and active."
160
+ };
161
+ }
162
+ return {
163
+ canonicality: "appended",
164
+ status: "active",
165
+ reason: trustedAgentSource
166
+ ? "Trusted or low-risk agent-authored nodes land append-first."
167
+ : "Low-risk agent-authored nodes land append-first."
168
+ };
169
+ }
170
+ export function resolveRelationStatus(input, _policy = resolveGovernancePolicy()) {
171
+ if (input.source.actorType === "agent") {
172
+ return {
173
+ status: "suggested",
174
+ reason: "Agent-authored relations start suggested and rely on automatic governance promotion."
175
+ };
176
+ }
177
+ return {
178
+ status: input.status ?? "active",
179
+ reason: "Human or imported relations land active unless a status is explicitly provided."
180
+ };
181
+ }
182
+ export function shouldPromoteActivitySummary(input) {
183
+ const tokenCount = countTokensApprox(input.body);
184
+ const durable = Boolean(input.metadata.reusable || input.metadata.durable || input.metadata.promoteCandidate);
185
+ return input.source.actorType === "agent" && tokenCount > 300 && durable;
186
+ }
187
+ export function maybeCreatePromotionCandidate(repository, input) {
188
+ if (!shouldPromoteActivitySummary(input)) {
189
+ return {};
190
+ }
191
+ const target = repository.getNode(input.targetNodeId);
192
+ const suggested = repository.createNode({
193
+ type: input.metadata.suggestedType === "reference" ? "reference" : "note",
194
+ title: typeof input.metadata.title === "string" ? input.metadata.title : `${target.title ?? "Untitled"} follow-up`,
195
+ body: input.body,
196
+ summary: typeof input.metadata.summary === "string" ? input.metadata.summary : undefined,
197
+ tags: Array.isArray(input.metadata.tags) ? input.metadata.tags : target.tags,
198
+ canonicality: "suggested",
199
+ status: "active",
200
+ resolvedCanonicality: "suggested",
201
+ resolvedStatus: "active",
202
+ source: input.source,
203
+ metadata: {
204
+ ...input.metadata,
205
+ derivedFromActivity: true,
206
+ targetNodeId: input.targetNodeId
207
+ }
208
+ });
209
+ repository.recordProvenance({
210
+ entityType: "node",
211
+ entityId: suggested.id,
212
+ operationType: "create",
213
+ source: input.source,
214
+ metadata: {
215
+ rule: "activity_to_suggested_promotion"
216
+ }
217
+ });
218
+ return { suggestedNodeId: suggested.id };
219
+ }
220
+ function evaluateNodeGovernance(repository, node, policy, feedback = repository.getSearchFeedbackSummaries("node", [node.id]).get(node.id), previousState = repository.getGovernanceStateNullable("node", node.id)) {
221
+ const contradictionCount = repository.countContradictionRelations(node.id);
222
+ const reusable = Boolean(node.metadata.reusable || node.metadata.durable || node.metadata.promoteCandidate);
223
+ let confidence = chooseNodeBaseConfidence(node, policy);
224
+ const reasons = [`source:${node.sourceType ?? "unknown"}`];
225
+ if (node.canonicality === "canonical")
226
+ confidence += 0.12;
227
+ if (node.canonicality === "appended")
228
+ confidence += 0.05;
229
+ if (node.canonicality === "suggested")
230
+ confidence += 0.02;
231
+ if (reusable) {
232
+ confidence += 0.08;
233
+ reasons.push("durable");
234
+ }
235
+ if (node.type === "decision") {
236
+ confidence += 0.06;
237
+ reasons.push("decision");
238
+ }
239
+ confidence += stabilityBonus(node.updatedAt);
240
+ confidence += feedbackConfidenceBonus(feedback);
241
+ if (feedback?.eventCount) {
242
+ reasons.push(`feedback:${feedback.totalDelta.toFixed(2)}`);
243
+ }
244
+ if (contradictionCount) {
245
+ confidence -= Math.min(0.5, contradictionCount * 0.35);
246
+ reasons.push(`contradictions:${contradictionCount}`);
247
+ }
248
+ const contested = contradictionCount > 0 ||
249
+ (feedback?.notUsefulCount ?? 0) >= 2 ||
250
+ (feedback?.totalDelta ?? 0) <= -1;
251
+ const healthyThreshold = chooseNodeHealthyThreshold(node);
252
+ const canPromote = node.canonicality === "suggested" && !contested && confidence >= healthyThreshold;
253
+ const nextCanonicality = canPromote ? "canonical" : node.canonicality;
254
+ const nextStatus = contested ? "contested" : node.status === "contested" ? "active" : node.status === "archived" ? "archived" : "active";
255
+ const nextState = contested ? "contested" : confidence >= healthyThreshold ? "healthy" : "low_confidence";
256
+ return {
257
+ entityType: "node",
258
+ entityId: node.id,
259
+ state: nextState,
260
+ confidence: clampConfidence(confidence),
261
+ reasons,
262
+ eventType: buildEventType(previousState, nextState, canPromote, false),
263
+ nextNodeStatus: nextStatus,
264
+ nextCanonicality,
265
+ metadata: {
266
+ contradictionCount,
267
+ feedbackDelta: feedback?.totalDelta ?? 0,
268
+ feedbackCount: feedback?.eventCount ?? 0
269
+ }
270
+ };
271
+ }
272
+ function evaluateRelationGovernance(repository, relation, policy, usage = repository.getRelationUsageSummaries([relation.id]).get(relation.id), previousState = repository.getGovernanceStateNullable("relation", relation.id)) {
273
+ let confidence = chooseRelationBaseConfidence(relation, policy);
274
+ const reasons = [`source:${relation.sourceType ?? "unknown"}`];
275
+ if (relation.status === "active") {
276
+ confidence += 0.08;
277
+ }
278
+ if (usage) {
279
+ confidence += Math.min(Math.max(usage.totalDelta, -2), 2) * 0.15;
280
+ reasons.push(`usage:${usage.totalDelta.toFixed(2)}`);
281
+ }
282
+ confidence = clampConfidence(confidence);
283
+ const hardReject = (usage?.eventCount ?? 0) >= 2 && (usage?.totalDelta ?? 0) <= -1.25;
284
+ const contested = (usage?.totalDelta ?? 0) <= -0.75;
285
+ const activeThreshold = chooseRelationActiveThreshold(relation);
286
+ const nextRelationStatus = hardReject
287
+ ? "rejected"
288
+ : confidence >= activeThreshold
289
+ ? "active"
290
+ : "suggested";
291
+ const nextState = contested ? "contested" : confidence >= activeThreshold ? "healthy" : "low_confidence";
292
+ return {
293
+ entityType: "relation",
294
+ entityId: relation.id,
295
+ state: nextState,
296
+ confidence,
297
+ reasons,
298
+ eventType: buildEventType(previousState, nextState, false, nextRelationStatus === "rejected"),
299
+ nextRelationStatus,
300
+ metadata: {
301
+ usageDelta: usage?.totalDelta ?? 0,
302
+ usageCount: usage?.eventCount ?? 0
303
+ }
304
+ };
305
+ }
306
+ function persistGovernanceEvaluation(repository, evaluation, options) {
307
+ const currentState = options?.currentState ?? repository.getGovernanceStateNullable(evaluation.entityType, evaluation.entityId);
308
+ const beforeNode = options?.beforeNode ?? (evaluation.entityType === "node" ? repository.getNode(evaluation.entityId) : null);
309
+ const beforeRelation = options?.beforeRelation ?? (evaluation.entityType === "relation" ? repository.getRelation(evaluation.entityId) : null);
310
+ if (evaluation.entityType === "node") {
311
+ if (evaluation.nextCanonicality && beforeNode && beforeNode.canonicality !== evaluation.nextCanonicality) {
312
+ repository.setNodeCanonicality(evaluation.entityId, evaluation.nextCanonicality);
313
+ }
314
+ if (evaluation.nextNodeStatus && beforeNode && beforeNode.status !== evaluation.nextNodeStatus) {
315
+ repository.updateNode(evaluation.entityId, { status: evaluation.nextNodeStatus });
316
+ }
317
+ }
318
+ if (evaluation.entityType === "relation" && evaluation.nextRelationStatus && beforeRelation && beforeRelation.status !== evaluation.nextRelationStatus) {
319
+ repository.updateRelationStatus(evaluation.entityId, evaluation.nextRelationStatus);
320
+ }
321
+ const state = repository.upsertGovernanceState({
322
+ entityType: evaluation.entityType,
323
+ entityId: evaluation.entityId,
324
+ state: evaluation.state,
325
+ confidence: evaluation.confidence,
326
+ reasons: evaluation.reasons,
327
+ lastEvaluatedAt: nowIso(),
328
+ metadata: evaluation.metadata,
329
+ previousState: currentState
330
+ });
331
+ repository.appendGovernanceEvent({
332
+ entityType: evaluation.entityType,
333
+ entityId: evaluation.entityId,
334
+ eventType: evaluation.eventType,
335
+ previousState: currentState?.state ?? null,
336
+ nextState: evaluation.state,
337
+ confidence: evaluation.confidence,
338
+ reason: evaluation.reasons.join(", "),
339
+ metadata: {
340
+ ...evaluation.metadata,
341
+ nextCanonicality: evaluation.nextCanonicality ?? null,
342
+ nextNodeStatus: evaluation.nextNodeStatus ?? null,
343
+ nextRelationStatus: evaluation.nextRelationStatus ?? null
344
+ }
345
+ });
346
+ return state;
347
+ }
348
+ export function recomputeAutomaticGovernance(repository, input, policy = resolveGovernancePolicy(repository.getSettings(["review.autoApproveLowRisk", "review.trustedSourceToolNames"]))) {
349
+ const targets = repository.recomputeGovernanceTargets(input);
350
+ const items = [];
351
+ let promotedCount = 0;
352
+ let contestedCount = 0;
353
+ const updatedNodes = new Map();
354
+ const nodes = targets.nodeIds.map((nodeId) => repository.getNode(nodeId));
355
+ const nodeFeedback = repository.getSearchFeedbackSummaries("node", nodes.map((node) => node.id));
356
+ const nodeStates = new Map(nodes.map((node) => [node.id, repository.getGovernanceStateNullable("node", node.id)]));
357
+ const relations = targets.relationIds.map((relationId) => repository.getRelation(relationId));
358
+ const relationUsage = repository.getRelationUsageSummaries(relations.map((relation) => relation.id));
359
+ const relationStates = new Map(relations.map((relation) => [relation.id, repository.getGovernanceStateNullable("relation", relation.id)]));
360
+ for (const node of nodes) {
361
+ const evaluation = evaluateNodeGovernance(repository, node, policy, nodeFeedback.get(node.id), nodeStates.get(node.id) ?? null);
362
+ if (evaluation.nextCanonicality === "canonical" && node.canonicality !== "canonical") {
363
+ promotedCount += 1;
364
+ }
365
+ if (evaluation.state === "contested") {
366
+ contestedCount += 1;
367
+ }
368
+ items.push(persistGovernanceEvaluation(repository, evaluation, {
369
+ currentState: nodeStates.get(node.id) ?? null,
370
+ beforeNode: node
371
+ }));
372
+ updatedNodes.set(node.id, {
373
+ ...node,
374
+ status: evaluation.nextNodeStatus ?? node.status,
375
+ canonicality: evaluation.nextCanonicality ?? node.canonicality
376
+ });
377
+ }
378
+ for (const relation of relations) {
379
+ const evaluation = evaluateRelationGovernance(repository, relation, policy, relationUsage.get(relation.id), relationStates.get(relation.id) ?? null);
380
+ if (evaluation.state === "contested") {
381
+ contestedCount += 1;
382
+ }
383
+ items.push(persistGovernanceEvaluation(repository, evaluation, {
384
+ currentState: relationStates.get(relation.id) ?? null,
385
+ beforeRelation: relation
386
+ }));
387
+ }
388
+ return {
389
+ updatedCount: items.length,
390
+ promotedCount,
391
+ contestedCount,
392
+ items,
393
+ updatedNodes
394
+ };
395
+ }
396
+ export function bootstrapAutomaticGovernance(repository) {
397
+ const legacyReviewItems = repository.listLegacyReviewItems();
398
+ if (legacyReviewItems.length === 0) {
399
+ return {
400
+ updatedCount: 0,
401
+ promotedCount: 0,
402
+ contestedCount: 0,
403
+ items: []
404
+ };
405
+ }
406
+ const migratedNodeIds = new Set();
407
+ const migratedRelationIds = new Set();
408
+ for (const item of legacyReviewItems) {
409
+ if (item.entityType !== "node" && item.entityType !== "relation") {
410
+ continue;
411
+ }
412
+ const state = item.status === "pending" ? "contested" : "low_confidence";
413
+ const current = repository.getGovernanceStateNullable(item.entityType, item.entityId);
414
+ repository.upsertGovernanceState({
415
+ entityType: item.entityType,
416
+ entityId: item.entityId,
417
+ state,
418
+ confidence: item.status === "pending" ? 0.2 : 0.4,
419
+ reasons: [`legacy review migrated from ${item.status}`],
420
+ metadata: {
421
+ legacyReviewId: item.id,
422
+ legacyReviewType: item.reviewType
423
+ }
424
+ });
425
+ repository.appendGovernanceEvent({
426
+ entityType: item.entityType,
427
+ entityId: item.entityId,
428
+ eventType: "migrated",
429
+ previousState: current?.state ?? null,
430
+ nextState: state,
431
+ confidence: item.status === "pending" ? 0.2 : 0.4,
432
+ reason: `Migrated legacy review item ${item.id}`,
433
+ metadata: {
434
+ legacyReviewStatus: item.status,
435
+ legacyReviewType: item.reviewType
436
+ }
437
+ });
438
+ if (item.entityType === "node") {
439
+ migratedNodeIds.add(item.entityId);
440
+ }
441
+ else {
442
+ migratedRelationIds.add(item.entityId);
443
+ }
444
+ }
445
+ repository.clearLegacyReviewQueue();
446
+ const nodeResult = migratedNodeIds.size > 0
447
+ ? recomputeAutomaticGovernance(repository, {
448
+ entityType: "node",
449
+ entityIds: Array.from(migratedNodeIds),
450
+ limit: migratedNodeIds.size
451
+ })
452
+ : { updatedCount: 0, promotedCount: 0, contestedCount: 0, items: [] };
453
+ const relationResult = migratedRelationIds.size > 0
454
+ ? recomputeAutomaticGovernance(repository, {
455
+ entityType: "relation",
456
+ entityIds: Array.from(migratedRelationIds),
457
+ limit: migratedRelationIds.size
458
+ })
459
+ : { updatedCount: 0, promotedCount: 0, contestedCount: 0, items: [] };
460
+ return {
461
+ updatedCount: nodeResult.updatedCount + relationResult.updatedCount,
462
+ promotedCount: nodeResult.promotedCount + relationResult.promotedCount,
463
+ contestedCount: nodeResult.contestedCount + relationResult.contestedCount,
464
+ items: [...nodeResult.items, ...relationResult.items]
465
+ };
466
+ }
@@ -0,0 +1,26 @@
1
+ import { createServer } from "node:http";
2
+ import { createServerConfig, ensureApiToken } from "./config.js";
3
+ import { createRecallXApp, resolveRendererDistDir } from "./app.js";
4
+ import { resolveWorkspaceRoot } from "./workspace.js";
5
+ import { WorkspaceSessionManager } from "./workspace-session.js";
6
+ const workspaceRoot = resolveWorkspaceRoot();
7
+ const config = createServerConfig(workspaceRoot);
8
+ const apiToken = ensureApiToken(config);
9
+ const workspaceSessionManager = new WorkspaceSessionManager(config, workspaceRoot, config.apiToken ? "bearer" : "optional");
10
+ const app = createRecallXApp({
11
+ workspaceSessionManager,
12
+ apiToken: config.apiToken ? apiToken : null
13
+ });
14
+ createServer(app).listen(config.port, config.bindAddress, () => {
15
+ console.log(`RecallX API listening on http://${config.bindAddress}:${config.port}`);
16
+ if (resolveRendererDistDir()) {
17
+ console.log(`RecallX UI available at http://${config.bindAddress}:${config.port}/`);
18
+ }
19
+ else {
20
+ console.log("Renderer bundle: not installed (headless mode)");
21
+ }
22
+ console.log(`Workspace root: ${workspaceSessionManager.getCurrent().workspaceRoot}`);
23
+ if (!config.apiToken) {
24
+ console.log("Auth mode: optional (set RECALLX_API_TOKEN to enforce bearer auth)");
25
+ }
26
+ });