recallx 1.0.5 → 1.0.8

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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 jazpiper
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md CHANGED
@@ -225,3 +225,7 @@ For launcher paths, environment variables, and editor-specific setup, see `docs/
225
225
  - `docs/mcp.md` for MCP bridge setup
226
226
  - `docs/workflows.md` for common usage flows
227
227
  - `docs/schema.md` for storage and data model details
228
+
229
+ ## License
230
+
231
+ MIT. See `LICENSE`.
@@ -304,7 +304,7 @@ export function renderTelemetryErrors(data) {
304
304
  return `${items
305
305
  .map(
306
306
  (item, index) =>
307
- `${index + 1}. [${item.surface}] ${item.operation}\n ts: ${item.ts}\n trace: ${item.traceId}\n error: ${item.errorKind || ""}/${item.errorCode || ""}\n status: ${item.statusCode ?? ""}\n durationMs: ${item.durationMs ?? ""}`
307
+ `${index + 1}. [${item.surface}] ${item.operation}\n ts: ${item.ts}\n trace: ${item.traceId}\n span: ${item.spanId ?? ""}\n parent: ${item.parentSpanId ?? ""}\n error: ${item.errorKind || ""}/${item.errorCode || ""}\n status: ${item.statusCode ?? ""}\n durationMs: ${item.durationMs ?? ""}`
308
308
  )
309
309
  .join("\n\n")}\n`;
310
310
  }
@@ -1,5 +1,5 @@
1
1
  import { buildApiRequestInit, buildApiUrl, parseApiJsonBody } from "../shared/request-runtime.js";
2
- import { currentTelemetryContext } from "../server/observability.js";
2
+ import { currentTelemetryContext, currentTelemetrySpanId } from "../server/observability.js";
3
3
  export class RecallXApiError extends Error {
4
4
  status;
5
5
  code;
@@ -43,6 +43,10 @@ export class RecallXApiClient {
43
43
  if (telemetryContext?.traceId) {
44
44
  headers.set("x-recallx-trace-id", telemetryContext.traceId);
45
45
  }
46
+ const telemetrySpanId = currentTelemetrySpanId();
47
+ if (telemetrySpanId) {
48
+ headers.set("x-recallx-parent-span-id", telemetrySpanId);
49
+ }
46
50
  if (telemetryContext?.toolName) {
47
51
  headers.set("x-recallx-mcp-tool", telemetryContext.toolName);
48
52
  }
package/app/mcp/index.js CHANGED
@@ -10,7 +10,7 @@ function createObservabilityStateReader() {
10
10
  let cachedState = null;
11
11
  let cachedAt = 0;
12
12
  let inFlight = null;
13
- const cacheTtlMs = 5_000;
13
+ const cacheTtlMs = 60_000;
14
14
  return async function readObservabilityState() {
15
15
  const now = Date.now();
16
16
  if (cachedState && now - cachedAt < cacheTtlMs) {
package/app/mcp/server.js CHANGED
@@ -1,6 +1,6 @@
1
1
  import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
2
2
  import * as z from "zod/v4";
3
- import { activityTypes, bundleModes, bundlePresets, canonicalities, captureModes, governanceStates, inferredRelationStatuses, nodeStatuses, nodeTypes, relationSources, relationStatuses, relationTypes, relationUsageEventTypes, searchFeedbackResultTypes, searchFeedbackVerdicts, sourceTypes } from "../shared/contracts.js";
3
+ import { activityTypes, bundleModes, bundlePresets, canonicalities, captureModes, governanceStates, inferredRelationStatuses, normalizeBundleMode, normalizeBundlePreset, nodeStatuses, nodeTypes, relationSources, relationStatuses, relationTypes, relationUsageEventTypes, searchFeedbackResultTypes, searchFeedbackVerdicts, sourceTypes } from "../shared/contracts.js";
4
4
  import { RECALLX_VERSION } from "../shared/version.js";
5
5
  import { createObservabilityWriter, summarizePayloadShape } from "../server/observability.js";
6
6
  import { RecallXApiClient, RecallXApiError } from "./api-client.js";
@@ -67,6 +67,24 @@ function coerceBooleanSchema(defaultValue) {
67
67
  function formatStructuredContent(content) {
68
68
  return JSON.stringify(content, null, 2);
69
69
  }
70
+ function formatInvalidBundleModeMessage(input) {
71
+ const quotedInput = typeof input === "string" && input.trim() ? `'${input}'` : "that value";
72
+ return `Unsupported mode ${quotedInput}. Use one of ${bundleModes.join(", ")}. Common aliases also work: small -> micro, concise -> compact, normal -> standard, full -> deep.`;
73
+ }
74
+ function formatInvalidBundlePresetMessage(input) {
75
+ const quotedInput = typeof input === "string" && input.trim() ? `'${input}'` : "that value";
76
+ return `Unsupported preset ${quotedInput}. Use one of ${bundlePresets.join(", ")}. Common aliases also work: coding -> for-coding, assistant/default -> for-assistant.`;
77
+ }
78
+ function bundleModeSchema(defaultValue) {
79
+ return z.preprocess(normalizeBundleMode, z.enum(bundleModes, {
80
+ error: (issue) => formatInvalidBundleModeMessage(issue.input)
81
+ })).default(defaultValue);
82
+ }
83
+ function bundlePresetSchema(defaultValue) {
84
+ return z.preprocess(normalizeBundlePreset, z.enum(bundlePresets, {
85
+ error: (issue) => formatInvalidBundlePresetMessage(issue.input)
86
+ })).default(defaultValue);
87
+ }
70
88
  function toolResult(structuredContent) {
71
89
  return {
72
90
  content: [
@@ -317,7 +335,7 @@ export function createRecallXMcpServer(params) {
317
335
  name: "recallx-mcp",
318
336
  version: params?.serverVersion ?? RECALLX_VERSION
319
337
  }, {
320
- instructions: "Use RecallX as a local knowledge backend. Treat the current workspace as the default scope, and do not create or open another workspace unless the user explicitly asks. When the work is clearly project-shaped, search for an existing project inside the current workspace first: prefer recallx_search_nodes with type=project, broaden with recallx_search_workspace when needed, create a project node only if no suitable one exists, and then anchor follow-up context with recallx_context_bundle targetId. If the conversation is not project-specific, keep memory at workspace scope. Prefer read tools first, and include source details on durable writes when you want caller-specific provenance.",
338
+ instructions: "Use RecallX as a local knowledge backend. Treat the current workspace as the default scope, and do not create or open another workspace unless the user explicitly asks. When the work is clearly project-shaped, search for an existing project inside the current workspace first: prefer recallx_search_nodes with type=project, broaden with recallx_search_workspace when needed, create a project node only if no suitable one exists, and then anchor follow-up context with recallx_context_bundle targetId. Once a project is known, do not keep writing untargeted workspace captures for routine work logs: append activity to that project or pass targetNodeId on capture writes. Reserve workspace-scope inbox activity for genuinely untargeted, cross-project, or not-yet-classified short logs. If the conversation is not project-specific, keep memory at workspace scope. Prefer read tools first, and include source details on durable writes when you want caller-specific provenance.",
321
339
  capabilities: {
322
340
  logging: {}
323
341
  }
@@ -617,7 +635,7 @@ export function createRecallXMcpServer(params) {
617
635
  }, createPostToolHandler(apiClient, "/activities"));
618
636
  registerTool(server, "recallx_capture_memory", {
619
637
  title: "Capture Memory",
620
- description: "Safely capture a memory item without choosing low-level storage first. Prefer this as the default write when the conversation is not yet tied to a specific project or node. General short logs can stay at workspace scope and be auto-routed into activities, while reusable content can still land as durable memory.",
638
+ description: "Safely capture a memory item without choosing low-level storage first. Prefer this as the default write only when the conversation is not yet tied to a specific project or node. Once a project or target node is known, include targetNodeId or switch to recallx_append_activity for routine work logs. General short logs can stay at workspace scope and be auto-routed into activities, while reusable content can still land as durable memory.",
621
639
  inputSchema: {
622
640
  mode: z.enum(captureModes).default("auto"),
623
641
  body: z.string().min(1),
@@ -726,8 +744,8 @@ export function createRecallXMcpServer(params) {
726
744
  description: "Build a compact RecallX context bundle for coding, research, writing, or decision support. Omit targetId to get a workspace-entry bundle when the work is not yet tied to a specific project or node, and add targetId only after you know which project or node should anchor the context.",
727
745
  inputSchema: {
728
746
  targetId: z.string().min(1).optional(),
729
- mode: z.enum(bundleModes).default("compact"),
730
- preset: z.enum(bundlePresets).default("for-assistant"),
747
+ mode: bundleModeSchema("compact"),
748
+ preset: bundlePresetSchema("for-assistant"),
731
749
  options: z
732
750
  .object({
733
751
  includeRelated: coerceBooleanSchema(true),
@@ -778,7 +796,7 @@ export function createRecallXMcpServer(params) {
778
796
  inputSchema: {
779
797
  query: z.string().default(""),
780
798
  candidateNodeIds: z.array(z.string().min(1)).min(1).max(100),
781
- preset: z.enum(bundlePresets).default("for-assistant"),
799
+ preset: bundlePresetSchema("for-assistant"),
782
800
  targetNodeId: z.string().optional()
783
801
  }
784
802
  }, createPostToolHandler(apiClient, "/retrieval/rank-candidates"));
package/app/server/app.js CHANGED
@@ -9,7 +9,7 @@ import { AppError } from "./errors.js";
9
9
  import { isShortLogLikeAgentNodeInput, maybeCreatePromotionCandidate, recomputeAutomaticGovernance, resolveGovernancePolicy, resolveNodeGovernance, resolveRelationStatus, shouldPromoteActivitySummary } from "./governance.js";
10
10
  import { refreshAutomaticInferredRelationsForNode, reindexAutomaticInferredRelations } from "./inferred-relations.js";
11
11
  import { appendCurrentTelemetryDetails, createObservabilityWriter, summarizePayloadShape } from "./observability.js";
12
- import { buildSemanticCandidateBonusMap, buildCandidateRelationBonusMap, buildContextBundle, buildNeighborhoodItems, buildTargetRelatedRetrievalItems, bundleAsMarkdown, computeRankCandidateScore, shouldUseSemanticCandidateAugmentation } from "./retrieval.js";
12
+ import { buildSemanticCandidateBonusMap, buildCandidateRelationBonusMap, buildContextBundle, buildNeighborhoodItems, buildNeighborhoodItemsBatch, buildTargetRelatedRetrievalItems, bundleAsMarkdown, computeRankCandidateScore, selectSemanticCandidateIds, shouldUseSemanticCandidateAugmentation } from "./retrieval.js";
13
13
  import { buildProjectGraph } from "./project-graph.js";
14
14
  import { createId, isPathWithinRoot } from "./utils.js";
15
15
  const relationTypeSet = new Set(relationTypes);
@@ -1160,6 +1160,25 @@ export function createRecallXApp(params) {
1160
1160
  reason: input.reason
1161
1161
  };
1162
1162
  }
1163
+ function resolveCaptureActivityTarget(repository, input) {
1164
+ if (input.targetNodeId) {
1165
+ return {
1166
+ targetNode: repository.getNode(input.targetNodeId),
1167
+ route: "explicit"
1168
+ };
1169
+ }
1170
+ const activeProjects = repository.listActiveNodesByType("project", 2);
1171
+ if (activeProjects.length === 1) {
1172
+ return {
1173
+ targetNode: activeProjects[0],
1174
+ route: "sole_active_project"
1175
+ };
1176
+ }
1177
+ return {
1178
+ targetNode: repository.ensureWorkspaceInboxNode(),
1179
+ route: "workspace_inbox"
1180
+ };
1181
+ }
1163
1182
  function createDurableNodeResponse(repository, input) {
1164
1183
  const governance = resolveNodeGovernance(input, resolveGovernancePolicy(repository.getSettings(["review.autoApproveLowRisk", "review.trustedSourceToolNames"])));
1165
1184
  const node = repository.createNode({
@@ -1204,6 +1223,7 @@ export function createRecallXApp(params) {
1204
1223
  app.use((request, response, next) => {
1205
1224
  const requestId = createId("req");
1206
1225
  const traceId = request.header("x-recallx-trace-id")?.trim() || createId("trace");
1226
+ const parentSpanId = request.header("x-recallx-parent-span-id")?.trim() || null;
1207
1227
  const operation = `${request.method.toUpperCase()} ${normalizeApiRequestPath(request.path)}`;
1208
1228
  const observabilityState = currentObservabilityConfig();
1209
1229
  const requestSpan = observability.startSpan({
@@ -1211,6 +1231,7 @@ export function createRecallXApp(params) {
1211
1231
  operation,
1212
1232
  requestId,
1213
1233
  traceId,
1234
+ parentSpanId,
1214
1235
  details: {
1215
1236
  ...(observabilityState.capturePayloadShape ? summarizePayloadShape(request.body) : {}),
1216
1237
  mcpTool: request.header("x-recallx-mcp-tool") ?? null
@@ -1221,6 +1242,7 @@ export function createRecallXApp(params) {
1221
1242
  response.locals.telemetryRequestSpan = requestSpan;
1222
1243
  response.setHeader("x-recallx-request-id", requestId);
1223
1244
  response.setHeader("x-recallx-trace-id", traceId);
1245
+ response.setHeader("x-recallx-span-id", requestSpan.spanId);
1224
1246
  response.on("finish", () => {
1225
1247
  void requestSpan.finish({
1226
1248
  outcome: response.statusCode >= 400 ? "error" : "success",
@@ -1229,14 +1251,14 @@ export function createRecallXApp(params) {
1229
1251
  errorKind: response.locals.telemetryErrorKind ?? null
1230
1252
  });
1231
1253
  });
1232
- observability.withContext({
1254
+ requestSpan.run(() => observability.withContext({
1233
1255
  traceId,
1234
1256
  requestId,
1235
1257
  workspaceRoot: currentWorkspaceRoot(),
1236
1258
  workspaceName: currentWorkspaceInfo().workspaceName,
1237
1259
  toolName: request.header("x-recallx-mcp-tool") ?? null,
1238
1260
  surface: "api"
1239
- }, next);
1261
+ }, next));
1240
1262
  });
1241
1263
  app.use(express.json({ limit: "2mb" }));
1242
1264
  app.use("/api/v1", (request, response, next) => {
@@ -1597,7 +1619,8 @@ export function createRecallXApp(params) {
1597
1619
  })()));
1598
1620
  return;
1599
1621
  }
1600
- const targetNode = input.targetNodeId ? repository.getNode(input.targetNodeId) : repository.ensureWorkspaceInboxNode();
1622
+ const captureTarget = resolveCaptureActivityTarget(repository, input);
1623
+ const targetNode = captureTarget.targetNode;
1601
1624
  const activity = repository.appendActivity({
1602
1625
  targetNodeId: targetNode.id,
1603
1626
  activityType: "agent_run_summary",
@@ -1605,6 +1628,7 @@ export function createRecallXApp(params) {
1605
1628
  source,
1606
1629
  metadata: {
1607
1630
  ...input.metadata,
1631
+ autoTargetRoute: captureTarget.route,
1608
1632
  captureMode: input.mode,
1609
1633
  capturedTitle: title
1610
1634
  }
@@ -1635,9 +1659,13 @@ export function createRecallXApp(params) {
1635
1659
  storedAs: "activity",
1636
1660
  status: "recorded",
1637
1661
  governanceState: null,
1638
- reason: input.mode === "activity"
1639
- ? "Capture was explicitly routed to the activity timeline."
1640
- : "Short log-like capture was routed to the activity timeline."
1662
+ reason: captureTarget.route === "sole_active_project"
1663
+ ? input.mode === "activity"
1664
+ ? "Capture was routed to the sole active project timeline."
1665
+ : "Short log-like capture was routed to the sole active project timeline."
1666
+ : input.mode === "activity"
1667
+ ? "Capture was explicitly routed to the activity timeline."
1668
+ : "Short log-like capture was routed to the activity timeline."
1641
1669
  })
1642
1670
  }));
1643
1671
  });
@@ -1751,19 +1779,17 @@ export function createRecallXApp(params) {
1751
1779
  }, (span) => {
1752
1780
  const repository = currentRepository();
1753
1781
  const nodeId = readRequestParam(request.params.id);
1754
- const result = buildNeighborhoodItems(repository, nodeId, {
1782
+ const neighborhoodOptions = {
1755
1783
  relationTypes: types,
1756
1784
  includeInferred,
1757
1785
  maxInferred
1758
- });
1786
+ };
1787
+ const result = buildNeighborhoodItems(repository, nodeId, neighborhoodOptions);
1759
1788
  const expanded = depth === 2
1760
1789
  ? (() => {
1761
1790
  const seen = new Set(result.map((item) => `${item.edge.relationId}:${item.node.id}:1`));
1762
- const secondHop = result.flatMap((item) => buildNeighborhoodItems(repository, item.node.id, {
1763
- relationTypes: types,
1764
- includeInferred,
1765
- maxInferred
1766
- })
1791
+ const secondHopByNodeId = buildNeighborhoodItemsBatch(repository, result.map((item) => item.node.id), neighborhoodOptions);
1792
+ const secondHop = result.flatMap((item) => (secondHopByNodeId.get(item.node.id) ?? [])
1767
1793
  .filter((nested) => nested.node.id !== nodeId)
1768
1794
  .map((nested) => ({
1769
1795
  ...nested,
@@ -1786,7 +1812,8 @@ export function createRecallXApp(params) {
1786
1812
  })()
1787
1813
  : result;
1788
1814
  span.addDetails({
1789
- resultCount: expanded.length
1815
+ resultCount: expanded.length,
1816
+ firstHopCount: result.length
1790
1817
  });
1791
1818
  return expanded;
1792
1819
  });
@@ -2126,8 +2153,9 @@ export function createRecallXApp(params) {
2126
2153
  });
2127
2154
  const semanticAugmentation = repository.getSemanticAugmentationSettings();
2128
2155
  const semanticEnabled = shouldUseSemanticCandidateAugmentation(query, candidates);
2156
+ const semanticCandidateIds = selectSemanticCandidateIds(query, candidates);
2129
2157
  const semanticBonuses = semanticEnabled
2130
- ? buildSemanticCandidateBonusMap(await repository.rankSemanticCandidates(query, candidateNodeIds), semanticAugmentation)
2158
+ ? buildSemanticCandidateBonusMap(await repository.rankSemanticCandidates(query, semanticCandidateIds), semanticAugmentation)
2131
2159
  : new Map();
2132
2160
  const result = candidates
2133
2161
  .map((node) => {
@@ -2151,7 +2179,9 @@ export function createRecallXApp(params) {
2151
2179
  .sort((left, right) => right.score - left.score);
2152
2180
  span.addDetails({
2153
2181
  resultCount: result.length,
2154
- semanticUsed: semanticEnabled
2182
+ semanticUsed: semanticEnabled,
2183
+ semanticCandidateCount: candidates.length,
2184
+ semanticRankedCandidateCount: semanticCandidateIds.length
2155
2185
  });
2156
2186
  return result;
2157
2187
  });
@@ -1,6 +1,6 @@
1
1
  import { AppError } from "./errors.js";
2
2
  import { countTokensApprox, nowIso } from "./utils.js";
3
- const relaxedShortFormNodeTypes = new Set(["reference", "question", "conversation"]);
3
+ const relaxedShortFormNodeTypes = new Set(["project", "reference", "question", "conversation"]);
4
4
  function clampConfidence(value) {
5
5
  return Math.min(Math.max(value, 0), 1);
6
6
  }
@@ -14,6 +14,9 @@ function normalizeText(value) {
14
14
  function normalizeTags(tags) {
15
15
  return Array.from(new Set(tags.map((tag) => normalizeText(tag)).filter(Boolean)));
16
16
  }
17
+ function normalizeTexts(values) {
18
+ return values.map(normalizeText).filter(Boolean);
19
+ }
17
20
  function escapeRegExp(value) {
18
21
  return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
19
22
  }
@@ -33,6 +36,10 @@ function mentionsNode(haystacks, candidate) {
33
36
  : false;
34
37
  return { idMention, titleMention };
35
38
  }
39
+ function hasCheapMention(normalizedHaystacks, candidate) {
40
+ const normalizedTitle = normalizeText(candidate.title);
41
+ return normalizedHaystacks.some((haystack) => haystack.includes(candidate.id.toLowerCase()) || (normalizedTitle.length >= 5 && haystack.includes(normalizedTitle)));
42
+ }
36
43
  function sortPair(left, right) {
37
44
  return left.localeCompare(right) <= 0 ? [left, right] : [right, left];
38
45
  }
@@ -154,36 +161,42 @@ function collectGeneratedCandidates(repository, target, trigger) {
154
161
  projectIds: new Set(projectMembershipsByNodeId.get(target.id) ?? []),
155
162
  artifactKeys: artifactKeysByNodeId.get(target.id) ?? { exactPaths: [], baseNames: [] }
156
163
  };
164
+ const normalizedTargetTexts = normalizeTexts([target.title, target.body, target.summary]);
157
165
  const activityBodies = trigger === "activity-append" || trigger === "reindex"
158
166
  ? repository
159
167
  .listNodeActivities(target.id, MAX_ACTIVITY_BODIES)
160
168
  .map((activity) => activity.body ?? "")
161
169
  .filter(Boolean)
162
170
  : [];
171
+ const normalizedActivityBodies = activityBodies.map(normalizeText).filter(Boolean);
163
172
  return candidates.flatMap((candidate) => {
164
173
  const generated = [];
174
+ const candidateProjectIds = projectMembershipsByNodeId.get(candidate.id) ?? [];
175
+ const candidateArtifacts = artifactKeysByNodeId.get(candidate.id) ?? { exactPaths: [], baseNames: [] };
165
176
  const tagOverlapCandidate = buildTagOverlapCandidate(target, candidate, targetContext.normalizedTags);
166
177
  if (tagOverlapCandidate) {
167
178
  generated.push(tagOverlapCandidate);
168
179
  }
169
- const bodyReferenceCandidate = buildBodyReferenceCandidate(target, candidate);
180
+ const projectMembershipCandidate = buildProjectMembershipCandidate(target, candidate, candidateProjectIds, targetContext.projectIds);
181
+ if (projectMembershipCandidate) {
182
+ generated.push(projectMembershipCandidate);
183
+ }
184
+ const sharedArtifactCandidate = buildSharedArtifactCandidate(target, candidate, candidateArtifacts, targetContext.artifactKeys);
185
+ if (sharedArtifactCandidate) {
186
+ generated.push(sharedArtifactCandidate);
187
+ }
188
+ const candidateTexts = normalizeTexts([candidate.title, candidate.body, candidate.summary]);
189
+ const shouldCheckBodyReference = hasCheapMention(normalizedTargetTexts, candidate) || hasCheapMention(candidateTexts, target);
190
+ const bodyReferenceCandidate = shouldCheckBodyReference ? buildBodyReferenceCandidate(target, candidate) : null;
170
191
  if (bodyReferenceCandidate) {
171
192
  generated.push(bodyReferenceCandidate);
172
193
  }
173
- if (activityBodies.length) {
194
+ if (normalizedActivityBodies.length && hasCheapMention(normalizedActivityBodies, candidate)) {
174
195
  const activityReferenceCandidate = buildActivityReferenceCandidate(target, candidate, activityBodies);
175
196
  if (activityReferenceCandidate) {
176
197
  generated.push(activityReferenceCandidate);
177
198
  }
178
199
  }
179
- const projectMembershipCandidate = buildProjectMembershipCandidate(target, candidate, projectMembershipsByNodeId.get(candidate.id) ?? [], targetContext.projectIds);
180
- if (projectMembershipCandidate) {
181
- generated.push(projectMembershipCandidate);
182
- }
183
- const sharedArtifactCandidate = buildSharedArtifactCandidate(target, candidate, artifactKeysByNodeId.get(candidate.id) ?? { exactPaths: [], baseNames: [] }, targetContext.artifactKeys);
184
- if (sharedArtifactCandidate) {
185
- generated.push(sharedArtifactCandidate);
186
- }
187
200
  return generated;
188
201
  });
189
202
  }
@@ -1,16 +1,41 @@
1
1
  import { AsyncLocalStorage } from "node:async_hooks";
2
- import { appendFile, mkdir, readFile, readdir, unlink } from "node:fs/promises";
2
+ import { createReadStream } from "node:fs";
3
+ import { appendFile, mkdir, readdir, unlink } from "node:fs/promises";
3
4
  import path from "node:path";
5
+ import { createInterface } from "node:readline";
6
+ import { createId } from "./utils.js";
4
7
  const telemetryStorage = new AsyncLocalStorage();
5
8
  function nowIso() {
6
9
  return new Date().toISOString();
7
10
  }
11
+ function createSpanId() {
12
+ return createId("span");
13
+ }
8
14
  function parseJsonLine(line) {
9
15
  if (!line.trim()) {
10
16
  return null;
11
17
  }
12
18
  try {
13
- return JSON.parse(line);
19
+ const parsed = JSON.parse(line);
20
+ if (!parsed || typeof parsed !== "object" || typeof parsed.ts !== "string" || typeof parsed.operation !== "string") {
21
+ return null;
22
+ }
23
+ return {
24
+ ts: parsed.ts,
25
+ traceId: typeof parsed.traceId === "string" ? parsed.traceId : "trace_unknown",
26
+ spanId: typeof parsed.spanId === "string" ? parsed.spanId : null,
27
+ parentSpanId: typeof parsed.parentSpanId === "string" ? parsed.parentSpanId : null,
28
+ requestId: typeof parsed.requestId === "string" ? parsed.requestId : null,
29
+ surface: parsed.surface === "mcp" ? "mcp" : "api",
30
+ operation: parsed.operation,
31
+ outcome: parsed.outcome === "error" ? "error" : "success",
32
+ durationMs: typeof parsed.durationMs === "number" ? parsed.durationMs : null,
33
+ statusCode: typeof parsed.statusCode === "number" ? parsed.statusCode : null,
34
+ errorCode: typeof parsed.errorCode === "string" ? parsed.errorCode : null,
35
+ errorKind: typeof parsed.errorKind === "string" ? parsed.errorKind : null,
36
+ workspaceName: typeof parsed.workspaceName === "string" ? parsed.workspaceName : null,
37
+ details: parsed.details && typeof parsed.details === "object" && !Array.isArray(parsed.details) ? parsed.details : {}
38
+ };
14
39
  }
15
40
  catch {
16
41
  return null;
@@ -22,6 +47,10 @@ function roundDuration(value) {
22
47
  function dateStamp(value) {
23
48
  return value.slice(0, 10);
24
49
  }
50
+ function telemetryLogDate(entry) {
51
+ const match = /^telemetry-(\d{4}-\d{2}-\d{2})\.ndjson$/.exec(entry);
52
+ return match ? match[1] : null;
53
+ }
25
54
  function normalizeRetentionDays(value) {
26
55
  return Math.max(1, Math.trunc(value || 14));
27
56
  }
@@ -142,14 +171,18 @@ export class TelemetrySpan {
142
171
  writer;
143
172
  state;
144
173
  context;
174
+ spanId;
175
+ parentSpanId;
145
176
  operation;
146
177
  startedAt = process.hrtime.bigint();
147
178
  details;
148
179
  finished = false;
149
- constructor(writer, state, context, operation, details) {
180
+ constructor(writer, state, context, spanId, parentSpanId, operation, details) {
150
181
  this.writer = writer;
151
182
  this.state = state;
152
183
  this.context = context;
184
+ this.spanId = spanId;
185
+ this.parentSpanId = parentSpanId;
153
186
  this.operation = operation;
154
187
  this.details = sanitizeDetails(details);
155
188
  }
@@ -165,6 +198,8 @@ export class TelemetrySpan {
165
198
  await this.writer.enqueue({
166
199
  ts: nowIso(),
167
200
  traceId: this.context.traceId,
201
+ spanId: this.spanId,
202
+ parentSpanId: this.parentSpanId,
168
203
  requestId: input.requestId ?? this.context.requestId,
169
204
  surface: this.context.surface,
170
205
  operation: this.operation,
@@ -198,14 +233,16 @@ export class ObservabilityWriter {
198
233
  return telemetryStorage.getStore() ?? null;
199
234
  }
200
235
  withContext(input, callback) {
236
+ const current = telemetryStorage.getStore();
201
237
  return telemetryStorage.run({
202
238
  ...input,
203
- spans: []
239
+ spans: current?.spans ?? []
204
240
  }, callback);
205
241
  }
206
242
  startSpan(input) {
207
243
  const state = this.options.getState();
208
244
  const current = telemetryStorage.getStore();
245
+ const parentSpan = current?.spans[current.spans.length - 1] ?? null;
209
246
  const context = {
210
247
  traceId: input.traceId ?? current?.traceId ?? "trace_unknown",
211
248
  requestId: input.requestId ?? current?.requestId ?? null,
@@ -215,7 +252,7 @@ export class ObservabilityWriter {
215
252
  toolName: current?.toolName ?? null,
216
253
  spans: current?.spans ?? []
217
254
  };
218
- return new TelemetrySpan(this, state, context, input.operation, input.details);
255
+ return new TelemetrySpan(this, state, context, createSpanId(), input.parentSpanId ?? parentSpan?.spanId ?? null, input.operation, input.details);
219
256
  }
220
257
  addCurrentSpanDetails(details) {
221
258
  const current = telemetryStorage.getStore();
@@ -230,6 +267,8 @@ export class ObservabilityWriter {
230
267
  await this.enqueue({
231
268
  ts: nowIso(),
232
269
  traceId: input.traceId ?? current?.traceId ?? "trace_unknown",
270
+ spanId: createSpanId(),
271
+ parentSpanId: current?.spans[current.spans.length - 1]?.spanId ?? null,
233
272
  requestId: input.requestId ?? current?.requestId ?? null,
234
273
  surface: input.surface ?? current?.surface ?? "api",
235
274
  operation: input.operation,
@@ -308,24 +347,41 @@ export class ObservabilityWriter {
308
347
  }
309
348
  const files = entries
310
349
  .filter((entry) => /^telemetry-\d{4}-\d{2}-\d{2}\.ndjson$/.test(entry))
350
+ .filter((entry) => {
351
+ const stamp = telemetryLogDate(entry);
352
+ return stamp !== null && stamp >= dateStamp(new Date(sinceMs).toISOString());
353
+ })
311
354
  .sort();
312
355
  const events = [];
313
356
  for (const file of files) {
314
357
  const filePath = path.join(logsDir, file);
315
- const content = await readFile(filePath, "utf8").catch(() => "");
316
- for (const line of content.split("\n")) {
317
- const event = parseJsonLine(line);
318
- if (!event) {
319
- continue;
320
- }
321
- const eventMs = Date.parse(event.ts);
322
- if (!Number.isFinite(eventMs) || eventMs < sinceMs) {
323
- continue;
324
- }
325
- if (options.surface && options.surface !== "all" && event.surface !== options.surface) {
326
- continue;
358
+ const stream = createReadStream(filePath, { encoding: "utf8" });
359
+ const lines = createInterface({
360
+ input: stream,
361
+ crlfDelay: Number.POSITIVE_INFINITY
362
+ });
363
+ try {
364
+ for await (const line of lines) {
365
+ const event = parseJsonLine(line);
366
+ if (!event) {
367
+ continue;
368
+ }
369
+ const eventMs = Date.parse(event.ts);
370
+ if (!Number.isFinite(eventMs) || eventMs < sinceMs) {
371
+ continue;
372
+ }
373
+ if (options.surface && options.surface !== "all" && event.surface !== options.surface) {
374
+ continue;
375
+ }
376
+ events.push(event);
327
377
  }
328
- events.push(event);
378
+ }
379
+ catch {
380
+ // Ignore individual file read failures so observability endpoints stay resilient.
381
+ }
382
+ finally {
383
+ lines.close();
384
+ stream.destroy();
329
385
  }
330
386
  }
331
387
  return {
@@ -497,9 +553,10 @@ export class ObservabilityWriter {
497
553
  }
498
554
  workspaceFallbackModeBuckets.set(bucketKey, current);
499
555
  }
500
- if (event.details.feedbackVerdict === "useful" ||
501
- event.details.feedbackVerdict === "not_useful" ||
502
- event.details.feedbackVerdict === "uncertain") {
556
+ if (event.operation === "search.feedback" &&
557
+ (event.details.feedbackVerdict === "useful" ||
558
+ event.details.feedbackVerdict === "not_useful" ||
559
+ event.details.feedbackVerdict === "uncertain")) {
503
560
  feedbackSampleCount += 1;
504
561
  if (event.details.feedbackVerdict === "useful") {
505
562
  feedbackUsefulCount += 1;
@@ -863,6 +920,10 @@ export function createObservabilityWriter(options) {
863
920
  export function currentTelemetryContext() {
864
921
  return telemetryStorage.getStore() ?? null;
865
922
  }
923
+ export function currentTelemetrySpanId() {
924
+ const current = telemetryStorage.getStore();
925
+ return current?.spans[current.spans.length - 1]?.spanId ?? null;
926
+ }
866
927
  export function appendCurrentTelemetryDetails(details) {
867
928
  const current = telemetryStorage.getStore();
868
929
  current?.spans[current.spans.length - 1]?.addDetails(details);
@@ -1282,6 +1282,17 @@ export class RecallXRepository {
1282
1282
  tags: parseJson(row.tags_json, [])
1283
1283
  }));
1284
1284
  }
1285
+ listActiveNodesByType(type, limit = 20) {
1286
+ const rows = this.db
1287
+ .prepare(`SELECT *
1288
+ FROM nodes
1289
+ WHERE type = ?
1290
+ AND status = 'active'
1291
+ ORDER BY updated_at DESC, id DESC
1292
+ LIMIT ?`)
1293
+ .all(type, limit);
1294
+ return rows.map(mapNode);
1295
+ }
1285
1296
  listInferenceCandidateNodes(targetNodeId, limit = 200) {
1286
1297
  const rows = this.db
1287
1298
  .prepare(`SELECT * FROM nodes