feat: align MCP retrieval contract with ra-h

- add external retrieval and confirmation-gated writeback tools across MCP surfaces
- port direct-search-first retrieval support and supporting ranking/query updates
- update MCP docs and skills to treat agent memory files as optional reinforcement

Generated with Claude Code
This commit is contained in:
“BeeRad”
2026-04-13 20:50:13 +10:00
parent 5dcb2b7df6
commit d825e7a783
19 changed files with 1875 additions and 73 deletions
+34 -3
View File
@@ -1,9 +1,10 @@
import { tool } from 'ai';
import { z } from 'zod';
import { contextService } from '@/services/database';
import { nodeService } from '@/services/database/nodes';
import { formatNodeForChat } from '../infrastructure/nodeFormatter';
import type { Node } from '@/types/database';
import { scoreNodeSearchMatch } from '@/services/database/searchRanking';
import { countHighSignalQueryTermMatches, getHighSignalSearchTerms, scoreNodeSearchMatch } from '@/services/database/searchRanking';
type QueryNodeFilters = {
contextId?: number;
@@ -16,7 +17,7 @@ type QueryNodeFilters = {
};
export const queryNodesTool = tool({
description: 'Search nodes across title, description, and source. For free-text lookups, search the graph broadly and prioritize title/description matches. Use context filtering only when the user is explicitly asking about a known context.',
description: 'Find specific existing nodes in the graph by searching title, description, and source. Use this first when the user is trying to locate a node they already created or a specific existing podcast, article, idea, person, project, or note. For broader current-turn grounding of a substantive question, use retrieveQueryContext instead. Leave contextId unset unless you know an actual context-table ID; never pass a hub node ID or arbitrary node ID as contextId.',
inputSchema: z.object({
filters: z.object({
contextId: z.number().int().positive().describe('Optional primary context filter.').optional(),
@@ -81,7 +82,8 @@ export const queryNodesTool = tool({
limit,
contextId: queryFilters.contextId,
search: queryFilters.search,
searchMode: searchTerm ? 'hybrid' : 'standard',
// Keep queryNodes literal-first. retrieveQueryContext is the broader semantic path.
searchMode: 'standard',
createdAfter: queryFilters.createdAfter,
createdBefore: queryFilters.createdBefore,
eventAfter: queryFilters.eventAfter,
@@ -93,9 +95,38 @@ export const queryNodesTool = tool({
};
const effectiveFilters = { ...filters };
if (effectiveFilters.contextId !== undefined) {
const context = await contextService.getContextById(effectiveFilters.contextId);
if (!context) {
console.warn(`queryNodes received invalid contextId ${effectiveFilters.contextId}; ignoring context filter.`);
delete effectiveFilters.contextId;
}
}
let safeNodes = await runQuery(effectiveFilters);
const hadExtraFilters = Boolean(
effectiveFilters.contextId !== undefined ||
effectiveFilters.createdAfter ||
effectiveFilters.createdBefore ||
effectiveFilters.eventAfter ||
effectiveFilters.eventBefore
);
const hasStrongAnchorMatch = (nodes: Node[]): boolean => {
if (!searchTerm || nodes.length === 0) return false;
const highSignalTerms = getHighSignalSearchTerms(searchTerm);
const requiredMatches = Math.min(2, highSignalTerms.length || 1);
return nodes.some(node => countHighSignalQueryTermMatches(node, searchTerm) >= requiredMatches);
};
// Match the nav search behavior when the model overconstrains a direct lookup.
// This prevents notes from disappearing behind synthetic date filters or weak filtered matches.
if (searchTerm && hadExtraFilters && (safeNodes.length === 0 || !hasStrongAnchorMatch(safeNodes))) {
console.warn(`queryNodes falling back to plain literal search for "${searchTerm}" after filtered lookup failed to return a strong anchor match.`);
safeNodes = await nodeService.searchNodes(searchTerm, limit);
}
if (searchTerm) {
safeNodes = safeNodes
.map(node => ({ node, score: scoreNodeSearchMatch(node, searchTerm) }))
@@ -0,0 +1,37 @@
import { tool } from 'ai';
import { z } from 'zod';
import { retrieveQueryContext } from '@/services/retrieval/queryContext';
export const retrieveQueryContextTool = tool({
description: 'Given a raw user query plus optional focused node state, retrieve the most relevant graph context to ground the current turn. Use this when the user is asking a substantive question or request that would benefit from one or more prior nodes, chunks, or neighbors. Do not use this as the first tool when the user is clearly trying to find a specific existing node; use queryNodes first for direct node retrieval.',
inputSchema: z.object({
query: z.string().min(1).describe('The raw user query for this turn.'),
focused_node_id: z.number().int().positive().nullable().optional().describe('Optional currently focused node ID.'),
active_context_id: z.number().int().positive().nullable().optional().describe('Optional active context ID as a soft hint.'),
limit: z.number().int().min(1).max(12).optional().describe('Maximum number of nodes to return.'),
}),
execute: async ({ query, focused_node_id, active_context_id, limit }) => {
try {
const result = await retrieveQueryContext({
query,
focused_node_id: focused_node_id ?? null,
active_context_id: active_context_id ?? null,
limit,
});
return {
success: true,
data: result,
message: result.shouldRetrieve
? `Retrieved ${result.nodes.length} node(s) and ${result.chunks.length} supporting chunk(s) for the current turn.`
: result.reason,
};
} catch (error) {
return {
success: false,
error: error instanceof Error ? error.message : 'Failed to retrieve query context',
data: null,
};
}
},
});
+72
View File
@@ -0,0 +1,72 @@
import { tool } from 'ai';
import { z } from 'zod';
import { getInternalApiBaseUrl } from '@/services/runtime/apiBase';
import { formatNodeForChat } from '../infrastructure/nodeFormatter';
export const writeContextTool = tool({
description: 'Write one atomic durable context node to the graph only after the user has explicitly approved the save. Use this sparingly for unusually valuable context. Never call it unless the user has clearly said yes.',
inputSchema: z.object({
title: z.string().min(1).max(160).describe('Clear proposed node title.'),
description: z.string().min(1).max(500).describe('Natural description of what this context is and why it matters.'),
source: z.string().optional().describe('Optional source or verbatim user wording to preserve.'),
context_id: z.number().int().positive().nullable().optional().describe('Optional primary context ID.'),
metadata: z.record(z.any()).optional().describe('Optional metadata patch.'),
confirmed_by_user: z.boolean().describe('Must be true. Reject the write otherwise.'),
}),
execute: async ({ title, description, source, context_id, metadata, confirmed_by_user }) => {
if (!confirmed_by_user) {
return {
success: false,
error: 'writeContext requires explicit user confirmation before writing to the graph.',
data: null,
};
}
try {
const response = await fetch(`${getInternalApiBaseUrl()}/api/nodes`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
title: title.trim(),
description: description.trim(),
source: source?.trim() || undefined,
context_id: context_id ?? null,
metadata: {
captured_by: 'human',
captured_method: 'write_context',
...(metadata || {}),
},
}),
});
const result = await response.json();
if (!response.ok) {
return {
success: false,
error: result.error || 'Failed to write context node',
data: null,
};
}
const formattedDisplay = formatNodeForChat({
id: result.data.id,
title: result.data.title,
});
return {
success: true,
data: {
...result.data,
formatted_display: formattedDisplay,
},
message: `Saved context as ${formattedDisplay}`,
};
} catch (error) {
return {
success: false,
error: error instanceof Error ? error.message : 'Failed to write context node',
data: null,
};
}
},
});
+3 -1
View File
@@ -35,6 +35,7 @@ export const TOOL_GROUPS: Record<string, ToolGroup> = {
export const TOOL_GROUP_ASSIGNMENTS: Record<string, string> = {
// Core: Read-only graph operations (all agents)
queryNodes: 'core',
retrieveQueryContext: 'core',
getNodesById: 'core',
queryEdge: 'core',
queryContexts: 'core',
@@ -46,6 +47,7 @@ export const TOOL_GROUP_ASSIGNMENTS: Record<string, string> = {
// Execution: Write operations and extraction (workers only)
createNode: 'execution',
writeContext: 'execution',
updateNode: 'execution',
deleteNode: 'execution',
createEdge: 'execution',
@@ -53,7 +55,7 @@ export const TOOL_GROUP_ASSIGNMENTS: Record<string, string> = {
embedContent: 'execution',
youtubeExtract: 'execution',
websiteExtract: 'execution',
paperExtract: 'execution'
paperExtract: 'execution',
};
/**
+4
View File
@@ -1,10 +1,12 @@
import { getToolGroup, groupTools, getAllToolsByGroup } from './groups';
import { queryNodesTool } from '../database/queryNodes';
import { retrieveQueryContextTool } from '../database/retrieveQueryContext';
import { getNodesByIdTool } from '../database/getNodesById';
import { queryEdgeTool } from '../database/queryEdge';
import { queryContextsTool } from '../database/queryContexts';
import { createNodeTool } from '../database/createNode';
import { updateNodeTool } from '../database/updateNode';
import { writeContextTool } from '../database/writeContext';
import { deleteNodeTool } from '../database/deleteNode';
import { createEdgeTool } from '../database/createEdge';
import { updateEdgeTool } from '../database/updateEdge';
@@ -21,6 +23,7 @@ import { logEvalToolCall } from '@/services/evals/evalsLogger';
const CORE_TOOLS: Record<string, any> = {
sqliteQuery: sqliteQueryTool,
queryNodes: queryNodesTool,
retrieveQueryContext: retrieveQueryContextTool,
getNodesById: getNodesByIdTool,
queryEdge: queryEdgeTool,
queryContexts: queryContextsTool,
@@ -36,6 +39,7 @@ const ORCHESTRATION_TOOLS: Record<string, any> = {
// Write tools (includes extraction)
const EXECUTION_TOOLS: Record<string, any> = {
createNode: createNodeTool,
writeContext: writeContextTool,
updateNode: updateNodeTool,
deleteNode: deleteNodeTool,
createEdge: createEdgeTool,