recallx-headless 1.1.0 → 1.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -6,7 +6,7 @@ import { fileURLToPath, pathToFileURL } from "node:url";
6
6
  import { getApiBase, getAuthToken, requestJson } from "./http.js";
7
7
  import { RECALLX_VERSION } from "../../shared/version.js";
8
8
  import { applyCliUpdate, getCliUpdatePlan } from "./update.js";
9
- import { renderActivitySearchResults, renderBundleMarkdown, renderGovernanceIssues, renderJson, renderNode, renderRelated, renderSearchResults, renderTelemetryErrors, renderTelemetrySummary, renderText, renderUpdateResult, renderWorkspaceSearchResults, renderWorkspaces, } from "./format.js";
9
+ import { renderActivitySearchResults, renderBundleMarkdown, renderGovernanceIssues, renderJson, renderNode, renderRelated, renderSearchResults, renderTelemetryErrors, renderTelemetrySummary, renderText, renderUpdateResult, renderWorkspaceBackups, renderWorkspaceSearchResults, renderWorkspaces, } from "./format.js";
10
10
  const DEFAULT_SOURCE = {
11
11
  actorType: "human",
12
12
  actorLabel: "recallx-cli",
@@ -465,6 +465,27 @@ async function runWorkspace(apiBase, token, format, args, positionals) {
465
465
  action,
466
466
  rootPath: args.root || args.path || positionals[1],
467
467
  });
468
+ case "backups": {
469
+ const data = await requestJson(apiBase, "/workspaces/backups", { token });
470
+ outputData(data, format, "workspace-backups");
471
+ return;
472
+ }
473
+ case "backup":
474
+ return runPostCommand(apiBase, token, format, "/workspaces/backups", "workspace-backup", {
475
+ label: args.label || args.name || positionals[1],
476
+ });
477
+ case "export":
478
+ return runPostCommand(apiBase, token, format, "/workspaces/export", "workspace-export", {
479
+ format: args.format || args.kind || "json",
480
+ });
481
+ case "restore":
482
+ validateRequired(args.backup || args.id || positionals[1], "workspace restore requires --backup");
483
+ validateRequired(args.root || args.path || positionals[2], "workspace restore requires --root");
484
+ return runPostCommand(apiBase, token, format, "/workspaces/restore", "workspace-restore", {
485
+ backupId: args.backup || args.id || positionals[1],
486
+ targetRootPath: args.root || args.path || positionals[2],
487
+ workspaceName: args.name || args.title,
488
+ });
468
489
  }
469
490
  throw new Error(`Unknown workspace action: ${action}`);
470
491
  }
@@ -547,6 +568,9 @@ function outputData(data, format, command) {
547
568
  case "workspace-list":
548
569
  writeStdout(renderWorkspaces(payload));
549
570
  return;
571
+ case "workspace-backups":
572
+ writeStdout(renderWorkspaceBackups(payload));
573
+ return;
550
574
  case "append":
551
575
  case "create":
552
576
  case "link":
@@ -557,6 +581,9 @@ function outputData(data, format, command) {
557
581
  case "workspace-current":
558
582
  case "workspace-create":
559
583
  case "workspace-open":
584
+ case "workspace-backup":
585
+ case "workspace-export":
586
+ case "workspace-restore":
560
587
  writeStdout(renderText(payload));
561
588
  return;
562
589
  case "observability-summary":
@@ -696,6 +723,10 @@ Usage:
696
723
  recallx workspace list
697
724
  recallx workspace create --root /path/to/workspace [--name "Personal"]
698
725
  recallx workspace open --root /path/to/workspace
726
+ recallx workspace backups
727
+ recallx workspace backup [--label "before-upgrade"]
728
+ recallx workspace export [--format json|markdown]
729
+ recallx workspace restore --backup <id> --root /path/to/restore [--name "Recovered"]
699
730
  recallx observability summary [--since 24h]
700
731
  recallx observability errors [--since 24h] [--surface mcp] [--limit 50]
701
732
 
@@ -144,6 +144,20 @@ export function renderWorkspaces(data) {
144
144
  .join("\n\n")}\n`;
145
145
  }
146
146
 
147
+ export function renderWorkspaceBackups(data) {
148
+ const items = data?.items || [];
149
+ if (!items.length) {
150
+ return "No backups.\n";
151
+ }
152
+
153
+ return `${items
154
+ .map(
155
+ (item, index) =>
156
+ `${index + 1}. ${item.label || item.id}\n id: ${item.id}\n created: ${item.createdAt}\n path: ${item.backupPath}`,
157
+ )
158
+ .join("\n\n")}\n`;
159
+ }
160
+
147
161
  export function renderUpdateResult(data) {
148
162
  const lines = [
149
163
  `package: ${data?.packageName || ""}`,
package/app/mcp/server.js CHANGED
@@ -587,11 +587,55 @@ export function createRecallXMcpServer(params) {
587
587
  const observability = createObservabilityWriter({
588
588
  getState: () => currentObservabilityState
589
589
  });
590
+ // Session-level feedback tracking for automatic signal collection.
591
+ // Tracks which node IDs appeared in read results so that after a write we can
592
+ // auto-append search/relation feedback for items that were actually useful.
593
+ const sessionFeedback = {
594
+ recentSearches: [],
595
+ recentBundles: [],
596
+ runId: `run_${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 8)}`,
597
+ trackSearch(query, resultIds, resultType) {
598
+ this.recentSearches.push({ query, resultIds, resultType });
599
+ if (this.recentSearches.length > 50)
600
+ this.recentSearches.shift();
601
+ },
602
+ trackBundle(targetId, itemIds) {
603
+ this.recentBundles.push({ targetId, itemIds });
604
+ if (this.recentBundles.length > 50)
605
+ this.recentBundles.shift();
606
+ }
607
+ };
608
+ /**
609
+ * Try to auto-append feedback for search results that led to successful reads.
610
+ * Called after a successful write tool (create_node, append_activity, capture_memory).
611
+ */
612
+ async function autoAppendSearchFeedback() {
613
+ // Only append for the most recent search that had results. Avoid spam by
614
+ // checking if we already appended feedback for the same search in this session.
615
+ const lastSearch = sessionFeedback.recentSearches.at(-1);
616
+ if (!lastSearch || !lastSearch.resultIds.length)
617
+ return;
618
+ try {
619
+ await apiClient.post("/search-feedback-events", {
620
+ resultType: lastSearch.resultType,
621
+ resultId: lastSearch.resultIds[0],
622
+ verdict: "useful",
623
+ query: lastSearch.query,
624
+ sessionId: sessionFeedback.runId,
625
+ confidence: 0.7
626
+ });
627
+ // Remove the search so we don't append again for the same search.
628
+ sessionFeedback.recentSearches.pop();
629
+ }
630
+ catch {
631
+ // Feedback append is best-effort — don't break the main tool call.
632
+ }
633
+ }
590
634
  const server = new McpServer({
591
635
  name: "recallx-mcp",
592
636
  version: params?.serverVersion ?? RECALLX_VERSION
593
637
  }, {
594
- instructions: "Use RecallX as the default local memory layer for meaningful work, not as an afterthought. Treat the current workspace as the default scope, and do not create or open another workspace unless the user explicitly asks. Before making assumptions or starting a meaningful task, read context first: confirm the active workspace, use recallx_search_workspace as the broad default when the target is still unclear, narrow with recallx_search_nodes or recallx_search_activities when needed, and build a compact recallx_context_bundle before deep execution when a node or project is known. When the work is clearly project-shaped, search for an existing project inside the current workspace first, create one only if no suitable project exists, and then anchor follow-up context and routine logs to that project. 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. Prefer read tools before durable writes, prefer compact context over repeated broad browsing, and write back concise summaries, decisions, or feedback when RecallX materially helped the task. Include source details on durable writes when you want caller-specific provenance.",
638
+ instructions: "Use RecallX as the default local memory layer for meaningful work, not as an afterthought. Treat the current workspace as the default scope, and do not create or open another workspace unless the user explicitly asks. Before making assumptions or starting a meaningful task, read context first: confirm the active workspace, use recallx_search_workspace as the broad default when the target is still unclear, narrow with recallx_search_nodes or recallx_search_activities when needed, and build a compact recallx_context_bundle before deep execution when a node or project is known. When the work is clearly project-shaped, search for an existing project inside the current workspace first, create one only if no suitable project exists, and then anchor follow-up context and routine logs to that project. 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. Prefer read tools before durable writes, prefer compact context over repeated broad browsing, and write back concise summaries, decisions, or feedback when RecallX materially helped the task. Include source details on durable writes when you want caller-specific provenance. Feedback signals (search usefulness, relation usefulness) are automatically recorded on your behalf — you do NOT need to call feedback tools manually.",
595
639
  capabilities: {
596
640
  logging: {}
597
641
  }
@@ -645,19 +689,25 @@ export function createRecallXMcpServer(params) {
645
689
  }),
646
690
  outputSchema: healthOutputSchema
647
691
  }, async () => toolResult("recallx_health", await apiClient.get("/health")));
648
- registerReadOnlyTool(server, "recallx_workspace_current", {
649
- title: "Current Workspace",
650
- description: "Read the currently active RecallX workspace and auth mode. Use this to confirm the default workspace scope before deciding whether an explicit user request justifies switching workspaces.",
651
- outputSchema: workspaceInfoSchema
652
- }, createGetToolHandler("recallx_workspace_current", apiClient, "/workspace"));
653
- registerReadOnlyTool(server, "recallx_workspace_list", {
654
- title: "List Workspaces",
655
- description: "List known RecallX workspaces and identify the currently active one.",
692
+ registerReadOnlyTool(server, "recallx_workspace_info", {
693
+ title: "Workspace Information",
694
+ description: "Read the active RecallX workspace and optionally list all known workspaces in one call. **When to use:** at the start of any task to confirm scope. Do not create or open another workspace unless the user explicitly asks.",
695
+ inputSchema: {
696
+ includeList: coerceBooleanSchema(false).describe("Set true to also return all known workspaces alongside the active one.")
697
+ },
656
698
  outputSchema: z.object({
657
699
  current: workspaceInfoSchema,
658
- items: z.array(workspaceInfoSchema.extend({ isCurrent: z.boolean(), lastOpenedAt: z.string() }))
700
+ items: z.array(workspaceInfoSchema.extend({ isCurrent: z.boolean(), lastOpenedAt: z.string() })).optional()
659
701
  })
660
- }, createGetToolHandler("recallx_workspace_list", apiClient, "/workspaces"));
702
+ }, async ({ includeList }) => {
703
+ const current = await apiClient.get("/workspace");
704
+ const result = { current: current };
705
+ if (includeList) {
706
+ const list = await apiClient.get("/workspaces");
707
+ result.items = (list.items ?? []);
708
+ }
709
+ return toolResult("recallx_workspace_info", result);
710
+ });
661
711
  registerTool(server, "recallx_workspace_create", {
662
712
  title: "Create Workspace",
663
713
  description: "Create a RecallX workspace on disk and switch the running service to it without restarting. Only use this when the user explicitly requests creating or switching to a new workspace.",
@@ -673,9 +723,14 @@ export function createRecallXMcpServer(params) {
673
723
  rootPath: z.string().min(1).describe("Existing workspace root path to open.")
674
724
  }
675
725
  }, createPostToolHandler("recallx_workspace_open", apiClient, "/workspaces/open"));
676
- registerReadOnlyTool(server, "recallx_semantic_status", {
677
- title: "Semantic Index Status",
678
- description: "Read the current semantic indexing status, provider configuration, and queued item counts.",
726
+ registerReadOnlyTool(server, "recallx_semantic_overview", {
727
+ title: "Semantic Overview",
728
+ description: "Read semantic index status, counts, and optionally active issues in one call. **When to use:** during workspace health checks or when search results seem unexpectedly stale. Not needed for routine coding tasks.",
729
+ inputSchema: {
730
+ includeIssues: coerceBooleanSchema(false).describe("Set true to also return recent semantic indexing issues."),
731
+ issueLimit: coerceIntegerSchema(5, 1, 25).describe("Max issue items when includeIssues is true."),
732
+ issueStatuses: z.array(z.enum(["pending", "stale", "failed"])).max(3).optional().describe("Issue statuses to include.")
733
+ },
679
734
  outputSchema: z.object({
680
735
  enabled: z.boolean(),
681
736
  provider: z.string().nullable(),
@@ -688,37 +743,30 @@ export function createRecallXMcpServer(params) {
688
743
  stale: z.number(),
689
744
  ready: z.number(),
690
745
  failed: z.number()
691
- })
692
- })
693
- }, createGetToolHandler("recallx_semantic_status", apiClient, "/semantic/status"));
694
- registerReadOnlyTool(server, "recallx_semantic_issues", {
695
- title: "Semantic Index Issues",
696
- description: "Read semantic indexing issues with optional status filters and cursor pagination.",
697
- inputSchema: {
698
- limit: coerceIntegerSchema(5, 1, 25).describe("Maximum number of semantic issue items to return."),
699
- cursor: z.string().min(1).optional().describe("Opaque cursor from a previous semantic issues call."),
700
- statuses: z.array(z.enum(["pending", "stale", "failed"])).max(3).optional().describe("Optional issue statuses to include.")
701
- },
702
- outputSchema: z.object({
703
- items: z.array(z.object({
746
+ }),
747
+ issues: z.array(z.object({
704
748
  nodeId: z.string(),
705
749
  title: z.string().nullable(),
706
750
  embeddingStatus: z.enum(["pending", "processing", "stale", "ready", "failed"]),
707
751
  staleReason: z.string().nullable(),
708
752
  updatedAt: z.string()
709
- })),
710
- nextCursor: z.string().nullable()
753
+ })).optional(),
754
+ nextCursor: z.string().nullable().optional()
711
755
  })
712
- }, async ({ limit, cursor, statuses }) => {
713
- const params = new URLSearchParams();
714
- params.set("limit", String(limit));
715
- if (cursor) {
716
- params.set("cursor", cursor);
717
- }
718
- if (statuses?.length) {
719
- params.set("statuses", statuses.join(","));
756
+ }, async ({ includeIssues, issueLimit, issueStatuses }) => {
757
+ const status = await apiClient.get("/semantic/status");
758
+ const result = { ...status };
759
+ if (includeIssues) {
760
+ const params = new URLSearchParams();
761
+ params.set("limit", String(issueLimit));
762
+ if (issueStatuses?.length) {
763
+ params.set("statuses", issueStatuses.join(","));
764
+ }
765
+ const issuesPayload = await apiClient.get(`/semantic/issues?${params.toString()}`);
766
+ result.issues = (issuesPayload.items ?? []);
767
+ result.nextCursor = typeof issuesPayload.nextCursor === "string" ? issuesPayload.nextCursor : null;
720
768
  }
721
- return toolResult("recallx_semantic_issues", await apiClient.get(`/semantic/issues?${params.toString()}`));
769
+ return toolResult("recallx_semantic_overview", result);
722
770
  });
723
771
  registerReadOnlyTool(server, "recallx_search_nodes", {
724
772
  title: "Search Nodes",
@@ -742,7 +790,14 @@ export function createRecallXMcpServer(params) {
742
790
  offset: coerceIntegerSchema(0, 0, 10_000),
743
791
  sort: z.enum(["relevance", "updated_at"]).default("relevance")
744
792
  }
745
- }, createNormalizedPostToolHandler("recallx_search_nodes", apiClient, "/nodes/search", normalizeNodeSearchInput));
793
+ }, async (input) => {
794
+ const result = await apiClient.post("/nodes/search", normalizeNodeSearchInput(input));
795
+ const items = Array.isArray(result.items) ? result.items : [];
796
+ const ids = items.filter((item) => isRecord(item) && typeof item.id === "string").map((item) => item.id);
797
+ const query = typeof input.query === "string" ? input.query : "";
798
+ sessionFeedback.trackSearch(query, ids, "node");
799
+ return toolResult("recallx_search_nodes", result);
800
+ });
746
801
  registerReadOnlyTool(server, "recallx_search_activities", {
747
802
  title: "Search Activities",
748
803
  description: "Search operational activity timelines by keyword and optional filters. Prefer this for recent logs, change history, and 'what happened recently' questions. Accepts `activityType` and `targetNodeId` aliases and normalizes single strings into arrays.",
@@ -764,7 +819,14 @@ export function createRecallXMcpServer(params) {
764
819
  offset: coerceIntegerSchema(0, 0, 10_000),
765
820
  sort: z.enum(["relevance", "updated_at"]).default("relevance")
766
821
  }
767
- }, createNormalizedPostToolHandler("recallx_search_activities", apiClient, "/activities/search", normalizeActivitySearchInput));
822
+ }, async (input) => {
823
+ const result = await apiClient.post("/activities/search", normalizeActivitySearchInput(input));
824
+ const items = Array.isArray(result.items) ? result.items : [];
825
+ const ids = items.filter((item) => isRecord(item) && typeof item.id === "string").map((item) => item.id);
826
+ const query = typeof input.query === "string" ? input.query : "";
827
+ sessionFeedback.trackSearch(query, ids, "activity");
828
+ return toolResult("recallx_search_activities", result);
829
+ });
768
830
  registerReadOnlyTool(server, "recallx_search_workspace", {
769
831
  title: "Search Workspace",
770
832
  description: "Search nodes, activities, or both through one workspace-wide endpoint. This is the preferred broad entry point when the target node or request shape is still unclear, or when you need both node and activity recall in the current workspace. Use `scopes` as an array such as `[\"nodes\", \"activities\"]`, or use `scope: \"activities\"` for a single scope. Do not pass a comma-separated string like `\"nodes,activities\"`.",
@@ -794,7 +856,15 @@ export function createRecallXMcpServer(params) {
794
856
  offset: coerceIntegerSchema(0, 0, 10_000),
795
857
  sort: z.enum(["relevance", "updated_at", "smart"]).default("relevance")
796
858
  }
797
- }, createNormalizedPostToolHandler("recallx_search_workspace", apiClient, "/search", normalizeWorkspaceSearchInput));
859
+ }, async (input) => {
860
+ const result = await apiClient.post("/search", normalizeWorkspaceSearchInput(input));
861
+ const items = Array.isArray(result.items) ? result.items : [];
862
+ const ids = items.filter((item) => isRecord(item) && typeof (item.id ?? item.nodeId) === "string").map((item) => (item.id ?? item.nodeId));
863
+ const mixedTypes = [...new Set(items.filter((item) => isRecord(item) && typeof item.type === "string").map((item) => item.type))];
864
+ const query = typeof input.query === "string" ? input.query : "";
865
+ sessionFeedback.trackSearch(query, ids, `mixed(${mixedTypes.join(",") || "unknown"})`);
866
+ return toolResult("recallx_search_workspace", result);
867
+ });
798
868
  registerReadOnlyTool(server, "recallx_get_node", {
799
869
  title: "Get Node",
800
870
  description: "Fetch a node together with its related nodes, activities, artifacts, and provenance.",
@@ -823,61 +893,102 @@ export function createRecallXMcpServer(params) {
823
893
  }
824
894
  return toolResult("recallx_get_related", await apiClient.get(`/nodes/${encodeURIComponent(nodeId)}/neighborhood?${query.toString()}`));
825
895
  });
826
- registerTool(server, "recallx_upsert_inferred_relation", {
827
- title: "Upsert Inferred Relation",
828
- description: "Upsert a lightweight inferred relation for retrieval, graph expansion, and later weight adjustment.",
896
+ registerTool(server, "recallx_manage_inferred_relations", {
897
+ title: "Manage Inferred Relations",
898
+ description: "Create or update inferred relations, or trigger a maintenance recompute pass. Use `action='upsert'` to add/update a single relation; use `action='recompute'` to refresh scores from usage events. **When to use:** only when you have strong evidence that two nodes are related and the system has not already inferred it (upsert), or during maintenance workflows (recompute). For routine tasks, prefer `recallx_get_related` to read existing inferred links.",
829
899
  inputSchema: {
830
- fromNodeId: z.string().min(1),
831
- toNodeId: z.string().min(1),
832
- relationType: z.enum(relationTypes),
833
- baseScore: z.number(),
834
- usageScore: z.number().default(0),
835
- finalScore: z.number(),
900
+ action: z.enum(["upsert", "recompute"]).describe("Whether to upsert a single inferred relation or trigger a recompute pass."),
901
+ fromNodeId: z.string().min(1).optional().describe("Source node for upsert action."),
902
+ toNodeId: z.string().min(1).optional().describe("Target node for upsert action."),
903
+ relationType: z.enum(relationTypes).optional().describe("Relation type for upsert."),
904
+ baseScore: z.number().optional().describe("Base confidence score for upsert."),
905
+ usageScore: z.number().default(0).describe("Usage bonus for upsert."),
906
+ finalScore: z.number().optional().describe("Combined score for upsert."),
836
907
  status: z.enum(inferredRelationStatuses).default("active"),
837
- generator: z.string().min(1).describe("Short generator label such as deterministic-linker or coaccess-pass."),
908
+ generator: z.string().min(1).optional().describe("Generator label for upsert or filter for recompute."),
838
909
  evidence: jsonRecordSchema,
839
910
  expiresAt: z.string().optional(),
840
- metadata: jsonRecordSchema
911
+ metadata: jsonRecordSchema,
912
+ relationIds: z.array(z.string().min(1)).max(200).optional().describe("Specific relation IDs to recompute."),
913
+ limit: z.number().int().min(1).max(500).default(100).describe("Max relations for recompute pass.")
841
914
  }
842
- }, createPostToolHandler("recallx_upsert_inferred_relation", apiClient, "/inferred-relations"));
843
- registerTool(server, "recallx_append_relation_usage_event", {
844
- title: "Append Relation Usage Event",
845
- description: "Append a lightweight usage signal after a relation actually helped retrieval or final output.",
846
- inputSchema: {
847
- relationId: z.string().min(1),
848
- relationSource: z.enum(relationSources),
849
- eventType: z.enum(relationUsageEventTypes),
850
- sessionId: z.string().optional(),
851
- runId: z.string().optional(),
852
- source: sourceSchema.optional(),
853
- delta: z.number(),
854
- metadata: jsonRecordSchema
915
+ }, async (input) => {
916
+ if (input.action === "upsert") {
917
+ if (!input.fromNodeId || !input.toNodeId || !input.relationType || input.baseScore === undefined || input.finalScore === undefined) {
918
+ throw new Error("Invalid arguments for tool recallx_manage_inferred_relations: action='upsert' requires fromNodeId, toNodeId, relationType, baseScore, and finalScore.");
919
+ }
920
+ const body = {
921
+ fromNodeId: input.fromNodeId,
922
+ toNodeId: input.toNodeId,
923
+ relationType: input.relationType,
924
+ baseScore: input.baseScore,
925
+ usageScore: input.usageScore,
926
+ finalScore: input.finalScore,
927
+ status: input.status,
928
+ generator: input.generator,
929
+ evidence: input.evidence,
930
+ expiresAt: input.expiresAt,
931
+ metadata: input.metadata
932
+ };
933
+ return toolResult("recallx_manage_inferred_relations", await apiClient.post("/inferred-relations", body));
855
934
  }
856
- }, createPostToolHandler("recallx_append_relation_usage_event", apiClient, "/relation-usage-events"));
857
- registerTool(server, "recallx_append_search_feedback", {
858
- title: "Append Search Feedback",
859
- description: "Append a usefulness signal for a node or activity search result after it helped or failed a task.",
935
+ const body = { limit: input.limit };
936
+ if (input.relationIds?.length)
937
+ body.relationIds = input.relationIds;
938
+ if (input.generator)
939
+ body.generator = input.generator;
940
+ return toolResult("recallx_manage_inferred_relations", await apiClient.post("/inferred-relations/recompute", body));
941
+ });
942
+ registerTool(server, "recallx_append_feedback", {
943
+ title: "Append Feedback",
944
+ description: "Append a usefulness signal for search results or relation links. **Note:** this tool is normally called automatically by the MCP bridge after your task completes. Only call it directly if you want to record ad-hoc feedback during a session.",
860
945
  inputSchema: {
861
- resultType: z.enum(searchFeedbackResultTypes),
862
- resultId: z.string().min(1),
863
- verdict: z.enum(searchFeedbackVerdicts),
864
- query: z.string().optional(),
946
+ feedbackType: z.enum(["search", "relation"]).describe("Whether this is search result feedback or relation usage feedback."),
947
+ resultType: z.enum(searchFeedbackResultTypes).optional().describe("Required when feedbackType='search': 'node' or 'activity'."),
948
+ resultId: z.string().min(1).optional().describe("Required when feedbackType='search': the node or activity ID."),
949
+ verdict: z.enum(searchFeedbackVerdicts).optional().describe("Required when feedbackType='search': 'useful', 'not_useful', or 'uncertain'."),
950
+ relationId: z.string().min(1).optional().describe("Required when feedbackType='relation': the relation ID."),
951
+ relationSource: z.enum(relationSources).optional().describe("Required when feedbackType='relation': 'canonical' or 'inferred'."),
952
+ relationEventType: z.enum(relationUsageEventTypes).optional().describe("Required when feedbackType='relation': e.g. 'bundle_included', 'bundle_used_in_output'."),
953
+ query: z.string().optional().describe("Original search query for context."),
865
954
  sessionId: z.string().optional(),
866
955
  runId: z.string().optional(),
867
956
  source: sourceSchema.optional(),
868
957
  confidence: z.number().min(0).max(1).default(1),
958
+ delta: z.number().default(1).describe("Score delta for relation feedback."),
869
959
  metadata: jsonRecordSchema
870
960
  }
871
- }, createPostToolHandler("recallx_append_search_feedback", apiClient, "/search-feedback-events"));
872
- registerTool(server, "recallx_recompute_inferred_relations", {
873
- title: "Recompute Inferred Relations",
874
- description: "Run an explicit maintenance pass that refreshes inferred relation usage_score and final_score from usage events.",
875
- inputSchema: {
876
- relationIds: z.array(z.string().min(1)).max(200).optional(),
877
- generator: z.string().min(1).optional(),
878
- limit: z.number().int().min(1).max(500).default(100)
961
+ }, async (input) => {
962
+ if (input.feedbackType === "search") {
963
+ if (!input.resultType || !input.resultId || !input.verdict) {
964
+ throw new Error("Invalid arguments for tool recallx_append_feedback: feedbackType='search' requires resultType, resultId, and verdict.");
965
+ }
966
+ return toolResult("recallx_append_feedback", await apiClient.post("/search-feedback-events", {
967
+ resultType: input.resultType,
968
+ resultId: input.resultId,
969
+ verdict: input.verdict,
970
+ query: input.query,
971
+ sessionId: input.sessionId,
972
+ runId: input.runId,
973
+ source: input.source,
974
+ confidence: input.confidence,
975
+ metadata: input.metadata
976
+ }));
977
+ }
978
+ if (!input.relationId || !input.relationSource || !input.relationEventType) {
979
+ throw new Error("Invalid arguments for tool recallx_append_feedback: feedbackType='relation' requires relationId, relationSource, and relationEventType.");
879
980
  }
880
- }, createPostToolHandler("recallx_recompute_inferred_relations", apiClient, "/inferred-relations/recompute"));
981
+ return toolResult("recallx_append_feedback", await apiClient.post("/relation-usage-events", {
982
+ relationId: input.relationId,
983
+ relationSource: input.relationSource,
984
+ eventType: input.relationEventType,
985
+ sessionId: input.sessionId,
986
+ runId: input.runId,
987
+ source: input.source,
988
+ delta: input.delta,
989
+ metadata: input.metadata
990
+ }));
991
+ });
881
992
  registerTool(server, "recallx_append_activity", {
882
993
  title: "Append Activity",
883
994
  description: "Append an activity entry to a specific RecallX node or project timeline with provenance. Use this when you already know the target node or project; otherwise prefer recallx_capture_memory for general workspace-scope updates.",
@@ -919,7 +1030,9 @@ export function createRecallXMcpServer(params) {
919
1030
  }
920
1031
  }, async (input) => {
921
1032
  try {
922
- return toolResult("recallx_create_node", await apiClient.post("/nodes", input));
1033
+ const result = await apiClient.post("/nodes", input);
1034
+ await autoAppendSearchFeedback();
1035
+ return toolResult("recallx_create_node", result);
923
1036
  }
924
1037
  catch (error) {
925
1038
  if (error instanceof RecallXApiError &&
@@ -951,7 +1064,11 @@ export function createRecallXMcpServer(params) {
951
1064
  .min(1)
952
1065
  .max(100)
953
1066
  }
954
- }, async (input) => toolResult("recallx_create_nodes", await apiClient.post("/nodes/batch", input)));
1067
+ }, async (input) => {
1068
+ const result = await apiClient.post("/nodes/batch", input);
1069
+ await autoAppendSearchFeedback();
1070
+ return toolResult("recallx_create_nodes", result);
1071
+ });
955
1072
  registerTool(server, "recallx_create_relation", {
956
1073
  title: "Create Relation",
957
1074
  description: "Create a relation between two nodes. Agent-created relations typically start suggested and are promoted automatically when confidence improves.",
@@ -964,37 +1081,36 @@ export function createRecallXMcpServer(params) {
964
1081
  metadata: jsonRecordSchema
965
1082
  }
966
1083
  }, createPostToolHandler("recallx_create_relation", apiClient, "/relations"));
967
- registerReadOnlyTool(server, "recallx_list_governance_issues", {
968
- title: "List Governance Issues",
969
- description: "List contested or low-confidence governance items that may need inspection.",
1084
+ registerReadOnlyTool(server, "recallx_governance", {
1085
+ title: "Governance",
1086
+ description: "Read governance issues, check a specific entity's state, or trigger a recompute pass. **When to use:** after creating/editing content to verify it landed in good shape, or when reviewing items flagged as contested/low_confidence. Use action='issues' (default) to list problems, action='state' to inspect one entity, or action='recompute' to refresh state.",
970
1087
  inputSchema: {
971
- states: z.array(z.enum(governanceStates)).default(["contested", "low_confidence"]),
972
- limit: z.number().int().min(1).max(100).default(20)
1088
+ action: z.enum(["issues", "state", "recompute"]).default("issues"),
1089
+ states: z.array(z.enum(governanceStates)).default(["contested", "low_confidence"]).describe("Issue states to include (for action='issues')."),
1090
+ limit: z.number().int().min(1).max(100).default(20).describe("Max issues (for action='issues') or recompute batch (for action='recompute')."),
1091
+ entityType: z.enum(["node", "relation"]).optional().describe("Required for action='state': entity type to inspect."),
1092
+ entityId: z.string().min(1).optional().describe("Required for action='state': entity ID to inspect."),
1093
+ entityIds: z.array(z.string().min(1)).max(200).optional().describe("Specific entity IDs to recompute (for action='recompute').")
1094
+ }
1095
+ }, async ({ action, states, limit, entityType, entityId, entityIds }) => {
1096
+ if (action === "state") {
1097
+ if (!entityType || !entityId) {
1098
+ throw new Error("Invalid arguments for tool recallx_governance: action='state' requires entityType and entityId.");
1099
+ }
1100
+ return toolResult("recallx_governance", await apiClient.get(`/governance/state/${encodeURIComponent(entityType)}/${encodeURIComponent(entityId)}`));
1101
+ }
1102
+ if (action === "recompute") {
1103
+ const body = { limit };
1104
+ if (entityIds?.length)
1105
+ body.entityIds = entityIds;
1106
+ return toolResult("recallx_governance", await apiClient.post("/governance/recompute", body));
973
1107
  }
974
- }, async ({ states, limit }) => {
975
1108
  const query = new URLSearchParams({
976
1109
  states: states.join(","),
977
1110
  limit: String(limit)
978
1111
  });
979
- return toolResult("recallx_list_governance_issues", await apiClient.get(`/governance/issues?${query.toString()}`));
1112
+ return toolResult("recallx_governance", await apiClient.get(`/governance/issues?${query.toString()}`));
980
1113
  });
981
- registerReadOnlyTool(server, "recallx_get_governance_state", {
982
- title: "Get Governance State",
983
- description: "Read the current automatic governance state and recent events for a node or relation.",
984
- inputSchema: {
985
- entityType: z.enum(["node", "relation"]),
986
- entityId: z.string().min(1)
987
- }
988
- }, async ({ entityType, entityId }) => toolResult("recallx_get_governance_state", await apiClient.get(`/governance/state/${encodeURIComponent(entityType)}/${encodeURIComponent(entityId)}`)));
989
- registerTool(server, "recallx_recompute_governance", {
990
- title: "Recompute Governance",
991
- description: "Run a bounded automatic governance recompute pass for nodes, relations, or both.",
992
- inputSchema: {
993
- entityType: z.enum(["node", "relation"]).optional(),
994
- entityIds: z.array(z.string().min(1)).max(200).optional(),
995
- limit: z.number().int().min(1).max(500).default(100)
996
- }
997
- }, createPostToolHandler("recallx_recompute_governance", apiClient, "/governance/recompute"));
998
1114
  registerReadOnlyTool(server, "recallx_context_bundle", {
999
1115
  title: "Build Context Bundle",
1000
1116
  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.",
@@ -1022,30 +1138,35 @@ export function createRecallXMcpServer(params) {
1022
1138
  maxItems: 10
1023
1139
  })
1024
1140
  }
1025
- }, async ({ targetId, ...input }) => toolResult("recallx_context_bundle", await apiClient.post("/context/bundles", {
1026
- ...input,
1027
- ...(targetId
1028
- ? {
1029
- target: {
1030
- id: targetId
1141
+ }, async ({ targetId, ...input }) => {
1142
+ const result = await apiClient.post("/context/bundles", {
1143
+ ...input,
1144
+ ...(targetId
1145
+ ? {
1146
+ target: {
1147
+ id: targetId
1148
+ }
1031
1149
  }
1032
- }
1033
- : {})
1034
- })));
1150
+ : {})
1151
+ });
1152
+ const items = Array.isArray(result.items) ? result.items : [];
1153
+ const ids = items.filter((item) => isRecord(item) && typeof item.id === "string").map((item) => item.id);
1154
+ sessionFeedback.trackBundle(targetId, ids);
1155
+ return toolResult("recallx_context_bundle", result);
1156
+ });
1035
1157
  registerTool(server, "recallx_semantic_reindex", {
1036
- title: "Queue Semantic Reindex",
1037
- description: "Queue semantic reindexing for a bounded set of recent active workspace nodes.",
1158
+ title: "Semantic Reindex",
1159
+ description: "Queue semantic reindexing for recent workspace nodes or a specific node. **When to use:** after editing node content that needs updated embeddings, or when semantic search results seem stale. Omit nodeId to reindex recent nodes.",
1038
1160
  inputSchema: {
1039
- limit: coerceIntegerSchema(250, 1, 1000)
1161
+ nodeId: z.string().min(1).optional().describe("If provided, reindex only this specific node. Otherwise, reindex recent active nodes."),
1162
+ limit: coerceIntegerSchema(250, 1, 1000).describe("Max nodes to reindex (ignored when nodeId is provided).")
1040
1163
  }
1041
- }, createPostToolHandler("recallx_semantic_reindex", apiClient, "/semantic/reindex"));
1042
- registerTool(server, "recallx_semantic_reindex_node", {
1043
- title: "Queue Node Semantic Reindex",
1044
- description: "Queue semantic reindexing for a specific node id.",
1045
- inputSchema: {
1046
- nodeId: z.string().min(1)
1164
+ }, async ({ nodeId, limit }) => {
1165
+ if (nodeId) {
1166
+ return toolResult("recallx_semantic_reindex", await apiClient.post(`/semantic/reindex/${encodeURIComponent(nodeId)}`, {}));
1047
1167
  }
1048
- }, async ({ nodeId }) => toolResult("recallx_semantic_reindex_node", await apiClient.post(`/semantic/reindex/${encodeURIComponent(nodeId)}`, {})));
1168
+ return toolResult("recallx_semantic_reindex", await apiClient.post("/semantic/reindex", { limit }));
1169
+ });
1049
1170
  registerReadOnlyTool(server, "recallx_rank_candidates", {
1050
1171
  title: "Rank Candidate Nodes",
1051
1172
  description: "Rank a bounded set of candidate node ids for a target using RecallX request-time retrieval scoring.",