diff --git a/README.md b/README.md index a289923..07e048d 100644 --- a/README.md +++ b/README.md @@ -93,7 +93,7 @@ Add to your `~/.claude.json`: "mcpServers": { "ra-h": { "command": "npx", - "args": ["ra-h-mcp-server"] + "args": ["--yes", "ra-h-mcp-server@2.1.1"] } } } @@ -101,6 +101,8 @@ Add to your `~/.claude.json`: Restart Claude Code fully (**Cmd+Q on Mac**, not just closing the window). +If you publish a newer MCP release and want clients to use it immediately, bump the pinned version here and restart the client. Do not rely on plain `npx ra-h-mcp-server` always refreshing instantly. + **Verify it worked:** Ask Claude "Do you have RA-H tools available?" — you should see tools like `createNode`, `queryNodes`, and `readSkill`. **For contributors** testing local changes, use the local path instead: @@ -115,20 +117,22 @@ Restart Claude Code fully (**Cmd+Q on Mac**, not just closing the window). } ``` -**What happens:** Once connected, Claude calls `getContext` first to orient itself (stats, contexts, hub nodes, available skills). It proactively captures knowledge. When a new insight, decision, person, or reference surfaces, it should propose a specific node with a strong title, description, source, and metadata. Context is optional and should only be set when the primary scope is obvious. +**What happens:** Once connected, Claude should use `queryNodes` for specific existing-node lookup, `retrieveQueryContext` when broader graph context would help, and `getContext` only for orientation. It proactively captures knowledge. When a new insight, decision, person, or reference surfaces, it should propose a specific node with a strong title, description, source, and metadata. Context is optional and should only be set when the primary scope is obvious. Available tools: | Tool | What it does | |------|--------------| | `getContext` | Get graph overview — stats, contexts, hub nodes, recent activity | +| `retrieveQueryContext` | Pull relevant graph context for a broader current-turn task | | `queryContexts` | List contexts, inspect a context, or search contexts | | `queryNodes` | Find nodes by keyword | | `createNode` | Create a new node | +| `writeContext` | Save one confirmed durable context node after explicit user approval | | `getNodesById` | Fetch nodes by ID | | `updateNode` | Edit an existing node | -| `createEdge` | Link two nodes together | -| `updateEdge` | Update an edge explanation | +| `createEdge` | Link two nodes together after explicit confirmation | +| `updateEdge` | Update an edge explanation after explicit confirmation | | `queryEdge` | Find connections | | `listSkills` | List available skills | | `readSkill` | Read a skill by name | diff --git a/app/api/edges/[id]/route.ts b/app/api/edges/[id]/route.ts index 755cf9f..de37ba3 100644 --- a/app/api/edges/[id]/route.ts +++ b/app/api/edges/[id]/route.ts @@ -1,5 +1,6 @@ import { NextRequest, NextResponse } from 'next/server'; import { edgeService } from '@/services/database'; +import { validateEdgeExplanation } from '@/services/database/quality'; export const runtime = 'nodejs'; @@ -66,7 +67,60 @@ export async function PUT( }, { status: 400 }); } - const edge = await edgeService.updateEdge(edgeId, body); + const explanation = + typeof body.explanation === 'string' + ? body.explanation.trim() + : typeof body.context?.explanation === 'string' + ? body.context.explanation.trim() + : ''; + + const createdVia = (() => { + const raw = + typeof body.created_via === 'string' + ? body.created_via + : typeof body.context?.created_via === 'string' + ? body.context.created_via + : ''; + if (['ui', 'agent', 'mcp', 'workflow', 'quicklink'].includes(raw)) return raw as any; + return 'ui' as const; + })(); + + if ((createdVia === 'agent' || createdVia === 'mcp' || createdVia === 'workflow') && body.confirmed_by_user !== true) { + return NextResponse.json({ + success: false, + error: 'Agent-driven edge updates require explicit user confirmation before writing to the graph.' + }, { status: 400 }); + } + + if (!explanation && createdVia !== 'ui' && createdVia !== 'quicklink') { + return NextResponse.json({ + success: false, + error: 'Agent-driven edge updates require an explicit explanation.' + }, { status: 400 }); + } + + if (explanation) { + const explanationError = validateEdgeExplanation(explanation); + if (explanationError) { + return NextResponse.json({ + success: false, + error: explanationError + }, { status: 400 }); + } + } + + const updatePayload = { ...body }; + delete updatePayload.confirmed_by_user; + + if (typeof updatePayload.created_via === 'string') { + updatePayload.context = { + ...(updatePayload.context && typeof updatePayload.context === 'object' ? updatePayload.context : {}), + created_via: updatePayload.created_via, + }; + delete updatePayload.created_via; + } + + const edge = await edgeService.updateEdge(edgeId, updatePayload); return NextResponse.json({ success: true, @@ -112,4 +166,4 @@ export async function DELETE( error: error instanceof Error ? error.message : 'Failed to delete edge' }, { status: 500 }); } -} \ No newline at end of file +} diff --git a/app/api/edges/route.ts b/app/api/edges/route.ts index 8e2bc87..a6a34dc 100644 --- a/app/api/edges/route.ts +++ b/app/api/edges/route.ts @@ -59,6 +59,26 @@ export async function POST(request: NextRequest) { const fromId = parseInt(body.from_node_id); const toId = parseInt(body.to_node_id); const explanation = String(body.explanation || '').trim(); + const createdVia = (() => { + const raw = typeof body.created_via === 'string' ? body.created_via : ''; + if (['ui', 'agent', 'mcp', 'workflow', 'quicklink'].includes(raw)) return raw as any; + return 'ui' as const; + })(); + + if (!explanation && createdVia !== 'ui' && createdVia !== 'quicklink') { + return NextResponse.json({ + success: false, + error: 'Agent-driven edge creation requires an explicit explanation. Propose likely edges first and only create them after the user confirms.' + }, { status: 400 }); + } + + if ((createdVia === 'agent' || createdVia === 'mcp' || createdVia === 'workflow') && body.confirmed_by_user !== true) { + return NextResponse.json({ + success: false, + error: 'Agent-driven edge creation requires explicit user confirmation before writing to the graph.' + }, { status: 400 }); + } + if (explanation) { const explanationError = validateEdgeExplanation(explanation); if (explanationError) { @@ -68,13 +88,7 @@ export async function POST(request: NextRequest) { }, { status: 400 }); } } - const skipInference = Boolean(body.skip_inference); - const createdVia = (() => { - const raw = typeof body.created_via === 'string' ? body.created_via : ''; - if (['ui', 'agent', 'mcp', 'workflow', 'quicklink'].includes(raw)) return raw as any; - return 'ui' as const; - })(); // Idempotency: prevent duplicate edges between same pair try { diff --git a/app/api/mcp/route.ts b/app/api/mcp/route.ts index 51e3bab..c8a5668 100644 --- a/app/api/mcp/route.ts +++ b/app/api/mcp/route.ts @@ -8,16 +8,20 @@ export const maxDuration = 30; const SERVER_INFO = { name: 'ra-h-mcp', - version: '1.0.0', + version: '2.1.1', }; const instructions = [ 'RA-H is a personal knowledge graph — local-first, vendor-neutral.', 'Core concepts: contexts (optional soft scopes, max 10), nodes (knowledge units), and edges (connections with explanations).', - 'Always call rah_get_context first to orient yourself — it returns high-level graph state, contexts, hub nodes, stats, and available guides.', + 'If the user is trying to find a specific existing node, use rah_search_nodes first.', + 'If the user is asking a broader question or request that would benefit from graph context, use rah_retrieve_query_context.', + 'Use rah_get_context only for orientation when high-level graph state would actually help.', 'Use contexts only when one obvious existing context is explicit and helpful. If unsure or if none exist, leave context empty. Do not expect automatic context assignment.', 'Search before creating: use rah_search_nodes to check if content already exists.', 'Every edge needs an explanation: why does this connection exist?', + 'Never create or update an edge unless the user has explicitly confirmed the relationship.', + 'Never write via rah_write_context unless the user has explicitly confirmed yes.', ].join(' '); function getBaseUrl(request: NextRequest): string { @@ -52,20 +56,19 @@ function createServer(request: NextRequest): McpServer { 'rah_add_node', { title: 'Add RA-H node', - description: 'Create a new node in the local RA-H knowledge base. Set context only when one obvious existing context clearly fits; otherwise leave it empty.', + description: 'Create a new node in the local RA-H knowledge base after you have already decided a net-new write is correct. If the user explicitly asked to save or import something and the target artifact is clear, write after duplicate/update checks. If you are only suggesting a save, propose the node first and wait for confirmation. Leave context blank by default. If the user explicitly wants context, use `context_name` rather than a numeric ID.', inputSchema: { title: z.string().min(1).max(160), content: z.string().max(20000).optional(), source: z.string().max(50000).optional(), link: z.string().url().optional(), description: z.string().max(500).optional(), - context_id: z.number().int().positive().nullable().optional(), context_name: z.string().optional(), metadata: z.record(z.any()).optional(), chunk: z.string().max(50000).optional(), }, }, - async ({ title, content, source, link, description, context_id, context_name, metadata, chunk }) => { + async ({ title, content, source, link, description, context_name, metadata, chunk }) => { const payload = await callRaHApi(request, '/api/nodes', { method: 'POST', body: JSON.stringify({ @@ -73,7 +76,6 @@ function createServer(request: NextRequest): McpServer { source: source?.trim() || content?.trim() || chunk?.trim() || undefined, link: link?.trim() || undefined, description: description?.trim() || undefined, - context_id, context_name: context_name?.trim() || undefined, metadata: metadata || {}, }), @@ -95,21 +97,23 @@ function createServer(request: NextRequest): McpServer { 'rah_search_nodes', { title: 'Search RA-H nodes', - description: 'Find existing RA-H entries that mention a topic before adding new ones.', + description: 'Find existing RA-H entries that mention a topic before adding new ones. Leave context blank by default. If the user explicitly wants a context-specific lookup, use `context_name` rather than a numeric ID. For full current-turn grounding of a substantive request, prefer `rah_retrieve_query_context`.', inputSchema: { query: z.string().min(1).max(400), limit: z.number().min(1).max(25).optional(), - contextId: z.number().int().positive().optional(), + context_name: z.string().optional(), }, }, - async ({ query, limit = 10, contextId }) => { - const params = new URLSearchParams(); - params.set('search', query.trim()); - params.set('limit', String(Math.min(Math.max(limit, 1), 25))); - if (contextId) params.set('contextId', String(contextId)); - - const payload = await callRaHApi(request, `/api/nodes?${params.toString()}`); - const nodes = Array.isArray(payload.data) ? payload.data : []; + async ({ query, limit = 10, context_name }) => { + const payload = await callRaHApi(request, '/api/nodes/direct-search', { + method: 'POST', + body: JSON.stringify({ + query: query.trim(), + limit: Math.min(Math.max(limit, 1), 25), + context_name: typeof context_name === 'string' ? context_name.trim() : undefined, + }), + }); + const nodes = Array.isArray(payload.data?.nodes) ? payload.data.nodes : []; return { content: [{ type: 'text', text: nodes.length === 0 ? 'No existing RA-H nodes mention that topic yet.' : `Found ${nodes.length} node(s) mentioning that topic.` }], @@ -128,6 +132,41 @@ function createServer(request: NextRequest): McpServer { } ); + server.registerTool( + 'rah_retrieve_query_context', + { + title: 'Retrieve RA-H query context', + description: 'Given the raw user query plus optional focused node state, retrieve the most relevant graph context for the current turn. It starts with direct graph search and broadens only if useful. Use this when graph context could help answer or complete a broader task. For explicit node lookup, use rah_search_nodes.', + inputSchema: { + query: z.string().min(1).max(1000), + focused_node_id: z.number().int().positive().nullable().optional(), + active_context_id: z.number().int().positive().nullable().optional(), + limit: z.number().min(1).max(20).optional(), + }, + }, + async ({ query, focused_node_id, active_context_id, limit = 6 }) => { + const payload = await callRaHApi(request, '/api/retrieval/query-context', { + method: 'POST', + body: JSON.stringify({ + query, + focused_node_id: focused_node_id ?? null, + active_context_id: active_context_id ?? null, + limit, + }), + }); + + return { + content: [{ + type: 'text', + text: payload.data.shouldRetrieve + ? `Retrieved ${payload.data.nodes.length} node(s) and ${payload.data.chunks.length} chunk(s) for this turn.` + : payload.data.reason, + }], + structuredContent: payload.data, + }; + } + ); + server.registerTool( 'rah_query_contexts', { @@ -214,7 +253,7 @@ function createServer(request: NextRequest): McpServer { 'rah_update_node', { title: 'Update RA-H node', - description: 'Update an existing node. Context remains optional and explicit.', + description: 'Update an existing node when it is clearly the same artifact and a net-new node would be redundant. Explicit user-directed updates can proceed once the target node is clear. Context is preserved by default. If the user explicitly wants to change context, use `context_name`. Use `clear_context` only when the user explicitly wants to remove the node context.', inputSchema: { id: z.number().int().positive(), updates: z.object({ @@ -223,17 +262,30 @@ function createServer(request: NextRequest): McpServer { content: z.string().optional(), source: z.string().optional(), link: z.string().optional(), - context_id: z.number().int().positive().nullable().optional(), + context_name: z.string().optional(), + clear_context: z.boolean().optional(), metadata: z.record(z.any()).optional(), }), }, }, async ({ id, updates }) => { + if (!updates || Object.keys(updates).length === 0) { + throw new Error('At least one field must be provided in updates.'); + } + const mappedUpdates = { ...updates } as Record; + if (mappedUpdates.chunk !== undefined && mappedUpdates.source === undefined) { + mappedUpdates.source = mappedUpdates.chunk; + } if (mappedUpdates.content !== undefined && mappedUpdates.source === undefined) { mappedUpdates.source = mappedUpdates.content; } delete mappedUpdates.content; + delete mappedUpdates.chunk; + + if (mappedUpdates.context_name && mappedUpdates.clear_context) { + throw new Error('context_name cannot be combined with clear_context: true.'); + } const payload = await callRaHApi(request, `/api/nodes/${id}`, { method: 'PUT', @@ -295,14 +347,19 @@ function createServer(request: NextRequest): McpServer { 'rah_create_edge', { title: 'Create RA-H edge', - description: 'Create a connection between two nodes.', + description: 'Create a connection between two nodes only after the user has explicitly confirmed the proposed relationship.', inputSchema: { sourceId: z.number().int().positive(), targetId: z.number().int().positive(), explanation: z.string().min(1), + confirmed_by_user: z.boolean(), }, }, - async ({ sourceId, targetId, explanation }) => { + async ({ sourceId, targetId, explanation, confirmed_by_user }) => { + if (!confirmed_by_user) { + throw new Error('rah_create_edge requires explicit user confirmation before writing the relationship.'); + } + const payload = await callRaHApi(request, '/api/edges', { method: 'POST', body: JSON.stringify({ @@ -311,6 +368,7 @@ function createServer(request: NextRequest): McpServer { explanation: explanation.trim(), source: 'helper_name', created_via: 'mcp', + confirmed_by_user: true, }), }); @@ -364,17 +422,23 @@ function createServer(request: NextRequest): McpServer { 'rah_update_edge', { title: 'Update RA-H edge', - description: 'Update an existing edge connection.', + description: 'Update an existing edge connection only after the user explicitly confirmed the corrected relationship.', inputSchema: { id: z.number().int().positive(), explanation: z.string().min(1), + confirmed_by_user: z.boolean(), }, }, - async ({ id, explanation }) => { + async ({ id, explanation, confirmed_by_user }) => { + if (!confirmed_by_user) { + throw new Error('rah_update_edge requires explicit user confirmation before writing the corrected relationship.'); + } + const payload = await callRaHApi(request, `/api/edges/${id}`, { method: 'PUT', body: JSON.stringify({ context: { explanation: explanation.trim(), created_via: 'mcp' }, + confirmed_by_user: true, }), }); @@ -551,6 +615,55 @@ function createServer(request: NextRequest): McpServer { } ); + server.registerTool( + 'rah_write_context', + { + title: 'Write RA-H context node', + description: 'Write one atomic durable context node to the graph only after the user has explicitly approved the save. Use this for agent-suggested capture after you already proposed the node briefly and got a clear yes. Prefer ordinary create/update flows for explicit user-directed capture.', + inputSchema: { + title: z.string().min(1).max(160), + description: z.string().min(1).max(500), + source: z.string().max(50000).optional(), + context_name: z.string().optional(), + metadata: z.record(z.any()).optional(), + confirmed_by_user: z.boolean(), + }, + }, + async ({ title, description, source, context_name, metadata, confirmed_by_user }) => { + if (!confirmed_by_user) { + throw new Error('rah_write_context requires explicit user confirmation before writing to the graph.'); + } + + const payload = await callRaHApi(request, '/api/nodes', { + method: 'POST', + body: JSON.stringify({ + title: title.trim(), + description: description.trim(), + source: source?.trim() || undefined, + context_name: context_name?.trim() || undefined, + metadata: { + captured_by: 'human', + captured_method: 'write_context', + ...(metadata || {}), + }, + }), + }); + + const node = payload.data; + const message = payload.message || `Saved context as node #${node.id}: ${node.title}`; + + return { + content: [{ type: 'text', text: message }], + structuredContent: { + success: true, + nodeId: node.id, + title: node.title, + message, + }, + }; + } + ); + return server; } @@ -603,6 +716,7 @@ export async function GET(request: NextRequest) { const tools = [ 'rah_add_node', 'rah_search_nodes', + 'rah_retrieve_query_context', 'rah_query_contexts', 'rah_update_node', 'rah_get_nodes', @@ -614,6 +728,7 @@ export async function GET(request: NextRequest) { 'rah_extract_youtube', 'rah_extract_pdf', 'rah_get_context', + 'rah_write_context', ]; return NextResponse.json( diff --git a/app/api/nodes/[id]/route.ts b/app/api/nodes/[id]/route.ts index 6b6d3af..f8ae468 100644 --- a/app/api/nodes/[id]/route.ts +++ b/app/api/nodes/[id]/route.ts @@ -100,17 +100,24 @@ export async function PUT( updates.metadata = mergeNodeMetadata(existingNode.metadata, body.metadata); } - if (Object.prototype.hasOwnProperty.call(body, 'context_name')) { + const hasContextName = typeof body.context_name === 'string' && body.context_name.trim().length > 0; + const wantsClearContext = body.clear_context === true; + + delete updates.context_name; + delete updates.clear_context; + + if (hasContextName && wantsClearContext) { return NextResponse.json({ success: false, - error: 'context_name is only supported on node creation. Use context_id for updates.' + error: 'context_name cannot be combined with clear_context: true.' }, { status: 400 }); } - if (Object.prototype.hasOwnProperty.call(body, 'context_id')) { + if (hasContextName || Object.prototype.hasOwnProperty.call(body, 'context_id') || wantsClearContext) { try { const resolvedContextId = await contextService.resolveContextId({ - context_id: body.context_id, + context_id: wantsClearContext ? null : body.context_id, + context_name: hasContextName ? body.context_name : undefined, }); updates.context_id = resolvedContextId; } catch (error) { diff --git a/app/api/nodes/direct-search/route.ts b/app/api/nodes/direct-search/route.ts new file mode 100644 index 0000000..be2b9f1 --- /dev/null +++ b/app/api/nodes/direct-search/route.ts @@ -0,0 +1,43 @@ +import { NextRequest, NextResponse } from 'next/server'; +import { directNodeLookup } from '@/services/retrieval/directNodeLookup'; + +export const runtime = 'nodejs'; + +export async function POST(request: NextRequest) { + try { + const body = await request.json(); + const query = typeof body.query === 'string' ? body.query : ''; + + if (!query.trim()) { + return NextResponse.json({ + success: false, + error: 'Missing required field: query', + }, { status: 400 }); + } + + const result = await directNodeLookup({ + search: query, + limit: typeof body.limit === 'number' ? body.limit : undefined, + context_name: typeof body.context_name === 'string' ? body.context_name : undefined, + contextId: typeof body.contextId === 'number' ? body.contextId : undefined, + createdAfter: typeof body.createdAfter === 'string' ? body.createdAfter : undefined, + createdBefore: typeof body.createdBefore === 'string' ? body.createdBefore : undefined, + eventAfter: typeof body.eventAfter === 'string' ? body.eventAfter : undefined, + eventBefore: typeof body.eventBefore === 'string' ? body.eventBefore : undefined, + }); + + return NextResponse.json({ + success: true, + data: { + count: result.count, + nodes: result.nodes, + filters_applied: result.filtersApplied, + }, + }); + } catch (error) { + return NextResponse.json({ + success: false, + error: error instanceof Error ? error.message : 'Failed to run direct node lookup', + }, { status: 500 }); + } +} diff --git a/app/api/nodes/route.ts b/app/api/nodes/route.ts index 87213cc..6fac629 100644 --- a/app/api/nodes/route.ts +++ b/app/api/nodes/route.ts @@ -3,7 +3,6 @@ import { contextService, nodeService } from '@/services/database'; import { Node, NodeFilters } from '@/types/database'; import { autoEmbedQueue } from '@/services/embedding/autoEmbedQueue'; import { generateDescription } from '@/services/database/descriptionService'; -import { scheduleAutoEdgeCreation } from '@/services/agents/autoEdge'; import { coerceDescriptionForStorage } from '@/services/database/quality'; import { normalizeNodeLink } from '@/utils/nodeLink'; import { buildCanonicalNodeMetadata } from '@/services/nodes/metadata'; @@ -181,11 +180,6 @@ export async function POST(request: NextRequest) { autoEmbedQueue.enqueue(node.id, { reason: 'node_created' }); } - // Schedule auto-edge creation (fire-and-forget, non-blocking) - if (node.id) { - scheduleAutoEdgeCreation(node.id); - } - return NextResponse.json({ success: true, data: node, diff --git a/apps/mcp-server-standalone/README.md b/apps/mcp-server-standalone/README.md index 2d173a3..3b5d88e 100644 --- a/apps/mcp-server-standalone/README.md +++ b/apps/mcp-server-standalone/README.md @@ -5,7 +5,7 @@ Connect Claude Code and Claude Desktop to your RA-H knowledge base. Direct SQLit ## Install ```bash -npx ra-h-mcp-server +npx --yes ra-h-mcp-server@2.1.1 ``` That's it. No manual setup required. @@ -19,7 +19,7 @@ Add to your Claude config (`~/.claude.json` or Claude Desktop settings): "mcpServers": { "ra-h": { "command": "npx", - "args": ["ra-h-mcp-server"] + "args": ["--yes", "ra-h-mcp-server@2.1.1"] } } } @@ -27,6 +27,8 @@ Add to your Claude config (`~/.claude.json` or Claude Desktop settings): Restart Claude. Done. +If you publish a newer MCP release and need this client to pick it up immediately, bump the pinned version here and restart Claude. Do not assume plain `npx ra-h-mcp-server` always refreshes instantly. + ## Requirements - Node.js 18+ @@ -44,13 +46,15 @@ Once connected, Claude will: - **Use `queryNodes` for explicit node lookup** when the user is trying to find a specific existing thing - **Use `retrieveQueryContext` when graph context is helpful** for a broader task, question, or request - **Use `getContext` only for orientation** when high-level graph state would actually help +- **Treat context as optional by default** — normal node creation and updates should omit context unless the user explicitly wants one; when context is intentionally provided, use `context_name` - **Proactively capture knowledge** — when a new insight, decision, person, or reference surfaces, it proposes a specific node (title, description, optional context) so you can approve with minimal friction +- **Treat edges as proposal-first** — it should suggest likely relationships briefly, then create them only after you explicitly confirm - **Read skills for complex tasks** — skills are editable and shared across internal + external agents - **Search before creating** to avoid duplicates ## Recommended Agent Memory Line -If you use external agents through this MCP server, add one short instruction line to your agent memory file (`AGENTS.md`, `CLAUDE.md`, etc.): +If you use external agents through this MCP server, you may add one short instruction line to your agent memory file (`AGENTS.md`, `CLAUDE.md`, etc.) as optional reinforcement: ```md Retrieve relevant context from RA-H before substantive work, and only suggest writing durable context back when it is clearly valuable and the user can confirm yes. @@ -62,6 +66,8 @@ Keep the writeback prompt brief. A good pattern is: Add "X" as a node? ``` +RA-H should still work well without this line. The MCP tools, server instructions, skills, and docs are meant to carry the core behavior on their own. + ## Available Tools | Tool | Description | @@ -74,8 +80,8 @@ Add "X" as a node? | `queryContexts` | List or inspect contexts | | `getNodesById` | Load nodes by ID (includes chunk + metadata) | | `updateNode` | Update an existing node | -| `createEdge` | Create connection between nodes | -| `updateEdge` | Update an edge explanation | +| `createEdge` | Create a confirmed connection between nodes | +| `updateEdge` | Update an edge explanation after explicit confirmation | | `queryEdge` | Find edges for a node | | `listSkills` | List available skills | | `readSkill` | Read a skill by name | @@ -86,6 +92,13 @@ Add "X" as a node? ## Node Metadata Contract +## Context Rule + +- Creating a node never requires context. +- Normal node lookup and update flows should omit context unless the user explicitly asks for it. +- If context is intentionally provided, prefer `context_name`. +- Numeric `context_id` is treated as an internal implementation detail rather than a normal agent-facing field. + When `createNode` or `updateNode` includes metadata, prefer the canonical shape: ```json @@ -112,6 +125,12 @@ Rules: - Keep the ask terse and concrete, for example: `Add "X" as a node?` - Never call `writeContext` unless the user has explicitly said yes. +## Edge Rule + +- External agents should propose likely edge candidates first. +- `createEdge` is the execution tool after explicit user confirmation. +- Agent-driven edge creation should always include a clear explanation sentence. + ## Skills Skills are detailed instruction sets that teach agents how to work with your knowledge base. The default seeded skills are editable and shared by internal + external agents. diff --git a/apps/mcp-server-standalone/index.js b/apps/mcp-server-standalone/index.js index a435304..93f1626 100644 --- a/apps/mcp-server-standalone/index.js +++ b/apps/mcp-server-standalone/index.js @@ -35,6 +35,7 @@ const edgeService = require('./services/edgeService'); const contextService = require('./services/contextService'); const skillService = require('./services/skillService'); const retrievalService = require('./services/retrievalService'); +const { directNodeLookup } = require('./services/directNodeLookupService'); // Server info const serverInfo = { @@ -68,15 +69,17 @@ function buildInstructions() { 6. For complex tasks, call readSkill("db-operations"). ## Context field rule -`context_id` is optional on writes. -Do not include `context_id` unless you already know a real existing context ID that clearly fits. -Omitting `context_id` is the normal default and does not block create or update operations. +Context is optional on writes. +Do not include any context field unless the user explicitly wants one. +If context is intentionally provided, prefer \`context_name\`. Treat numeric \`context_id\` as an internal implementation detail. +Omitting context is the normal default and does not block create or update operations. ## Knowledge capture Only suggest saving context when it seems unusually durable and valuable. Keep the ask brief: Add "X" as a node? Do not pester. Do not keep re-asking if the user says no, ignores it, or moves on. Never write via writeContext unless the user has explicitly confirmed yes. +Do not create edges autonomously. Surface likely edge candidates briefly, then call edge-write tools only after the user explicitly confirms. Always search or retrieve before creating to avoid duplicates. ## Available skills @@ -93,16 +96,15 @@ const addNodeInputSchema = { source: z.string().max(50000).optional().describe('Canonical source content for embedding'), link: z.string().url().optional().describe('Source URL'), description: z.string().optional().describe('Strongly recommended. Write the description as natural prose, not labels or a checklist. It should make clear what the artifact is and any surrounding context available. RA-H will accept whatever description is provided and will not block the write.'), - context_id: z.number().int().positive().nullable().optional().describe('Optional primary context ID. Usually omit this field entirely unless you already know a real matching context.'), - context_name: z.string().optional().describe('Optional convenience context name.'), + context_name: z.string().optional().describe('Optional primary context name. Use only when the user explicitly wants this node assigned to a known context.'), metadata: z.record(z.any()).optional().describe('Optional metadata. Prefer canonical keys: type, state, captured_method, captured_by, source_metadata.'), chunk: z.string().max(50000).optional().describe('Legacy alias for source text') }; const searchNodesInputSchema = { query: z.string().min(1).max(400).describe('Search query'), - limit: z.number().min(1).max(25).optional().describe('Max results (default 10)'), - contextId: z.number().int().positive().optional().describe('Optional primary context filter.'), + limit: z.number().min(1).max(50).optional().describe('Max results (default 10)'), + context_name: z.string().optional().describe('Optional primary context name filter. Use only when the user explicitly wants a context-specific lookup.'), created_after: z.string().optional().describe('ISO date (YYYY-MM-DD). Only return nodes created on or after this date.'), created_before: z.string().optional().describe('ISO date (YYYY-MM-DD). Only return nodes created before this date.'), event_after: z.string().optional().describe('ISO date (YYYY-MM-DD). Only return nodes with event_date on or after this date.'), @@ -120,7 +122,7 @@ const writeContextInputSchema = { 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().max(50000).optional().describe('Optional source or verbatim user wording to preserve'), - context_id: z.number().int().positive().nullable().optional().describe('Optional primary context ID. Usually omit this field entirely unless you already know a real matching context.'), + context_name: z.string().optional().describe('Optional primary context name. Use only when the user explicitly wants this saved under a known context.'), metadata: z.record(z.any()).optional().describe('Optional metadata patch'), confirmed_by_user: z.boolean().describe('Must be true before the write is allowed') }; @@ -137,7 +139,8 @@ const updateNodeInputSchema = { content: z.string().optional().describe('Legacy alias for source'), source: z.string().optional().describe('Canonical source content for embedding'), link: z.string().optional().describe('New link'), - context_id: z.number().int().positive().nullable().optional().describe('Optional primary context ID. Omit this field to preserve existing context. Only use null when you intentionally want to clear context.'), + context_name: z.string().optional().describe('Optional primary context name. Use only when the user explicitly wants to assign this node to a known context.'), + clear_context: z.boolean().optional().describe('Set true only when the user explicitly wants to remove the node context.'), metadata: z.record(z.any()).optional().describe('Metadata patch. It now merges with existing metadata. Prefer canonical keys: type, state, captured_method, captured_by, source_metadata.') }).describe('Fields to update') }; @@ -145,12 +148,14 @@ const updateNodeInputSchema = { const createEdgeInputSchema = { sourceId: z.number().int().positive().describe("The 'subject' node (reads: source [explanation] target)"), targetId: z.number().int().positive().describe('Target node ID'), - explanation: z.string().min(1).describe("Human-readable explanation. Should read as a sentence: 'Alice invented this technique'") + explanation: z.string().min(1).describe("Human-readable explanation. Should read as a sentence: 'Alice invented this technique'"), + confirmed_by_user: z.boolean().describe('Must be true. Only create the edge after the user explicitly confirmed this proposed relationship.') }; const updateEdgeInputSchema = { id: z.number().int().positive().describe('Edge ID'), - explanation: z.string().min(1).describe('Updated explanation for this connection') + explanation: z.string().min(1).describe('Updated explanation for this connection'), + confirmed_by_user: z.boolean().describe('Must be true. Only update the edge after the user explicitly confirmed the corrected relationship.') }; const queryEdgesInputSchema = { @@ -358,16 +363,16 @@ async function main() { 'createNode', { title: 'Add RA-H node', - description: 'Create a new node. Always search first (queryNodes) to avoid duplicates. `context_id` is optional and should usually be omitted entirely unless one obvious existing context clearly fits. Title: max 160 chars, clear and descriptive. Description is strongly recommended and should explicitly describe what the thing is and any surrounding context available, but the write will never be blocked over description quality. Use "link" ONLY for external content (URL, video, article) — omit for synthesis/ideas derived from existing nodes. "source" = verbatim or canonical content for embedding. Legacy "content" and "chunk" are mapped to source for compatibility.', + description: 'Create a new node. Always search first (queryNodes) to avoid duplicates. If the user explicitly asked to save or import something and the target artifact is clear, write after duplicate/update checks. If you are only suggesting a save, propose the node first and wait for confirmation. Leave context blank by default. If the user explicitly wants context, use `context_name` rather than a numeric ID. Title: max 160 chars, clear and descriptive. Description is strongly recommended and should explicitly describe what the thing is and any surrounding context available, but the write will never be blocked over description quality. Use "link" ONLY for external content (URL, video, article) — omit for synthesis/ideas derived from existing nodes. "source" = verbatim or canonical content for embedding. Legacy "content" and "chunk" are mapped to source for compatibility.', inputSchema: addNodeInputSchema }, - async ({ title, content, source, link, description, context_id, context_name, metadata, chunk }) => { + async ({ title, content, source, link, description, context_name, metadata, chunk }) => { const sourceText = source?.trim() || content?.trim() || chunk?.trim(); const normalizedDescription = typeof description === 'string' ? description.trim() : description; let resolvedContextId; try { - resolvedContextId = contextService.resolveContextId({ context_id, context_name }); + resolvedContextId = contextService.resolveContextId({ context_name }); } catch (error) { throw new Error(error.message); } @@ -398,37 +403,36 @@ async function main() { 'queryNodes', { title: 'Search RA-H nodes', - description: 'Search nodes by keyword across title, description, and source fields using the same indexed search path as the app search UI. Use this for direct node lookup or duplicate checks. For full current-turn grounding of a substantive query, prefer retrieveQueryContext. NOT for searching source documents (transcripts, articles) — use searchContentEmbeddings for that.', + description: 'Search nodes by keyword across title, description, and source fields using the same safe direct-lookup behavior as the app. Use this for direct node lookup or duplicate checks. Leave context blank by default. If the user explicitly wants a context-specific lookup, use context_name rather than a numeric ID. For full current-turn grounding of a substantive query, prefer retrieveQueryContext. NOT for searching source documents (transcripts, articles) — use searchContentEmbeddings for that.', inputSchema: searchNodesInputSchema }, - async ({ query: searchQuery, limit = 10, contextId, created_after, created_before, event_after, event_before }) => { - const safeLimit = Math.min(Math.max(limit, 1), 25); - const trimmedQuery = searchQuery.trim(); - const nodes = nodeService.getNodes({ - search: trimmedQuery, + async ({ query: searchQuery, limit = 10, context_name, created_after, created_before, event_after, event_before }) => { + const safeLimit = Math.min(Math.max(limit, 1), 50); + const result = directNodeLookup({ + search: searchQuery.trim(), limit: safeLimit, - contextId, + context_name, createdAfter: created_after, createdBefore: created_before, eventAfter: event_after, eventBefore: event_before, }); - const summary = nodes.length === 0 + const summary = result.count === 0 ? 'No nodes found matching that query.' - : `Found ${nodes.length} node(s).`; + : `Found ${result.count} node(s).`; return { content: [{ type: 'text', text: summary }], structuredContent: { - count: nodes.length, - nodes: nodes.map((node) => ({ + count: result.count, + filters_applied: result.filtersApplied, + nodes: result.nodes.map((node) => ({ id: node.id, title: node.title, source: node.source ?? null, description: node.description ?? null, link: node.link ?? null, - context_id: node.context_id ?? null, created_at: node.created_at, updated_at: node.updated_at, event_date: node.event_date ?? null, @@ -442,19 +446,26 @@ async function main() { 'writeContext', { title: 'Write RA-H context node', - 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.', + description: 'Write one atomic durable context node to the graph only after the user has explicitly approved the save. Use this for agent-suggested capture after you already proposed the node briefly and got a clear yes. Prefer ordinary create/update flows for explicit user-directed capture.', inputSchema: writeContextInputSchema }, - async ({ title, description, source, context_id, metadata, confirmed_by_user }) => { + async ({ title, description, source, context_name, metadata, confirmed_by_user }) => { if (!confirmed_by_user) { throw new Error('writeContext requires explicit user confirmation before writing to the graph.'); } + let resolvedContextId; + try { + resolvedContextId = contextService.resolveContextId({ context_name }); + } catch (error) { + throw new Error(error.message); + } + const node = nodeService.createNode({ title: title.trim(), description: description.trim(), source: source?.trim(), - context_id: context_id ?? null, + context_id: resolvedContextId, metadata: { captured_by: 'human', captured_method: 'write_context', @@ -527,7 +538,7 @@ async function main() { 'updateNode', { title: 'Update RA-H node', - description: 'Update an existing node. `context_id` is optional and should usually be omitted entirely unless you are intentionally setting or clearing a real context. Description updates should explicitly state what this thing is and any surrounding context available, but the write will never be blocked over description quality. Source content lives in "source". Legacy "content" is mapped to source for compatibility. Title, description, and link are overwritten. Call getNodesById first to verify current state before updating.', + description: 'Update an existing node when it is clearly the same artifact and a net-new node would be redundant. Explicit user-directed updates can proceed once the target node is clear. Context is preserved by default. If the user explicitly wants to change context, use `context_name`. Use `clear_context` only when the user explicitly wants the context removed. Description updates should explicitly state what this thing is and any surrounding context available, but the write will never be blocked over description quality. Source content lives in "source". Legacy "content" is mapped to source for compatibility. Title, description, and link are overwritten. Call getNodesById first to verify current state before updating.', inputSchema: updateNodeInputSchema }, async ({ id, updates }) => { @@ -552,10 +563,19 @@ async function main() { : mappedUpdates.description; } - if (Object.prototype.hasOwnProperty.call(mappedUpdates, 'context_id')) { - mappedUpdates.context_id = contextService.resolveContextId({ context_id: mappedUpdates.context_id }); + if (mappedUpdates.context_name && mappedUpdates.clear_context) { + throw new Error('context_name cannot be combined with clear_context: true.'); } + if (mappedUpdates.context_name || mappedUpdates.clear_context || Object.prototype.hasOwnProperty.call(mappedUpdates, 'context_id')) { + mappedUpdates.context_id = contextService.resolveContextId({ + context_id: mappedUpdates.clear_context ? null : mappedUpdates.context_id, + context_name: mappedUpdates.context_name, + }); + } + delete mappedUpdates.context_name; + delete mappedUpdates.clear_context; + const node = nodeService.updateNode(id, mappedUpdates); const message = `Updated node #${id}`; @@ -576,10 +596,14 @@ async function main() { 'createEdge', { title: 'Create RA-H edge', - description: 'Connect two nodes with an edge. Edges are the most valuable part of the graph — they represent understanding, not proximity. Direction matters: reads as sourceId → [explanation] → targetId. The explanation should read as a sentence (e.g. "invented this technique", "contradicts the claim in"). Call queryEdge first to check if a connection already exists between the two nodes.', + description: 'Connect two nodes with an edge only after the user has explicitly confirmed the proposed relationship. Edges are the most valuable part of the graph — they represent understanding, not proximity. Direction matters: reads as sourceId → [explanation] → targetId. The explanation should read as a sentence (e.g. "invented this technique", "contradicts the claim in"). Call queryEdge first to check if a connection already exists between the two nodes.', inputSchema: createEdgeInputSchema }, - async ({ sourceId, targetId, explanation }) => { + async ({ sourceId, targetId, explanation, confirmed_by_user }) => { + if (!confirmed_by_user) { + throw new Error('createEdge requires explicit user confirmation before writing the relationship.'); + } + const edge = edgeService.createEdge({ from_node_id: sourceId, to_node_id: targetId, @@ -602,10 +626,14 @@ async function main() { 'updateEdge', { title: 'Update RA-H edge', - description: 'Update an edge explanation. Use when a connection needs a better or corrected explanation.', + description: 'Update an edge explanation only after the user explicitly confirmed the corrected relationship.', inputSchema: updateEdgeInputSchema }, - async ({ id, explanation }) => { + async ({ id, explanation, confirmed_by_user }) => { + if (!confirmed_by_user) { + throw new Error('updateEdge requires explicit user confirmation before writing the corrected relationship.'); + } + const edge = edgeService.updateEdge(id, { explanation: explanation.trim() }); return { diff --git a/apps/mcp-server-standalone/package.json b/apps/mcp-server-standalone/package.json index 0bd5c3f..6435bce 100644 --- a/apps/mcp-server-standalone/package.json +++ b/apps/mcp-server-standalone/package.json @@ -1,6 +1,6 @@ { "name": "ra-h-mcp-server", - "version": "1.10.1", + "version": "2.1.1", "description": "Connect Claude Code/Desktop to your RA-H knowledge base. Direct SQLite access - no web app required.", "main": "index.js", "bin": { diff --git a/apps/mcp-server-standalone/services/directNodeLookupService.js b/apps/mcp-server-standalone/services/directNodeLookupService.js new file mode 100644 index 0000000..f14841c --- /dev/null +++ b/apps/mcp-server-standalone/services/directNodeLookupService.js @@ -0,0 +1,223 @@ +'use strict'; + +const nodeService = require('./nodeService'); +const contextService = require('./contextService'); + +const SEARCH_STOP_WORDS = new Set([ + 'a', 'about', 'added', 'already', 'an', 'and', 'are', 'as', 'at', 'be', 'by', + 'can', 'created', 'do', 'find', 'for', 'from', 'hello', 'i', 'in', 'into', 'is', + 'it', 'just', 'look', 'me', 'my', 'node', 'of', 'on', 'or', 'pull', 'recent', + 'recently', 'saved', 'shared', 'show', 'some', 'stuff', 'term', 'that', 'the', 'this', + 'to', 'versus', 'were', 'what', 'with', 'wrote', 'you', 'doing', 'going', 'having', +]); + +function normalizeSearchText(value) { + return String(value || '') + .toLowerCase() + .replace(/\b([a-z0-9]{1,3})-([a-z0-9]{1,3})(?:-([a-z0-9]{1,3}))?\b/gi, (_match, a, b, c) => `${a}${b}${c || ''}`) + .replace(/[^a-z0-9]+/g, ' ') + .replace(/\s+/g, ' ') + .trim(); +} + +function singularizeTerm(term) { + if (term.endsWith('ies') && term.length > 4) return `${term.slice(0, -3)}y`; + if (term.endsWith('s') && term.length > 4 && !term.endsWith('ss') && !term.endsWith('us')) { + return term.slice(0, -1); + } + return term; +} + +function getHighSignalSearchTerms(query) { + const seen = new Set(); + const terms = []; + + for (const rawTerm of normalizeSearchText(query).split(' ')) { + const term = singularizeTerm(rawTerm.trim()); + if (!term || term.length < 3) continue; + if (SEARCH_STOP_WORDS.has(term)) continue; + if (seen.has(term)) continue; + seen.add(term); + terms.push(term); + } + + return terms; +} + +function countHighSignalQueryTermMatches(node, query) { + const terms = getHighSignalSearchTerms(query); + if (terms.length === 0) return 0; + + const haystack = [ + normalizeSearchText(node.title || ''), + normalizeSearchText(node.description || ''), + normalizeSearchText(node.source || ''), + ].join(' '); + + return terms.filter(term => haystack.includes(term)).length; +} + +function countOccurrences(text, term) { + if (!text || !term) return 0; + const matches = text.match(new RegExp(`\\b${term.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}\\b`, 'g')); + return matches ? matches.length : 0; +} + +function orderedTermMatches(text, terms) { + let position = 0; + for (const term of terms) { + const index = text.indexOf(term, position); + if (index === -1) return false; + position = index + term.length; + } + return terms.length > 0; +} + +function scoreNodeSearchMatch(node, query) { + const normalizedQuery = normalizeSearchText(query); + const normalizedTitle = normalizeSearchText(node.title || ''); + const normalizedDescription = normalizeSearchText(node.description || ''); + const normalizedSource = normalizeSearchText(node.source || ''); + const terms = getHighSignalSearchTerms(query); + + let score = 0; + + if (normalizedTitle === normalizedQuery) score += 2000; + if (normalizedTitle.startsWith(normalizedQuery)) score += 1200; + if (normalizedTitle.includes(normalizedQuery)) score += 700; + if (orderedTermMatches(normalizedTitle, terms)) score += 500; + if (terms.length > 0 && terms.every(term => normalizedTitle.includes(term))) score += 350; + + if (normalizedDescription.includes(normalizedQuery)) score += 180; + if (orderedTermMatches(normalizedDescription, terms)) score += 120; + if (normalizedSource.includes(normalizedQuery)) score += 90; + + const matchedTermCount = countHighSignalQueryTermMatches(node, query); + score += matchedTermCount * 120; + if (terms.length > 0 && matchedTermCount === terms.length) score += 300; + + for (const term of terms) { + score += countOccurrences(normalizedTitle, term) * 40; + score += countOccurrences(normalizedDescription, term) * 8; + score += countOccurrences(normalizedSource, term) * 3; + } + + if (node.updated_at) { + score += new Date(node.updated_at).getTime() / 1e13; + } + + return score; +} + +function normalizeContextName(value) { + if (typeof value !== 'string') return undefined; + const normalized = value.trim().replace(/\s+/g, ' '); + return normalized || undefined; +} + +function resolveSearchContext({ context_name, contextId }) { + const normalizedName = normalizeContextName(context_name); + if (normalizedName) { + const context = contextService.getContextByName(normalizedName); + if (!context) { + console.warn(`directNodeLookupService received unknown context_name "${normalizedName}"; ignoring context filter.`); + return {}; + } + return { + contextId: context.id, + context_name: context.name, + }; + } + + if (typeof contextId === 'number') { + const context = contextService.getContextById(contextId); + if (!context) { + console.warn(`directNodeLookupService received invalid legacy contextId ${contextId}; ignoring context filter.`); + return {}; + } + return { + contextId: context.id, + context_name: context.name, + }; + } + + return {}; +} + +function hasStrongAnchorMatch(nodes, searchTerm) { + 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); +} + +function directNodeLookup(input = {}) { + const limit = Math.min(Math.max(input.limit || 10, 1), 50); + const searchTerm = typeof input.search === 'string' ? input.search.trim() : ''; + + if (searchTerm && /^\d+$/.test(searchTerm)) { + const nodeId = Number(searchTerm); + const node = nodeService.getNodeById(nodeId); + return { + nodes: node ? [node] : [], + count: node ? 1 : 0, + filtersApplied: { + search: searchTerm, + limit, + }, + }; + } + + const resolvedContext = resolveSearchContext(input); + + let safeNodes = nodeService.getNodes({ + search: searchTerm || undefined, + limit, + contextId: resolvedContext.contextId, + createdAfter: input.createdAfter, + createdBefore: input.createdBefore, + eventAfter: input.eventAfter, + eventBefore: input.eventBefore, + }); + + const hadExtraFilters = Boolean( + resolvedContext.contextId !== undefined || + input.createdAfter || + input.createdBefore || + input.eventAfter || + input.eventBefore + ); + + if (searchTerm && hadExtraFilters && (safeNodes.length === 0 || !hasStrongAnchorMatch(safeNodes, searchTerm))) { + console.warn(`directNodeLookupService falling back to plain literal search for "${searchTerm}" after filtered lookup missed a strong anchor match.`); + safeNodes = nodeService.searchNodes({ search: searchTerm, limit }); + } + + if (searchTerm) { + safeNodes = safeNodes + .map(node => ({ node, score: scoreNodeSearchMatch(node, searchTerm) })) + .sort((a, b) => b.score - a.score || String(b.node.updated_at || '').localeCompare(String(a.node.updated_at || ''))) + .slice(0, limit) + .map(entry => entry.node); + } else { + safeNodes = safeNodes.slice(0, limit); + } + + return { + nodes: safeNodes, + count: safeNodes.length, + filtersApplied: { + search: searchTerm || undefined, + limit, + context_name: resolvedContext.context_name, + createdAfter: input.createdAfter, + createdBefore: input.createdBefore, + eventAfter: input.eventAfter, + eventBefore: input.eventBefore, + }, + }; +} + +module.exports = { + directNodeLookup, +}; diff --git a/apps/mcp-server-standalone/services/nodeService.js b/apps/mcp-server-standalone/services/nodeService.js index e8f2789..3abe74b 100644 --- a/apps/mcp-server-standalone/services/nodeService.js +++ b/apps/mcp-server-standalone/services/nodeService.js @@ -158,6 +158,69 @@ function sanitizeTitle(title) { return clean.slice(0, 160); } +const CHUNK_SIZE = 1000; +const CHUNK_OVERLAP = 200; + +function splitSourceIntoChunks(sourceText) { + const text = String(sourceText || '').trim(); + if (!text) return []; + + const chunks = []; + let start = 0; + + while (start < text.length) { + const end = Math.min(start + CHUNK_SIZE, text.length); + const chunkText = text.slice(start, end).trim(); + + if (chunkText) { + chunks.push({ + text: chunkText, + start_char: start, + end_char: end + }); + } + + if (end >= text.length) break; + start = Math.max(end - CHUNK_OVERLAP, start + 1); + } + + return chunks; +} + +function replaceNodeChunks(db, nodeId, title, sourceText) { + db.prepare('DELETE FROM chunks WHERE node_id = ?').run(nodeId); + + const chunks = splitSourceIntoChunks(sourceText); + if (chunks.length === 0) { + return 0; + } + + const now = new Date().toISOString(); + const insertChunk = db.prepare(` + INSERT INTO chunks (node_id, chunk_idx, text, embedding_type, metadata, created_at) + VALUES (?, ?, ?, ?, ?, ?) + `); + + chunks.forEach((chunk, index) => { + insertChunk.run( + nodeId, + index, + chunk.text, + 'text-embedding-3-small', + JSON.stringify({ + node_id: nodeId, + chunk_index: index, + start_char: chunk.start_char, + end_char: chunk.end_char, + title + }), + now + ); + }); + + return chunks.length; +} + /** * Create a new node. */ @@ -180,11 +243,12 @@ function createNode(nodeData) { const sourceToStore = source ?? ([title, description].filter(Boolean).join('\n\n').trim() || null); const effectiveContextId = context_id ?? null; + const hasSource = !!normalizeString(sourceToStore); const nodeId = transaction(() => { const stmt = db.prepare(` - INSERT INTO nodes (title, description, source, link, event_date, metadata, context_id, created_at, updated_at) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?) + INSERT INTO nodes (title, description, source, link, event_date, metadata, chunk_status, context_id, created_at, updated_at) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?) `); const result = stmt.run( @@ -194,6 +258,7 @@ function createNode(nodeData) { link ?? null, event_date ?? null, JSON.stringify(canonicalMetadata), + hasSource ? 'chunked' : null, effectiveContextId ?? null, now, now @@ -201,6 +266,10 @@ function createNode(nodeData) { const id = Number(result.lastInsertRowid); + if (hasSource) { + replaceNodeChunks(db, id, title, sourceToStore); + } + return id; }); @@ -224,6 +293,9 @@ function updateNode(id, updates, options = {}) { const mergedMetadata = metadata !== undefined ? buildCanonicalMetadata({ existing: existing.metadata, metadata }) : undefined; + const sourceWasProvided = Object.prototype.hasOwnProperty.call(updates, 'source'); + const updatedTitle = title !== undefined ? title : existing.title; + const normalizedSource = sourceWasProvided ? normalizeString(source) : undefined; transaction(() => { const setFields = []; @@ -249,6 +321,10 @@ function updateNode(id, updates, options = {}) { setFields.push('event_date = ?'); params.push(event_date); } + if (sourceWasProvided) { + setFields.push('chunk_status = ?'); + params.push(normalizedSource ? 'chunked' : null); + } if (Object.prototype.hasOwnProperty.call(updates, 'context_id')) { setFields.push('context_id = ?'); params.push(updates.context_id ?? null); @@ -268,6 +344,10 @@ function updateNode(id, updates, options = {}) { stmt.run(...params); } + if (sourceWasProvided) { + replaceNodeChunks(db, id, updatedTitle, normalizedSource || ''); + } + }); return getNodeById(id); diff --git a/apps/mcp-server-standalone/skills/db-operations.md b/apps/mcp-server-standalone/skills/db-operations.md index 63ddef8..fc80693 100644 --- a/apps/mcp-server-standalone/skills/db-operations.md +++ b/apps/mcp-server-standalone/skills/db-operations.md @@ -13,8 +13,8 @@ description: "Use for graph read, write, connect, classify, or traverse operatio 4. Search before create to avoid duplicates. 5. Every create/update must include a natural description that clearly says what the thing is, why it matters here, and its current workflow status. 6. Use event dates when known (when it happened, not when saved). -7. Apply context only when it is an obvious match to one of the user's existing contexts and genuinely useful. One node gets at most one primary context, and leaving context blank is valid. -8. Create edges when relationships are meaningful; edge explanations should read as a sentence. +7. Leave context blank by default. Only apply context when the user explicitly wants it or when one obvious existing context is clearly useful. One node gets at most one primary context, and leaving context blank is valid. +8. Do not create edges autonomously. Surface likely edge candidates first, then create them only after the user explicitly confirms. 9. For user-authored ideas, notes, or dictated thoughts, preserve the user's wording in `source` as fully as possible with only minimal cleanup. ## Write Quality Contract @@ -24,7 +24,9 @@ description: "Use for graph read, write, connect, classify, or traverse operatio - `source`: full verbatim or canonical content of the node (transcript, article text, book passage, user's thoughts). This is what gets chunked and embedded for semantic search. - For idea capture from chat, the `source` should usually be the raw user thought, not a compressed assistant summary. - `link`: external source URL only. -- `context_id`: the node's primary context. This field is optional. Omit it entirely unless it is an obvious existing match. Do not add `context_id: null` defensively. +- Normal writes should omit context entirely unless the user explicitly wants one. +- If context is intentionally provided, prefer `context_name`. +- Treat numeric `context_id` as an internal implementation detail, not a normal agent-facing field. - `metadata`: use the canonical node metadata contract when metadata is needed: - `type` - `state` (`processed` or `not_processed`) @@ -34,6 +36,8 @@ description: "Use for graph read, write, connect, classify, or traverse operatio - `source_metadata`: factual source-specific details only. Keep it compact. No AI summaries or reasoning text. - metadata updates are merge-safe patches, not full-blob replacements. Do not assume `updateNode.metadata` wipes existing keys. - Derived analysis, briefs, and research notes should be stored in a separate linked node, not appended to the source node. +- Explicit user-directed capture may write immediately after duplicate/update checks when the node is clear. +- Agent-suggested capture should propose the node first and wait for confirmation. ## Description Standard @@ -57,6 +61,8 @@ For user-authored idea capture, do not treat the inferred description as final i - why it belongs here - where it sits in their workflow +The first job of the description is object identity. It should start from what the thing is, not from interpretation. + Max 500 characters. ## Metadata Semantics @@ -71,17 +77,19 @@ Max 500 characters. 1. Decide whether this is direct node retrieval or broader contextual grounding. 2. If the user is trying to find a specific existing node, call `queryNodes` first. 3. If the user is asking a broader question that would benefit from prior graph context, call `retrieveQueryContext`. -4. Decide: answer only vs create vs update vs connect. +4. Decide: answer only vs create vs update vs propose save vs propose edge. 5. If something seems unusually durable and valuable, you may suggest a save in one short line like `Add "X" as a node?` 6. Do not pester. If the user says no, ignores it, or moves on, do not keep asking. -7. Only call `writeContext` or another write tool after explicit user confirmation. -8. Execute minimum required writes. -9. If the node is a user-authored idea and the contextual framing was inferred, offer one concise feedback pass after the write. -10. Verify result reflects user intent exactly. +7. For explicit user-directed capture, search before create when practical, prefer update over duplicate create, then write once the artifact is clear. +8. For agent-suggested capture, propose the node first and wait for explicit confirmation before writing. +9. When relationships are obvious, include brief proposed edges in the same reply, then wait for confirmation before calling `createEdge`. +10. If the node is a user-authored idea and the contextual framing was inferred, offer one concise feedback pass after the write. +11. Verify result reflects user intent exactly. ## Do Not - Create duplicate nodes when an update is correct. - Write vague descriptions ("discusses", "explores", "is about"). - Create weak or directionless edges. +- Create edges before the user explicitly confirms the proposed relationship. - Ask to save every moderately useful point from the conversation. diff --git a/apps/mcp-server/server.js b/apps/mcp-server/server.js index ee3a9ef..f04b576 100644 --- a/apps/mcp-server/server.js +++ b/apps/mcp-server/server.js @@ -53,6 +53,7 @@ const instructions = [ 'Search before creating: use rah_search_nodes to check if content already exists.', 'Only suggest saving context when it is unusually durable and valuable. Keep the ask brief, for example: Add "X" as a node?', 'Never write via rah_write_context unless the user has explicitly confirmed yes.', + 'Do not create edges autonomously. Surface likely edge candidates briefly, then call edge-write tools only after the user explicitly confirms.', 'Every edge needs an explanation: why does this connection exist?', 'All data stays local on this device; nothing leaves 127.0.0.1.', ].join(' '); @@ -78,8 +79,7 @@ const addNodeInputSchema = { source: z.string().max(50000).optional(), link: z.string().url().optional(), description: z.string().max(500).optional().describe('Description of the node. Write it as natural prose, not labels or a checklist. It must still make clear what the artifact is, why it is in the graph (infer from conversation context; ask the user if needed), and its current workflow status. Max 500 characters. If the reason is unclear, say that naturally instead of inventing it. Never use filler phrases like "insightful for understanding" or "relevant to the user\'s work".'), - context_id: z.number().int().positive().nullable().optional().describe('Optional primary context ID. Usually omit this field entirely unless you already know a real matching context.'), - context_name: z.string().optional(), + context_name: z.string().optional().describe('Optional primary context name. Use only when the user explicitly wants this node assigned to a known context.'), metadata: z.record(z.any()).optional().describe('Optional metadata. Prefer canonical keys: type, state, captured_method, captured_by, source_metadata.'), chunk: z.string().max(50000).optional() }; @@ -92,8 +92,8 @@ const addNodeOutputSchema = { const searchNodesInputSchema = { query: z.string().min(1).max(400), - limit: z.number().min(1).max(25).optional(), - contextId: z.number().int().positive().optional() + limit: z.number().min(1).max(50).optional(), + context_name: z.string().optional() }; const searchNodesOutputSchema = { @@ -147,7 +147,7 @@ const writeContextInputSchema = { title: z.string().min(1).max(160), description: z.string().min(1).max(500), source: z.string().max(50000).optional(), - context_id: z.number().int().positive().nullable().optional(), + context_name: z.string().optional(), metadata: z.record(z.any()).optional(), confirmed_by_user: z.boolean() }; @@ -199,7 +199,8 @@ const updateNodeInputSchema = { content: z.string().optional().describe('Legacy alias for source. Mapped to source for backward compatibility.'), source: z.string().optional().describe('Canonical source text for embedding.'), link: z.string().optional().describe('New link'), - context_id: z.number().int().positive().nullable().optional().describe('Optional primary context ID. Omit this field to preserve existing context. Only use null when you intentionally want to clear context.'), + context_name: z.string().optional().describe('Optional primary context name. Use only when the user explicitly wants to assign this node to a known context.'), + clear_context: z.boolean().optional().describe('Set true only when the user explicitly wants to remove the node context.'), metadata: z.record(z.any()).optional().describe('Metadata patch. This now merges with existing metadata. Prefer canonical keys: type, state, captured_method, captured_by, source_metadata.') }).describe('Fields to update') }; @@ -232,7 +233,8 @@ const getNodesOutputSchema = { const createEdgeInputSchema = { sourceId: z.number().int().positive().describe('Source node ID'), targetId: z.number().int().positive().describe('Target node ID'), - explanation: z.string().min(1).describe('REQUIRED: Why does this connection exist? Be specific.') + explanation: z.string().min(1).describe('REQUIRED: Why does this connection exist? Be specific.'), + confirmed_by_user: z.boolean().describe('Must be true. Only create the edge after the user explicitly confirmed this proposed relationship.') }; const createEdgeOutputSchema = { @@ -263,7 +265,8 @@ const queryEdgesOutputSchema = { // rah_update_edge schemas const updateEdgeInputSchema = { id: z.number().int().positive().describe('Edge ID to update'), - explanation: z.string().min(1).optional().describe('New explanation text (will re-infer relationship type)') + explanation: z.string().min(1).describe('New explanation text (will re-infer relationship type)'), + confirmed_by_user: z.boolean().describe('Must be true. Only update the edge after the user explicitly confirmed the corrected relationship.') }; const updateEdgeOutputSchema = { @@ -375,17 +378,16 @@ mcpServer.registerTool( 'rah_add_node', { title: 'Add RA-H node', - description: 'Create a new node in the local RA-H knowledge base. `context_id` is optional and should usually be omitted entirely unless one obvious existing context clearly fits.', + description: 'Create a new node in the local RA-H knowledge base after you have already decided a net-new write is correct. If the user explicitly asked to save or import something and the target artifact is clear, write after duplicate/update checks. If you are only suggesting a save, propose the node first and wait for confirmation. Leave context blank by default. If the user explicitly wants context, use `context_name` rather than a numeric ID.', inputSchema: addNodeInputSchema, outputSchema: addNodeOutputSchema }, - async ({ title, content, source, link, description, context_id, context_name, metadata, chunk }) => { + async ({ title, content, source, link, description, context_name, metadata, chunk }) => { const payload = { title: title.trim(), source: source?.trim() || content?.trim() || chunk?.trim() || undefined, link: link?.trim() || undefined, description: description?.trim() || undefined, - context_id, context_name: context_name?.trim() || undefined, metadata: metadata || {} }; @@ -413,24 +415,21 @@ mcpServer.registerTool( 'rah_search_nodes', { title: 'Search RA-H nodes', - description: 'Find existing RA-H entries that mention a topic before adding new ones. For full current-turn grounding of a substantive request, prefer rah_retrieve_query_context.', + description: 'Find existing RA-H entries that mention a topic before adding new ones. Leave context blank by default. If the user explicitly wants a context-specific lookup, use `context_name` rather than a numeric ID. For full current-turn grounding of a substantive request, prefer rah_retrieve_query_context.', inputSchema: searchNodesInputSchema, outputSchema: searchNodesOutputSchema }, - async ({ query, limit = 10, contextId }) => { - const params = new URLSearchParams(); - params.set('search', query.trim()); - params.set('limit', String(Math.min(Math.max(limit, 1), 25))); - - if (contextId) { - params.set('contextId', String(contextId)); - } - - const result = await callRaHApi(`/api/nodes?${params.toString()}`, { - method: 'GET' + async ({ query, limit = 10, context_name }) => { + const result = await callRaHApi('/api/nodes/direct-search', { + method: 'POST', + body: JSON.stringify({ + query: query.trim(), + limit: Math.min(Math.max(limit, 1), 50), + context_name: typeof context_name === 'string' ? context_name.trim() : undefined, + }) }); - const nodes = Array.isArray(result.data) ? result.data : []; + const nodes = Array.isArray(result.data?.nodes) ? result.data.nodes : []; const summary = nodes.length === 0 ? 'No existing RA-H nodes mention that topic yet.' : `Found ${nodes.length} node(s) mentioning that topic.`; @@ -565,7 +564,7 @@ mcpServer.registerTool( 'rah_update_node', { title: 'Update RA-H node', - description: 'Update an existing node. `context_id` is optional and should usually be omitted entirely unless you are intentionally setting or clearing a real context.', + description: 'Update an existing node when it is clearly the same artifact and a net-new node would be redundant. Explicit user-directed updates can proceed once the target node is clear. Context is preserved by default. If the user explicitly wants to change context, use `context_name`. Use `clear_context` only when the user explicitly wants to remove the node context.', inputSchema: updateNodeInputSchema, outputSchema: updateNodeOutputSchema }, @@ -585,6 +584,10 @@ mcpServer.registerTool( } delete mappedUpdates.chunk; + if (mappedUpdates.context_name && mappedUpdates.clear_context) { + throw new McpError(ErrorCode.InvalidParams, 'context_name cannot be combined with clear_context: true.'); + } + const result = await callRaHApi(`/api/nodes/${id}`, { method: 'PUT', body: JSON.stringify(mappedUpdates) @@ -648,17 +651,22 @@ mcpServer.registerTool( 'rah_create_edge', { title: 'Create RA-H edge', - description: 'Create a connection between two nodes.', + description: 'Create a connection between two nodes only after the user has explicitly confirmed the proposed relationship.', inputSchema: createEdgeInputSchema, outputSchema: createEdgeOutputSchema }, - async ({ sourceId, targetId, explanation }) => { + async ({ sourceId, targetId, explanation, confirmed_by_user }) => { + if (!confirmed_by_user) { + throw new McpError(ErrorCode.InvalidParams, 'rah_create_edge requires explicit user confirmation before writing the relationship.'); + } + const payload = { from_node_id: sourceId, to_node_id: targetId, explanation: explanation.trim(), source: 'helper_name', - created_via: 'mcp' + created_via: 'mcp', + confirmed_by_user: true }; const result = await callRaHApi('/api/edges', { @@ -716,19 +724,20 @@ mcpServer.registerTool( 'rah_update_edge', { title: 'Update RA-H edge', - description: 'Update an existing edge connection.', + description: 'Update an existing edge connection only after the user explicitly confirmed the corrected relationship.', inputSchema: updateEdgeInputSchema, outputSchema: updateEdgeOutputSchema }, - async ({ id, explanation }) => { - if (typeof explanation !== 'string' || explanation.trim().length === 0) { - throw new McpError(ErrorCode.InvalidParams, 'explanation is required.'); + async ({ id, explanation, confirmed_by_user }) => { + if (!confirmed_by_user) { + throw new McpError(ErrorCode.InvalidParams, 'rah_update_edge requires explicit user confirmation before writing the corrected relationship.'); } const result = await callRaHApi(`/api/edges/${id}`, { method: 'PUT', body: JSON.stringify({ - context: { explanation: explanation.trim(), created_via: 'mcp' } + context: { explanation: explanation.trim(), created_via: 'mcp' }, + confirmed_by_user: true }) }); @@ -902,11 +911,11 @@ mcpServer.registerTool( 'rah_write_context', { title: 'Write RA-H context node', - 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.', + description: 'Write one atomic durable context node to the graph only after the user has explicitly approved the save. Use this for agent-suggested capture after you already proposed the node briefly and got a clear yes. Prefer ordinary create/update flows for explicit user-directed capture.', inputSchema: writeContextInputSchema, outputSchema: writeContextOutputSchema }, - async ({ title, description, source, context_id, metadata, confirmed_by_user }) => { + async ({ title, description, source, context_name, metadata, confirmed_by_user }) => { if (!confirmed_by_user) { throw new Error('rah_write_context requires explicit user confirmation before writing to the graph.'); } @@ -917,7 +926,7 @@ mcpServer.registerTool( title: title.trim(), description: description.trim(), source: source?.trim() || undefined, - context_id: context_id ?? null, + context_name: context_name?.trim() || undefined, metadata: { captured_by: 'human', captured_method: 'write_context', diff --git a/apps/mcp-server/stdio-server.js b/apps/mcp-server/stdio-server.js index 706820e..9cc59ae 100644 --- a/apps/mcp-server/stdio-server.js +++ b/apps/mcp-server/stdio-server.js @@ -20,6 +20,7 @@ const instructions = [ 'Search before creating: use rah_search_nodes to check if content already exists.', 'Only suggest saving context when it is unusually durable and valuable. Keep the ask brief, for example: Add "X" as a node?', 'Never write via rah_write_context unless the user has explicitly confirmed yes.', + 'Do not create edges autonomously. Surface likely edge candidates briefly, then call edge-write tools only after the user explicitly confirms.', 'Every edge needs an explanation: why does this connection exist?', 'All data stays local on this device; nothing leaves 127.0.0.1.', ].join(' '); @@ -44,8 +45,7 @@ const addNodeInputSchema = { source: z.string().max(50000).optional(), link: z.string().url().optional(), description: z.string().max(500).optional().describe('Description of the node. Write it as natural prose, not labels or a checklist. It must still make clear what the artifact is, why it is in the graph (infer from conversation context; ask the user if needed), and its current workflow status. Max 500 characters. If the reason is unclear, say that naturally instead of inventing it. Never use filler phrases like "insightful for understanding" or "relevant to the user\'s work".'), - context_id: z.number().int().positive().nullable().optional().describe('Optional primary context ID. Usually omit this field entirely unless you already know a real matching context.'), - context_name: z.string().optional(), + context_name: z.string().optional().describe('Optional primary context name. Use only when the user explicitly wants this node assigned to a known context.'), metadata: z.record(z.any()).optional().describe('Optional metadata. Prefer canonical keys: type, state, captured_method, captured_by, source_metadata.'), chunk: z.string().max(50000).optional() }; @@ -58,8 +58,8 @@ const addNodeOutputSchema = { const searchNodesInputSchema = { query: z.string().min(1).max(400), - limit: z.number().min(1).max(25).optional(), - contextId: z.number().int().positive().optional() + limit: z.number().min(1).max(50).optional(), + context_name: z.string().optional() }; const searchNodesOutputSchema = { @@ -113,7 +113,7 @@ const writeContextInputSchema = { title: z.string().min(1).max(160), description: z.string().min(1).max(500), source: z.string().max(50000).optional(), - context_id: z.number().int().positive().nullable().optional(), + context_name: z.string().optional(), metadata: z.record(z.any()).optional(), confirmed_by_user: z.boolean() }; @@ -165,7 +165,8 @@ const updateNodeInputSchema = { content: z.string().optional().describe('Legacy alias for source. Mapped to source for backward compatibility.'), source: z.string().optional().describe('Canonical source text for embedding.'), link: z.string().optional().describe('New link'), - context_id: z.number().int().positive().nullable().optional().describe('Optional primary context ID. Omit this field to preserve existing context. Only use null when you intentionally want to clear context.'), + context_name: z.string().optional().describe('Optional primary context name. Use only when the user explicitly wants to assign this node to a known context.'), + clear_context: z.boolean().optional().describe('Set true only when the user explicitly wants to remove the node context.'), metadata: z.record(z.any()).optional().describe('Metadata patch. This now merges with existing metadata. Prefer canonical keys: type, state, captured_method, captured_by, source_metadata.') }).describe('Fields to update') }; @@ -198,7 +199,8 @@ const getNodesOutputSchema = { const createEdgeInputSchema = { sourceId: z.number().int().positive().describe('Source node ID'), targetId: z.number().int().positive().describe('Target node ID'), - explanation: z.string().min(1).describe('REQUIRED: Why does this connection exist? Be specific.') + explanation: z.string().min(1).describe('REQUIRED: Why does this connection exist? Be specific.'), + confirmed_by_user: z.boolean().describe('Must be true. Only create the edge after the user explicitly confirmed this proposed relationship.') }; const createEdgeOutputSchema = { @@ -229,7 +231,8 @@ const queryEdgesOutputSchema = { // rah_update_edge schemas const updateEdgeInputSchema = { id: z.number().int().positive().describe('Edge ID to update'), - explanation: z.string().min(1).optional().describe('New explanation text (will re-infer relationship type)') + explanation: z.string().min(1).describe('New explanation text (will re-infer relationship type)'), + confirmed_by_user: z.boolean().describe('Must be true. Only update the edge after the user explicitly confirmed the corrected relationship.') }; const updateEdgeOutputSchema = { @@ -315,13 +318,34 @@ async function resolveBaseUrl() { if (envTarget && envTarget.trim().length > 0) { return envTarget.replace(/\/+$/, ''); } + + const isReachableBaseUrl = async (candidate) => { + if (!candidate) return false; + const normalized = String(candidate).replace(/\/+$/, ''); + try { + const response = await fetch(`${normalized}/api/contexts`, { + method: 'GET', + signal: AbortSignal.timeout(1500) + }); + return response.ok; + } catch { + return false; + } + }; + const status = readStatusFile(); - if (status?.target_base_url) { - return String(status.target_base_url).replace(/\/+$/, ''); - } - if (status?.port) { - return `http://127.0.0.1:${status.port}`.replace(/\/+$/, ''); + const candidates = [ + status?.target_base_url ? String(status.target_base_url) : null, + process.env.NEXT_PUBLIC_BASE_URL || null, + 'http://127.0.0.1:3000' + ].filter(Boolean); + + for (const candidate of candidates) { + if (await isReachableBaseUrl(candidate)) { + return String(candidate).replace(/\/+$/, ''); + } } + return 'http://127.0.0.1:3000'; } @@ -349,17 +373,16 @@ server.registerTool( 'rah_add_node', { title: 'Add RA-H node', - description: 'Create a new node in the local RA-H knowledge base. `context_id` is optional and should usually be omitted entirely unless one obvious existing context clearly fits.', + description: 'Create a new node in the local RA-H knowledge base after you have already decided a net-new write is correct. If the user explicitly asked to save or import something and the target artifact is clear, write after duplicate/update checks. If you are only suggesting a save, propose the node first and wait for confirmation. Leave context blank by default. If the user explicitly wants context, use `context_name` rather than a numeric ID.', inputSchema: addNodeInputSchema, outputSchema: addNodeOutputSchema }, - async ({ title, content, source, link, description, context_id, context_name, metadata, chunk }) => { + async ({ title, content, source, link, description, context_name, metadata, chunk }) => { const payload = { title: title.trim(), source: source?.trim() || content?.trim() || chunk?.trim() || undefined, link: link?.trim() || undefined, description: description?.trim() || undefined, - context_id, context_name: context_name?.trim() || undefined, metadata: metadata || {} }; @@ -387,24 +410,21 @@ server.registerTool( 'rah_search_nodes', { title: 'Search RA-H nodes', - description: 'Find existing RA-H entries that mention a topic before adding new ones. For full current-turn grounding of a substantive request, prefer rah_retrieve_query_context.', + description: 'Find existing RA-H entries that mention a topic before adding new ones. Leave context blank by default. If the user explicitly wants a context-specific lookup, use `context_name` rather than a numeric ID. For full current-turn grounding of a substantive request, prefer rah_retrieve_query_context.', inputSchema: searchNodesInputSchema, outputSchema: searchNodesOutputSchema }, - async ({ query, limit = 10, contextId }) => { - const params = new URLSearchParams(); - params.set('search', query.trim()); - params.set('limit', String(Math.min(Math.max(limit, 1), 25))); - - if (contextId) { - params.set('contextId', String(contextId)); - } - - const result = await callRaHApi(`/api/nodes?${params.toString()}`, { - method: 'GET' + async ({ query, limit = 10, context_name }) => { + const result = await callRaHApi('/api/nodes/direct-search', { + method: 'POST', + body: JSON.stringify({ + query: query.trim(), + limit: Math.min(Math.max(limit, 1), 50), + context_name: typeof context_name === 'string' ? context_name.trim() : undefined, + }) }); - const nodes = Array.isArray(result.data) ? result.data : []; + const nodes = Array.isArray(result.data?.nodes) ? result.data.nodes : []; const summary = nodes.length === 0 ? 'No existing RA-H nodes mention that topic yet.' @@ -541,7 +561,7 @@ server.registerTool( 'rah_update_node', { title: 'Update RA-H node', - description: 'Update an existing node. `context_id` is optional and should usually be omitted entirely unless you are intentionally setting or clearing a real context.', + description: 'Update an existing node when it is clearly the same artifact and a net-new node would be redundant. Explicit user-directed updates can proceed once the target node is clear. Context is preserved by default. If the user explicitly wants to change context, use `context_name`. Use `clear_context` only when the user explicitly wants to remove the node context.', inputSchema: updateNodeInputSchema, outputSchema: updateNodeOutputSchema }, @@ -561,6 +581,10 @@ server.registerTool( } delete mappedUpdates.chunk; + if (mappedUpdates.context_name && mappedUpdates.clear_context) { + throw new Error('context_name cannot be combined with clear_context: true.'); + } + const result = await callRaHApi(`/api/nodes/${id}`, { method: 'PUT', body: JSON.stringify(mappedUpdates) @@ -624,17 +648,22 @@ server.registerTool( 'rah_create_edge', { title: 'Create RA-H edge', - description: 'Create a connection between two nodes.', + description: 'Create a connection between two nodes only after the user has explicitly confirmed the proposed relationship.', inputSchema: createEdgeInputSchema, outputSchema: createEdgeOutputSchema }, - async ({ sourceId, targetId, explanation }) => { + async ({ sourceId, targetId, explanation, confirmed_by_user }) => { + if (!confirmed_by_user) { + throw new Error('rah_create_edge requires explicit user confirmation before writing the relationship.'); + } + const payload = { from_node_id: sourceId, to_node_id: targetId, explanation: explanation.trim(), source: 'helper_name', - created_via: 'mcp' + created_via: 'mcp', + confirmed_by_user: true }; const result = await callRaHApi('/api/edges', { @@ -692,19 +721,20 @@ server.registerTool( 'rah_update_edge', { title: 'Update RA-H edge', - description: 'Update an existing edge connection.', + description: 'Update an existing edge connection only after the user explicitly confirmed the corrected relationship.', inputSchema: updateEdgeInputSchema, outputSchema: updateEdgeOutputSchema }, - async ({ id, explanation }) => { - if (typeof explanation !== 'string' || explanation.trim().length === 0) { - throw new Error('explanation is required'); + async ({ id, explanation, confirmed_by_user }) => { + if (!confirmed_by_user) { + throw new Error('rah_update_edge requires explicit user confirmation before writing the corrected relationship.'); } const result = await callRaHApi(`/api/edges/${id}`, { method: 'PUT', body: JSON.stringify({ - context: { explanation: explanation.trim(), created_via: 'mcp' } + context: { explanation: explanation.trim(), created_via: 'mcp' }, + confirmed_by_user: true }) }); @@ -921,11 +951,11 @@ server.registerTool( 'rah_write_context', { title: 'Write RA-H context node', - 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.', + description: 'Write one atomic durable context node to the graph only after the user has explicitly approved the save. Use this for agent-suggested capture after you already proposed the node briefly and got a clear yes. Prefer ordinary create/update flows for explicit user-directed capture.', inputSchema: writeContextInputSchema, outputSchema: writeContextOutputSchema }, - async ({ title, description, source, context_id, metadata, confirmed_by_user }) => { + async ({ title, description, source, context_name, metadata, confirmed_by_user }) => { if (!confirmed_by_user) { throw new Error('rah_write_context requires explicit user confirmation before writing to the graph.'); } @@ -936,7 +966,7 @@ server.registerTool( title: title.trim(), description: description.trim(), source: source?.trim() || undefined, - context_id: context_id ?? null, + context_name: context_name?.trim() || undefined, metadata: { captured_by: 'human', captured_method: 'write_context', diff --git a/docs/0_overview.md b/docs/0_overview.md index f052d7c..198dd42 100644 --- a/docs/0_overview.md +++ b/docs/0_overview.md @@ -63,15 +63,15 @@ RA-OS is designed to be the knowledge backend for your AI workflows: "mcpServers": { "ra-h": { "command": "npx", - "args": ["ra-h-mcp-server"] + "args": ["--yes", "ra-h-mcp-server@2.1.1"] } } } ``` -Add this to `~/.claude.json` and restart Claude. Works without RA-OS running. +Add this to `~/.claude.json` and restart Claude. Works without RA-OS running. If you publish a newer MCP release and need clients to pick it up immediately, bump the pinned version here and restart the client. -Core tools include: `createNode`, `queryNodes`, `updateNode`, `getNodesById`, `createEdge`, `queryEdge`, `queryContexts`, `listSkills`, `readSkill` +Core tools include: `queryNodes`, `retrieveQueryContext`, `createNode`, `writeContext`, `updateNode`, `getNodesById`, `createEdge`, `updateEdge`, `queryEdge`, `queryContexts`, `listSkills`, `readSkill` ## Documentation diff --git a/docs/4_tools-and-guides.md b/docs/4_tools-and-guides.md index a725e70..59b03da 100644 --- a/docs/4_tools-and-guides.md +++ b/docs/4_tools-and-guides.md @@ -15,13 +15,15 @@ RA-OS exposes these core standalone MCP tools: | Tool | Description | |------|-------------| | `getContext` | Graph overview: stats, contexts, hub nodes, recent activity, skills | -| `queryNodes` | Search nodes by keyword/date/context | +| `retrieveQueryContext` | Pull relevant graph context for a broader current-turn task | +| `queryNodes` | Find specific existing nodes by title, description, or source | | `getNodesById` | Fetch full nodes by ID | | `createNode` | Create a node | -| `updateNode` | Update a node | -| `createEdge` | Create an edge between nodes | +| `writeContext` | Save one confirmed durable context node after explicit user approval | +| `updateNode` | Update a node while preserving context by default | +| `createEdge` | Create a confirmed edge between nodes | | `queryEdge` | Query edges | -| `updateEdge` | Update edge explanation | +| `updateEdge` | Update an edge explanation after explicit confirmation | | `queryContexts` | List contexts and optional attached nodes | ### Skills + Search diff --git a/docs/8_mcp.md b/docs/8_mcp.md index 56939bd..6ee61cc 100644 --- a/docs/8_mcp.md +++ b/docs/8_mcp.md @@ -7,8 +7,12 @@ RA-H exposes MCP tools for direct graph work against the local database or app A - `queryNodes` is the primary tool for direct node retrieval when the user is trying to find a specific existing node. - `retrieveQueryContext` is the primary retrieval entrypoint for substantive current-turn work when the agent needs graph context to support a broader answer. - `getContext` returns graph orientation: stats, contexts, hubs, and skills. -- `createNode` and `updateNode` accept optional `context_id` but do not require context. Omitting `context_id` is the normal default. +- `createNode`, `updateNode`, and `queryNodes` leave context blank by default. +- If context is intentionally provided, prefer `context_name`. +- `context_id` is an internal implementation detail, not the normal agent-facing field. - `writeContext` writes one confirmed durable context node and must never be called before explicit user approval. +- `createEdge` is a post-confirmation execution tool. Agents should propose likely edges first and only write them after the user explicitly confirms. +- `updateEdge` is also a post-confirmation execution tool. Agents should only correct an edge after the user explicitly confirms the corrected relationship. - `queryNodes` searches title, description, and source, with optional context filters. - `dimensions` are removed from the MCP contract. @@ -40,8 +44,13 @@ Write: - Always search before creating. - If the user is trying to find a specific existing node, use `queryNodes` first. - If the user is asking a broader question that would benefit from graph context, use `retrieveQueryContext`. -- Prefer explicit context assignment only when the primary scope is clear and a real context is already known. -- Do not send `context_id: null` unless the tool call is intentionally clearing an existing context. +- Optional user memory reinforcement can help, but the MCP tools, instructions, skills, and docs should be enough for the core retrieval and writeback contract to work. +- Prefer explicit context assignment only when the user clearly wants it and a real context is already known. +- Use `context_name` when context is intentionally provided. +- Do not assume the agent needs to think about context during normal node creation, lookup, or update flows. - Do not assume the server will infer a best-fit context. +- If the user explicitly asked to save or update something and the target artifact is clear, the agent can write after duplicate/update checks. +- If the agent is only suggesting a save, it should propose the node first and wait for confirmation. +- When obvious relationships appear, propose candidate edges briefly rather than writing them automatically. - Judge graph quality by node quality and edges, not taxonomy completeness. - Keep writeback prompts terse and selective. The goal is not to ask constantly whether every useful sentence should be saved. diff --git a/docs/README.md b/docs/README.md index 83dc4a9..bba1047 100644 --- a/docs/README.md +++ b/docs/README.md @@ -49,12 +49,14 @@ Add to your `~/.claude.json`: "mcpServers": { "ra-h": { "command": "npx", - "args": ["ra-h-mcp-server"] + "args": ["--yes", "ra-h-mcp-server@2.1.1"] } } } ``` +If you publish a newer MCP release and need clients to use it immediately, bump the pinned version here and restart the client. Do not assume plain `npx ra-h-mcp-server` always refreshes instantly. + Works without RA-OS running. See [MCP docs](./8_mcp.md) for alternatives. ## Questions? diff --git a/src/components/focus/FocusPanel.tsx b/src/components/focus/FocusPanel.tsx index b41fecb..4290705 100644 --- a/src/components/focus/FocusPanel.tsx +++ b/src/components/focus/FocusPanel.tsx @@ -432,32 +432,6 @@ export default function FocusPanel({ } }; - const createEdgeAuto = async (targetNodeId: number) => { - if (activeTab === null) return; - try { - const response = await fetch('/api/edges', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ - from_node_id: activeTab, - to_node_id: targetNodeId, - source: 'user', - explanation: '', - }), - }); - - if (!response.ok) { - throw new Error('Failed to create edge'); - } - - await fetchEdgesData(activeTab); - } catch (error) { - console.error('Error creating edge:', error); - window.alert('Failed to create connection. Please try again.'); - throw error; - } - }; - const createEdgeWithExplanation = async (targetNodeId: number, explanation: string) => { if (activeTab === null) return; try { @@ -1433,11 +1407,11 @@ export default function FocusPanel({ onClose={() => setEdgeSearchOpen(false)} excludeNodeId={activeTab} onEdgeCreate={async (nodeId, explanation) => { - if (explanation && explanation.trim()) { - await createEdgeWithExplanation(nodeId, explanation.trim()); - } else { - await createEdgeAuto(nodeId); + if (!explanation || !explanation.trim()) { + window.alert('Add a short explanation for the relationship before creating the edge.'); + return; } + await createEdgeWithExplanation(nodeId, explanation.trim()); }} /> diff --git a/src/components/focus/edges/NodeSearchModal.tsx b/src/components/focus/edges/NodeSearchModal.tsx index 2c65cdd..4226c9c 100644 --- a/src/components/focus/edges/NodeSearchModal.tsx +++ b/src/components/focus/edges/NodeSearchModal.tsx @@ -315,12 +315,12 @@ export default function NodeSearchModal({ value={explanation} onChange={(e) => setExplanation(e.target.value.slice(0, 500))} onKeyDown={(e) => { - if ((e.metaKey || e.ctrlKey) && e.key === 'Enter') { + if ((e.metaKey || e.ctrlKey) && e.key === 'Enter' && explanation.trim()) { e.preventDefault(); void handleCreate(selectedNode, explanation); } }} - placeholder="Describe this connection... (optional, leave blank to auto-infer)" + placeholder="Describe this connection in one clear sentence" rows={3} style={{ width: '100%', @@ -351,7 +351,7 @@ export default function NodeSearchModal({ )} diff --git a/src/config/skills/db-operations.md b/src/config/skills/db-operations.md index 63ddef8..fc80693 100644 --- a/src/config/skills/db-operations.md +++ b/src/config/skills/db-operations.md @@ -13,8 +13,8 @@ description: "Use for graph read, write, connect, classify, or traverse operatio 4. Search before create to avoid duplicates. 5. Every create/update must include a natural description that clearly says what the thing is, why it matters here, and its current workflow status. 6. Use event dates when known (when it happened, not when saved). -7. Apply context only when it is an obvious match to one of the user's existing contexts and genuinely useful. One node gets at most one primary context, and leaving context blank is valid. -8. Create edges when relationships are meaningful; edge explanations should read as a sentence. +7. Leave context blank by default. Only apply context when the user explicitly wants it or when one obvious existing context is clearly useful. One node gets at most one primary context, and leaving context blank is valid. +8. Do not create edges autonomously. Surface likely edge candidates first, then create them only after the user explicitly confirms. 9. For user-authored ideas, notes, or dictated thoughts, preserve the user's wording in `source` as fully as possible with only minimal cleanup. ## Write Quality Contract @@ -24,7 +24,9 @@ description: "Use for graph read, write, connect, classify, or traverse operatio - `source`: full verbatim or canonical content of the node (transcript, article text, book passage, user's thoughts). This is what gets chunked and embedded for semantic search. - For idea capture from chat, the `source` should usually be the raw user thought, not a compressed assistant summary. - `link`: external source URL only. -- `context_id`: the node's primary context. This field is optional. Omit it entirely unless it is an obvious existing match. Do not add `context_id: null` defensively. +- Normal writes should omit context entirely unless the user explicitly wants one. +- If context is intentionally provided, prefer `context_name`. +- Treat numeric `context_id` as an internal implementation detail, not a normal agent-facing field. - `metadata`: use the canonical node metadata contract when metadata is needed: - `type` - `state` (`processed` or `not_processed`) @@ -34,6 +36,8 @@ description: "Use for graph read, write, connect, classify, or traverse operatio - `source_metadata`: factual source-specific details only. Keep it compact. No AI summaries or reasoning text. - metadata updates are merge-safe patches, not full-blob replacements. Do not assume `updateNode.metadata` wipes existing keys. - Derived analysis, briefs, and research notes should be stored in a separate linked node, not appended to the source node. +- Explicit user-directed capture may write immediately after duplicate/update checks when the node is clear. +- Agent-suggested capture should propose the node first and wait for confirmation. ## Description Standard @@ -57,6 +61,8 @@ For user-authored idea capture, do not treat the inferred description as final i - why it belongs here - where it sits in their workflow +The first job of the description is object identity. It should start from what the thing is, not from interpretation. + Max 500 characters. ## Metadata Semantics @@ -71,17 +77,19 @@ Max 500 characters. 1. Decide whether this is direct node retrieval or broader contextual grounding. 2. If the user is trying to find a specific existing node, call `queryNodes` first. 3. If the user is asking a broader question that would benefit from prior graph context, call `retrieveQueryContext`. -4. Decide: answer only vs create vs update vs connect. +4. Decide: answer only vs create vs update vs propose save vs propose edge. 5. If something seems unusually durable and valuable, you may suggest a save in one short line like `Add "X" as a node?` 6. Do not pester. If the user says no, ignores it, or moves on, do not keep asking. -7. Only call `writeContext` or another write tool after explicit user confirmation. -8. Execute minimum required writes. -9. If the node is a user-authored idea and the contextual framing was inferred, offer one concise feedback pass after the write. -10. Verify result reflects user intent exactly. +7. For explicit user-directed capture, search before create when practical, prefer update over duplicate create, then write once the artifact is clear. +8. For agent-suggested capture, propose the node first and wait for explicit confirmation before writing. +9. When relationships are obvious, include brief proposed edges in the same reply, then wait for confirmation before calling `createEdge`. +10. If the node is a user-authored idea and the contextual framing was inferred, offer one concise feedback pass after the write. +11. Verify result reflects user intent exactly. ## Do Not - Create duplicate nodes when an update is correct. - Write vague descriptions ("discusses", "explores", "is about"). - Create weak or directionless edges. +- Create edges before the user explicitly confirms the proposed relationship. - Ask to save every moderately useful point from the conversation. diff --git a/src/config/skills/onboarding.md b/src/config/skills/onboarding.md index f9340dc..bb94bf1 100644 --- a/src/config/skills/onboarding.md +++ b/src/config/skills/onboarding.md @@ -114,7 +114,7 @@ Do your best to build the graph as useful context emerges. - Add nodes when the user mentions concrete things worth keeping. - Assign a context only when it is an obvious match to one of the user's existing contexts. Prefer leaving context empty over low-confidence guessing. -- Add edges when relationships are clear enough to explain well. +- Surface likely edges when relationships are clear enough to explain well, but create them only after the user confirms. - Explain what you're adding in plain language so the user understands the structure as it develops. When the graph is empty or nearly empty, bias toward creating a small, clean starter set rather than over-modeling everything. @@ -126,7 +126,7 @@ Before writing anything, call `readSkill('db-operations')` for full quality stan - Search before creating — avoid duplicates from day one - Every description must be concrete: what it IS and why it matters to them, not what it "explores" or "discusses" - Contexts are optional and should only be used for an obvious existing match; otherwise leave them empty -- Every edge needs an explicit explanation sentence +- Every edge needs an explicit explanation sentence, and agent-driven edge creation should only happen after confirmation ## Propose Before Writing diff --git a/src/services/agents/autoEdge.ts b/src/services/agents/autoEdge.ts index e5e2bcf..ec517bb 100644 --- a/src/services/agents/autoEdge.ts +++ b/src/services/agents/autoEdge.ts @@ -1,24 +1,24 @@ /** - * Auto-Edge Creation Service + * Potential edge suggestion helper. * - * After Quick Capture creates a node, this service: - * 1. Extracts candidate entity strings from the node's description - * 2. Looks up existing entity nodes by exact title match - * 3. Creates edges with explanations for matches - * - * This is a "fast path" for obvious connections only - conservative by design. + * This module no longer writes edges automatically. It only identifies + * obvious connection candidates so an agent or UI can propose them first. */ -import { nodeService, edgeService } from '@/services/database'; +import { nodeService } from '@/services/database'; import { Node } from '@/types/database'; -/** - * Clean up a candidate entity string by removing common prefixes/suffixes. - */ +export interface PotentialEdgeSuggestion { + from_node_id: number; + to_node_id: number; + to_node_title: string; + explanation: string; + candidate_text: string; +} + function cleanEntityCandidate(candidate: string): string { let cleaned = candidate.trim(); - // Remove common author/attribution prefixes const prefixPatterns = [ /^by\s+/i, /^author:\s*/i, @@ -37,65 +37,8 @@ function cleanEntityCandidate(candidate: string): string { return cleaned.trim(); } -/** - * Extract candidate entity strings from text using conservative heuristics. - * Returns proper names, quoted titles, and recognized patterns. - */ -function extractCandidateEntities(text: string): string[] { - if (!text || typeof text !== 'string') return []; - - const candidates: Set = new Set(); - - // Pattern 1: "By [Name]" pattern - common in article descriptions - // Matches: "By Simon Willison", "by Sam Altman" - const byPattern = /\b[Bb]y\s+([A-Z][a-z]+(?:\s+[A-Z][a-z]+){0,2})\b/g; - let match; - while ((match = byPattern.exec(text)) !== null) { - const name = match[1].trim(); - if (name.length >= 4 && !isGenericPhrase(name)) { - candidates.add(name); - } - } - - // Pattern 2: Proper name sequences (2-4 capitalized words) - // Matches: "Sam Altman", "Dario Amodei", "Peter Thiel" - const properNamePattern = /\b([A-Z][a-z]+(?:\s+[A-Z][a-z]+){1,3})\b/g; - while ((match = properNamePattern.exec(text)) !== null) { - const name = match[1].trim(); - // Clean the candidate (remove "By ", etc.) - const cleaned = cleanEntityCandidate(name); - if (cleaned.length >= 4 && !isGenericPhrase(cleaned)) { - candidates.add(cleaned); - } - } - - // Pattern 3: Quoted titles (single or double quotes) - // Matches: "Zero to One", 'The Lean Startup' - const quotedPattern = /["']([^"']{3,60})["']/g; - while ((match = quotedPattern.exec(text)) !== null) { - const title = match[1].trim(); - if (title.length >= 3 && !isGenericPhrase(title)) { - candidates.add(title); - } - } - - // Pattern 4: Known organization patterns - // Matches: OpenAI, DeepMind, Y Combinator, Fly.io - const orgPattern = /\b(OpenAI|DeepMind|Anthropic|Google|Microsoft|Meta|Apple|Amazon|Y Combinator|YC|Stripe|Coinbase|Fly\.io|Vercel|Cloudflare)\b/gi; - while ((match = orgPattern.exec(text)) !== null) { - candidates.add(match[1]); - } - - return Array.from(candidates); -} - -/** - * Check if a phrase is too generic to be a useful entity reference. - */ function isGenericPhrase(phrase: string): boolean { const normalized = phrase.toLowerCase(); - - // Common stopwords and generic terms const genericTerms = [ 'the author', 'the article', 'the book', 'the podcast', 'this article', 'this book', 'this podcast', 'this paper', @@ -105,30 +48,58 @@ function isGenericPhrase(phrase: string): boolean { 'united states', 'new york', 'san francisco', 'silicon valley' ]; - return genericTerms.some(term => normalized === term || normalized.startsWith(term + ' ')); + return genericTerms.some(term => normalized === term || normalized.startsWith(`${term} `)); +} + +function extractCandidateEntities(text: string): string[] { + if (!text || typeof text !== 'string') return []; + + const candidates: Set = new Set(); + + const byPattern = /\b[Bb]y\s+([A-Z][a-z]+(?:\s+[A-Z][a-z]+){0,2})\b/g; + let match; + while ((match = byPattern.exec(text)) !== null) { + const name = match[1].trim(); + if (name.length >= 4 && !isGenericPhrase(name)) { + candidates.add(name); + } + } + + const properNamePattern = /\b([A-Z][a-z]+(?:\s+[A-Z][a-z]+){1,3})\b/g; + while ((match = properNamePattern.exec(text)) !== null) { + const cleaned = cleanEntityCandidate(match[1].trim()); + if (cleaned.length >= 4 && !isGenericPhrase(cleaned)) { + candidates.add(cleaned); + } + } + + const quotedPattern = /["']([^"']{3,60})["']/g; + while ((match = quotedPattern.exec(text)) !== null) { + const title = match[1].trim(); + if (title.length >= 3 && !isGenericPhrase(title)) { + candidates.add(title); + } + } + + const orgPattern = /\b(OpenAI|DeepMind|Anthropic|Google|Microsoft|Meta|Apple|Amazon|Y Combinator|YC|Stripe|Coinbase|Fly\.io|Vercel|Cloudflare)\b/gi; + while ((match = orgPattern.exec(text)) !== null) { + candidates.add(match[1]); + } + + return Array.from(candidates); } -/** - * Look up existing nodes that match candidate entity strings. - * Uses exact title matching (case-insensitive). - */ async function findMatchingEntityNodes(candidates: string[]): Promise> { const matches = new Map(); - if (candidates.length === 0) return matches; - // Get all nodes (we'll filter in memory for exact title matches) - // In a larger system, we'd use a more efficient query const allNodes = await nodeService.getNodes({ limit: 10000 }); for (const candidate of candidates) { const normalizedCandidate = candidate.toLowerCase().trim(); - - // Find exact title match (case-insensitive) - const matchingNode = allNodes.find(node => { + const matchingNode = allNodes.find((node) => { const normalizedTitle = (node.title || '').toLowerCase().trim(); - if (normalizedTitle !== normalizedCandidate) return false; - return node.title.length < 80; + return normalizedTitle === normalizedCandidate && node.title.length < 80; }); if (matchingNode) { @@ -139,95 +110,36 @@ async function findMatchingEntityNodes(candidates: string[]): Promise -): Promise { - let edgesCreated = 0; +export async function suggestPotentialEdgesForNode(nodeId: number): Promise { + const node = await nodeService.getNodeById(nodeId); + if (!node) { + console.warn(`[autoEdge] Node ${nodeId} not found, skipping suggestion lookup`); + return []; + } + + const description = node.description || ''; + if (!description || description.length < 10) { + return []; + } + + const candidates = extractCandidateEntities(description); + if (candidates.length === 0) { + return []; + } + + const matches = await findMatchingEntityNodes(candidates); + const suggestions: PotentialEdgeSuggestion[] = []; for (const [candidateText, entityNode] of matches) { - // Skip self-references - if (entityNode.id === newNodeId) continue; - - // Check if edge already exists - const exists = await edgeService.edgeExists(newNodeId, entityNode.id); - if (exists) continue; - - try { - await edgeService.createEdge({ - from_node_id: newNodeId, - to_node_id: entityNode.id, - explanation: `Explicitly mentioned in description: "${candidateText}"`, - created_via: 'quick_capture_auto', - source: 'ai_similarity', - skip_inference: false, // Let Idea Genealogy classify the relationship - }); - edgesCreated++; - console.log(`[autoEdge] Created edge: ${newNodeId} → ${entityNode.id} (${entityNode.title})`); - } catch (error) { - console.warn(`[autoEdge] Failed to create edge to ${entityNode.id}:`, error); - } - } - - return edgesCreated; -} - -/** - * Main entry point: Run auto-edge creation for a newly created node. - * This is designed to be called fire-and-forget (non-blocking). - */ -export async function runAutoEdgeCreation(nodeId: number): Promise { - try { - // Fetch the newly created node - const node = await nodeService.getNodeById(nodeId); - if (!node) { - console.warn(`[autoEdge] Node ${nodeId} not found, skipping auto-edge creation`); - return; - } - - // Use description as the source of truth for entity extraction - const description = node.description || ''; - if (!description || description.length < 10) { - console.log(`[autoEdge] Node ${nodeId} has no/short description, skipping`); - return; - } - - // Extract candidate entities from description - const candidates = extractCandidateEntities(description); - if (candidates.length === 0) { - console.log(`[autoEdge] No entity candidates found in node ${nodeId} description`); - return; - } - - console.log(`[autoEdge] Found ${candidates.length} candidates for node ${nodeId}:`, candidates); - - // Find matching existing nodes - const matches = await findMatchingEntityNodes(candidates); - if (matches.size === 0) { - console.log(`[autoEdge] No matching entity nodes found for node ${nodeId}`); - return; - } - - // Create edges - const edgesCreated = await createAutoEdges(nodeId, matches); - console.log(`[autoEdge] Created ${edgesCreated} auto-edges for node ${nodeId}`); - } catch (error) { - console.error(`[autoEdge] Error in auto-edge creation for node ${nodeId}:`, error); - } -} - -/** - * Schedule auto-edge creation to run asynchronously (fire-and-forget). - * Use this from the nodes API to avoid blocking the response. - */ -export function scheduleAutoEdgeCreation(nodeId: number): void { - setImmediate(() => { - runAutoEdgeCreation(nodeId).catch(error => { - console.error(`[autoEdge] Scheduled auto-edge creation failed for node ${nodeId}:`, error); + if (entityNode.id === nodeId) continue; + suggestions.push({ + from_node_id: nodeId, + to_node_id: entityNode.id, + to_node_title: entityNode.title, + explanation: `Explicitly mentioned in description: "${candidateText}"`, + candidate_text: candidateText, }); - }); + } + + return suggestions; } diff --git a/src/services/agents/quickAdd.ts b/src/services/agents/quickAdd.ts index ea42457..4ab58e0 100644 --- a/src/services/agents/quickAdd.ts +++ b/src/services/agents/quickAdd.ts @@ -234,6 +234,7 @@ async function handleExtractionQuickAdd(type: ExtractionQuickAddType, url: strin }); } catch (error) { const message = error instanceof Error ? error.message : `Failed to execute ${toolName}`; + const capturedAt = new Date().toISOString(); const title = deriveFallbackLinkTitle(url); const description = `Link record for this source. RA-H could not correctly process the URL during ingestion because ${message}. Stored so the source is not lost and can be revisited later.`; @@ -257,10 +258,15 @@ async function handleExtractionQuickAdd(type: ExtractionQuickAddType, url: strin captured_method: 'quick_add_link_fallback', captured_by: 'human', source_metadata: { + capture_origin: 'quick_add', + capture_path: 'quick_add_link_fallback', + explicit_capture: true, + source_url: url, attempted_pipeline: type, extraction_failed: true, extraction_error: message, - refined_at: new Date().toISOString(), + captured_at: capturedAt, + refined_at: capturedAt, }, }, context_id: contextId, @@ -296,6 +302,7 @@ async function handleNoteQuickAdd(rawInput: string, task: string, userDescriptio throw new Error('Input is required to create a note'); } + const capturedAt = new Date().toISOString(); const title = deriveNoteTitle(content); const nodePayload: Record = { title, @@ -307,7 +314,12 @@ async function handleNoteQuickAdd(rawInput: string, task: string, userDescriptio captured_method: 'quick_add_note', captured_by: 'human', source_metadata: { - refined_at: new Date().toISOString(), + capture_origin: 'quick_add', + capture_path: 'quick_add_note', + explicit_capture: true, + input_type: 'note', + captured_at: capturedAt, + refined_at: capturedAt, }, }, }; @@ -351,6 +363,7 @@ async function handleChatTranscriptQuickAdd(rawInput: string, task: string, cont throw new Error('Input is required to import a chat transcript'); } + const capturedAt = new Date().toISOString(); const summaryResult = await summarizeTranscript(transcript); const baseSummary = summaryResult.summary?.trim() || 'Captured chat transcript. Review the raw transcript for full detail.'; @@ -395,7 +408,12 @@ async function handleChatTranscriptQuickAdd(rawInput: string, task: string, cont transcript_length_chars: transcript.length, transcript_length_words: wordCount, transcript_truncated_for_summary: summaryResult.truncated ?? false, - summary_generated_at: new Date().toISOString(), + capture_origin: 'quick_add', + capture_path: 'quick_add_chat', + explicit_capture: true, + input_type: 'chat', + captured_at: capturedAt, + summary_generated_at: capturedAt, }, }; diff --git a/src/services/retrieval/directNodeLookup.ts b/src/services/retrieval/directNodeLookup.ts new file mode 100644 index 0000000..d006cd9 --- /dev/null +++ b/src/services/retrieval/directNodeLookup.ts @@ -0,0 +1,140 @@ +import { contextService } from '@/services/database/contextService'; +import { nodeService } from '@/services/database/nodes'; +import { countHighSignalQueryTermMatches, getHighSignalSearchTerms, scoreNodeSearchMatch } from '@/services/database/searchRanking'; +import type { Node } from '@/types/database'; + +export interface DirectNodeLookupInput { + search?: string; + limit?: number; + context_name?: string; + contextId?: number; + createdAfter?: string; + createdBefore?: string; + eventAfter?: string; + eventBefore?: string; +} + +export interface DirectNodeLookupResult { + nodes: Node[]; + count: number; + filtersApplied: { + search?: string; + limit: number; + context_name?: string; + createdAfter?: string; + createdBefore?: string; + eventAfter?: string; + eventBefore?: string; + }; +} + +function normalizeContextName(value: string | undefined): string | undefined { + if (typeof value !== 'string') return undefined; + const normalized = value.trim().replace(/\s+/g, ' '); + return normalized || undefined; +} + +async function resolveSearchContext(input: DirectNodeLookupInput): Promise<{ contextId?: number; context_name?: string }> { + const normalizedName = normalizeContextName(input.context_name); + if (normalizedName) { + const context = await contextService.getContextByName(normalizedName); + if (!context) { + console.warn(`directNodeLookup received unknown context_name "${normalizedName}"; ignoring context filter.`); + return {}; + } + return { + contextId: context.id, + context_name: context.name, + }; + } + + if (typeof input.contextId === 'number') { + const context = await contextService.getContextById(input.contextId); + if (!context) { + console.warn(`directNodeLookup received invalid legacy contextId ${input.contextId}; ignoring context filter.`); + return {}; + } + return { + contextId: context.id, + context_name: context.name, + }; + } + + return {}; +} + +function hasStrongAnchorMatch(nodes: Node[], searchTerm: string): 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); +} + +export async function directNodeLookup(input: DirectNodeLookupInput): Promise { + const limit = Math.min(Math.max(input.limit ?? 10, 1), 50); + const searchTerm = input.search?.trim(); + + if (searchTerm && /^\d+$/.test(searchTerm)) { + const nodeId = Number(searchTerm); + const node = await nodeService.getNodeById(nodeId); + return { + nodes: node ? [node] : [], + count: node ? 1 : 0, + filtersApplied: { + search: searchTerm, + limit, + }, + }; + } + + const resolvedContext = await resolveSearchContext(input); + const effectiveFilters = { + search: searchTerm, + limit, + contextId: resolvedContext.contextId, + searchMode: 'standard' as const, + createdAfter: input.createdAfter, + createdBefore: input.createdBefore, + eventAfter: input.eventAfter, + eventBefore: input.eventBefore, + }; + + let safeNodes = await nodeService.getNodes(effectiveFilters); + + const hadExtraFilters = Boolean( + effectiveFilters.contextId !== undefined || + effectiveFilters.createdAfter || + effectiveFilters.createdBefore || + effectiveFilters.eventAfter || + effectiveFilters.eventBefore + ); + + if (searchTerm && hadExtraFilters && (safeNodes.length === 0 || !hasStrongAnchorMatch(safeNodes, searchTerm))) { + console.warn(`directNodeLookup falling back to plain literal search for "${searchTerm}" after filtered lookup missed a strong anchor match.`); + safeNodes = await nodeService.searchNodes(searchTerm, limit); + } + + if (searchTerm) { + safeNodes = safeNodes + .map(node => ({ node, score: scoreNodeSearchMatch(node, searchTerm) })) + .sort((a, b) => b.score - a.score || b.node.updated_at.localeCompare(a.node.updated_at)) + .slice(0, limit) + .map(entry => entry.node); + } else { + safeNodes = safeNodes.slice(0, limit); + } + + return { + nodes: safeNodes, + count: safeNodes.length, + filtersApplied: { + search: searchTerm, + limit, + context_name: resolvedContext.context_name, + createdAfter: input.createdAfter, + createdBefore: input.createdBefore, + eventAfter: input.eventAfter, + eventBefore: input.eventBefore, + }, + }; +} diff --git a/src/tools/database/createEdge.ts b/src/tools/database/createEdge.ts index ff456b7..b3c2c23 100644 --- a/src/tools/database/createEdge.ts +++ b/src/tools/database/createEdge.ts @@ -7,7 +7,7 @@ import { validateEdgeExplanation } from '@/services/database/quality'; export const createEdgeTool = tool({ description: - 'Create a relationship between two nodes. Provide an explanation and the system will infer the type and direction.\n\n' + + 'Create a relationship between two nodes only after the user has explicitly confirmed the proposed connection. Use this as the execution step after you surfaced candidate edges in plain language and got a clear yes. Provide an explanation and the system will infer the type and direction.\n\n' + 'Examples of explanations:\n' + '- "Written by" (book → author)\n' + '- "Episode of this podcast" (episode → podcast)\n' + @@ -19,6 +19,9 @@ export const createEdgeTool = tool({ explanation: z.string().describe( 'REQUIRED: Why does this connection exist? The system will infer the relationship type from your explanation.' ), + confirmed_by_user: z.boolean().describe( + 'Must be true. Only create the edge after the user has explicitly approved this proposed relationship.' + ), source: z.enum(['user', 'ai', 'ai_similarity', 'helper_name']).default('ai').describe( 'Source of this edge. Use "ai" for AI-created, "user" for manual, "ai_similarity" for similarity-based.' ) @@ -27,6 +30,14 @@ export const createEdgeTool = tool({ console.log('🔗 CreateEdge tool called with params:', JSON.stringify(params, null, 2)); try { + if (!params.confirmed_by_user) { + return { + success: false, + error: 'createEdge requires explicit user confirmation before writing the relationship.', + data: null, + }; + } + // Validate basic IDs if (!Number.isFinite(params.from_node_id) || params.from_node_id <= 0) { return { diff --git a/src/tools/database/createNode.ts b/src/tools/database/createNode.ts index 5b099fd..d3b0ec8 100644 --- a/src/tools/database/createNode.ts +++ b/src/tools/database/createNode.ts @@ -56,17 +56,16 @@ function inferSourceFromContext(params: { title: string; description?: string; s } export const createNodeTool = tool({ - description: 'Create a node. Set context explicitly only when it is clear and useful; otherwise leave it blank. Focus on a clean title, a strong natural description, preserved source text, and the right metadata. When the node comes from the user\'s own idea, note, or dictated thought, preserve their actual wording in source with only minimal cleanup instead of flattening it into a summary. Do not block creation if the description is incomplete. If the description framing is materially inferred, create the node first and then invite one concise user correction pass.', + description: 'Create a node after you have already decided this should be a net-new write. If the user explicitly asked to save or import something and duplicate/update checks are complete, write immediately. If you are only suggesting a save, propose the node first and wait for confirmation. Leave context blank by default. Only set context when the user explicitly wants one and it is clear and useful; when that happens, use context_name rather than a numeric ID. Focus on a clean title, a strong natural description that says what the thing is, preserved source text, and the right metadata. When the node comes from the user\'s own idea, note, or dictated thought, preserve their actual wording in source with only minimal cleanup instead of flattening it into a summary. Do not block creation if the description is incomplete. If the description framing is materially inferred, create the node first and then invite one concise user correction pass.', inputSchema: z.object({ title: z.string().describe('The title of the node'), description: z.string().max(500).optional().describe('Optional natural description. If you have enough context, describe what this is, why it belongs in Brad\'s graph, and its current workflow status in normal prose. Do not use labels like WHAT:, WHY:, or STATUS:.'), source: z.string().optional().describe('Canonical source content for embedding. For external content, store the actual transcript/article/document text. For user-authored ideas or dictated notes, store the user\'s original wording as fully as possible with only minimal cleanup such as obvious whitespace or transcription artifacts. Do not replace raw user thinking with a thin summary.'), link: z.string().optional().describe('A URL link to the source'), event_date: z.string().optional().describe('When the thing actually happened (ISO 8601). Not when it was added to the graph.'), - context_id: z.number().int().positive().nullable().optional().describe('Optional primary context ID. Use when the node clearly belongs to a known context.'), - context_name: z.string().optional().describe('Optional convenience context name. Resolved to a stable context_id before persistence.'), + context_name: z.string().optional().describe('Optional primary context name. Use only when the user explicitly wants this node assigned to a known context.'), metadata: z.record(z.any()).optional().describe('Optional node metadata. Use canonical keys when known: type, state, captured_method, captured_by, and source_metadata. Source-specific facts belong inside source_metadata.') - }), + }).passthrough(), execute: async (params, context) => { console.log('🎯 CreateNode tool called with params:', JSON.stringify(params, null, 2)); try { @@ -76,7 +75,7 @@ export const createNodeTool = tool({ const response = await fetch(`${getInternalApiBaseUrl()}/api/nodes`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ ...params, source: canonicalSource }) + body: JSON.stringify({ ...params, source: canonicalSource ?? params.source }) }); const result = await response.json(); diff --git a/src/tools/database/queryNodes.ts b/src/tools/database/queryNodes.ts index 599f1da..ec3a963 100644 --- a/src/tools/database/queryNodes.ts +++ b/src/tools/database/queryNodes.ts @@ -1,13 +1,11 @@ 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 { countHighSignalQueryTermMatches, getHighSignalSearchTerms, scoreNodeSearchMatch } from '@/services/database/searchRanking'; +import { directNodeLookup } from '@/services/retrieval/directNodeLookup'; type QueryNodeFilters = { contextId?: number; + context_name?: string; search?: string; limit?: number; createdAfter?: string; @@ -17,128 +15,34 @@ type QueryNodeFilters = { }; export const queryNodesTool = tool({ - 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.', + 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 context blank by default. If the user explicitly wants a context filter, use context_name rather than a numeric ID.', inputSchema: z.object({ filters: z.object({ - contextId: z.number().int().positive().describe('Optional primary context filter.').optional(), + context_name: z.string().describe('Optional primary context name filter. Use only when the user explicitly wants a context-specific lookup.').optional(), search: z.string().describe('Search term to match against node title, description, or source').optional(), limit: z.number().min(1).max(50).default(10).describe('Maximum number of results to return'), createdAfter: z.string().optional().describe('ISO date (YYYY-MM-DD). Only return nodes created on or after this date.'), createdBefore: z.string().optional().describe('ISO date (YYYY-MM-DD). Only return nodes created before this date.'), eventAfter: z.string().optional().describe('ISO date (YYYY-MM-DD). Only return nodes with event_date on or after this date.'), eventBefore: z.string().optional().describe('ISO date (YYYY-MM-DD). Only return nodes with event_date before this date.'), - }).optional() + }).passthrough().optional() }), execute: async ({ filters = {} }: { filters?: QueryNodeFilters }) => { console.log('🔍 QueryNodes tool called with filters:', JSON.stringify(filters, null, 2)); try { - const limit = filters.limit || 10; - - const searchTerm = filters.search?.trim(); - if (searchTerm && /^\d+$/.test(searchTerm)) { - const nodeId = Number(searchTerm); - const node = await nodeService.getNodeById(nodeId); - if (!node) { - return { - success: true, - data: { - nodes: [], - count: 0, - filters_applied: filters, - }, - message: `Found 0 nodes matching id ${nodeId}`, - }; - } - - const formatted = formatNodeForChat({ - id: node.id, - title: node.title, - }); - - return { - success: true, - data: { - nodes: [{ - id: node.id, - title: node.title, - created_at: node.created_at, - updated_at: node.updated_at, - event_date: node.event_date ?? null, - formatted_display: formatted, - }], - count: 1, - filters_applied: filters, - }, - message: `Found 1 node matching id ${nodeId}:\n${formatted}`, - }; - } - - const runQuery = async (queryFilters: typeof filters): Promise => { - const timeoutPromise: Promise = new Promise((_, reject) => { - setTimeout(() => reject(new Error('QueryNodes timeout after 10 seconds')), 10000); - }); - - const nodesPromise: Promise = nodeService.getNodes({ - limit, - contextId: queryFilters.contextId, - search: queryFilters.search, - // Keep queryNodes literal-first. retrieveQueryContext is the broader semantic path. - searchMode: 'standard', - createdAfter: queryFilters.createdAfter, - createdBefore: queryFilters.createdBefore, - eventAfter: queryFilters.eventAfter, - eventBefore: queryFilters.eventBefore, - }); - - const nodes = await Promise.race([nodesPromise, timeoutPromise]); - return Array.isArray(nodes) ? nodes : []; - }; - - 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) })) - .sort((a, b) => b.score - a.score || b.node.updated_at.localeCompare(a.node.updated_at)) - .slice(0, limit) - .map(entry => entry.node); - } - - const limitedNodes = safeNodes.slice(0, limit); + const result = await directNodeLookup({ + search: filters.search, + limit: filters.limit, + context_name: filters.context_name, + contextId: filters.contextId, + createdAfter: filters.createdAfter, + createdBefore: filters.createdBefore, + eventAfter: filters.eventAfter, + eventBefore: filters.eventBefore, + }); // Format nodes for chat display - const formattedNodes = limitedNodes.map(node => { + const formattedNodes = result.nodes.map(node => { const formatted = formatNodeForChat({ id: node.id, title: node.title, @@ -155,14 +59,14 @@ export const queryNodesTool = tool({ // Create message with formatted node labels only (no full node payload) const formattedLabels = formattedNodes.map(node => node.formatted_display).join(', '); - const message = `Found ${safeNodes.length} nodes${effectiveFilters.search ? ` matching: "${effectiveFilters.search}"` : ''}${formattedLabels ? `:\n${formattedLabels}` : ''}`; + const message = `Found ${result.count} nodes${result.filtersApplied.search ? ` matching: "${result.filtersApplied.search}"` : ''}${formattedLabels ? `:\n${formattedLabels}` : ''}`; return { success: true, data: { nodes: formattedNodes, - count: safeNodes.length, - filters_applied: effectiveFilters + count: result.count, + filters_applied: result.filtersApplied }, message: message }; diff --git a/src/tools/database/updateEdge.ts b/src/tools/database/updateEdge.ts index 7ea9aff..89437c8 100644 --- a/src/tools/database/updateEdge.ts +++ b/src/tools/database/updateEdge.ts @@ -4,8 +4,9 @@ import { edgeService } from '@/services/database/edges'; import { validateEdgeExplanation } from '@/services/database/quality'; export const updateEdgeTool = tool({ - description: 'Update an edge explanation and/or source. Explanations must explicitly state the relationship.', + description: 'Update an edge explanation and/or source only after the user explicitly confirmed the corrected relationship. Explanations must explicitly state the relationship.', inputSchema: z.object({ + confirmed_by_user: z.boolean().describe('Must be true. Reject the edge update otherwise.'), edge_id: z.number().describe('The ID of the edge to update'), updates: z.object({ explanation: z.string().optional().describe('Updated relationship explanation'), @@ -17,6 +18,14 @@ export const updateEdgeTool = tool({ console.log('📝 UpdateEdge tool called with params:', JSON.stringify(params, null, 2)); try { + if (!params.confirmed_by_user) { + return { + success: false, + error: 'Edge updates require explicit user confirmation before writing to the graph.', + data: null + }; + } + // Validate that edge exists before updating const existingEdge = await edgeService.getEdgeById(params.edge_id); if (!existingEdge) { diff --git a/src/tools/database/updateNode.ts b/src/tools/database/updateNode.ts index 223cea5..b483fbb 100644 --- a/src/tools/database/updateNode.ts +++ b/src/tools/database/updateNode.ts @@ -3,7 +3,7 @@ import { z } from 'zod'; import { getInternalApiBaseUrl } from '@/services/runtime/apiBase'; export const updateNodeTool = tool({ - description: 'Update node fields. Use this to enrich or correct nodes without losing canonical source content. Context is preserved unless context_id is supplied explicitly. When fixing a user-authored idea node, source should preserve the user\'s original wording as fully as possible. Never block an update because the description is incomplete. If the new description framing is materially inferred, complete the update and then invite one concise user feedback pass.', + description: 'Update node fields when the existing node is clearly the same artifact and a net-new node would be redundant. Explicit user-directed updates should proceed once the target node is clear; if you are only proposing a change, ask first. Use this to enrich or correct nodes without losing canonical source content. Context is preserved unless the user explicitly wants it changed. When that happens, prefer context_name rather than a numeric ID. Use clear_context only when the user explicitly wants the context removed. When fixing a user-authored idea node, source should preserve the user\'s original wording as fully as possible. Never block an update because the description is incomplete. If the new description framing is materially inferred, complete the update and then invite one concise user feedback pass.', inputSchema: z.object({ id: z.number().describe('The ID of the node to update'), updates: z.object({ @@ -12,9 +12,10 @@ export const updateNodeTool = tool({ source: z.string().optional().describe('Canonical source content for embedding. Use this to set or correct the raw source text. For user-authored ideas or dictated notes, preserve the user\'s original wording with only minimal cleanup rather than compressing it into a summary.'), link: z.string().optional().describe('New link'), event_date: z.string().optional().describe('When the thing actually happened (ISO 8601). Not when it was added to the graph.'), - context_id: z.number().int().positive().nullable().optional().describe('Primary context ID. Omit to preserve the existing context. Use null only to clear it intentionally.'), + context_name: z.string().optional().describe('Optional primary context name. Use only when the user explicitly wants to assign this node to a known context.'), + clear_context: z.boolean().optional().describe('Set true only when the user explicitly wants to remove the node context.'), metadata: z.record(z.any()).optional().describe('Metadata patch. It now merges with existing metadata instead of replacing the full blob. Use canonical keys: type, state, captured_method, captured_by, source_metadata.') - }).describe('Object containing the fields to update. Derived analysis should be stored in a separate linked node, not appended to the source node.') + }).passthrough().describe('Object containing the fields to update. Derived analysis should be stored in a separate linked node, not appended to the source node.') }), execute: async ({ id, updates }) => { try { diff --git a/src/tools/database/writeContext.ts b/src/tools/database/writeContext.ts index 8d12fde..6e7231f 100644 --- a/src/tools/database/writeContext.ts +++ b/src/tools/database/writeContext.ts @@ -4,16 +4,16 @@ 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.', + description: 'Write one atomic durable context node to the graph only after the user has explicitly approved the save. Use this for agent-suggested capture after you already proposed the node briefly and got a clear yes. Prefer ordinary create/update flows for explicit user-directed capture. 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.'), + context_name: z.string().optional().describe('Optional primary context name. Use only when the user explicitly wants this saved under a known context.'), 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 }) => { + }).passthrough(), + execute: async ({ title, description, source, context_name, metadata, confirmed_by_user, ...legacyParams }) => { if (!confirmed_by_user) { return { success: false, @@ -30,7 +30,10 @@ export const writeContextTool = tool({ title: title.trim(), description: description.trim(), source: source?.trim() || undefined, - context_id: context_id ?? null, + context_name: context_name?.trim() || undefined, + context_id: typeof legacyParams.context_id === 'number' || legacyParams.context_id === null + ? legacyParams.context_id + : undefined, metadata: { captured_by: 'human', captured_method: 'write_context', diff --git a/src/tools/other/paperExtract.ts b/src/tools/other/paperExtract.ts index bedf7eb..6281347 100644 --- a/src/tools/other/paperExtract.ts +++ b/src/tools/other/paperExtract.ts @@ -157,6 +157,7 @@ export const paperExtractTool = tool({ const nodeTitle = title || result.metadata?.title || `PDF: ${new URL(url).pathname.split('/').pop()?.replace('.pdf', '')}`; const fallbackDescriptionLead = `PDF document titled "${nodeTitle}"`; const finalDescription = ensureNodeDescription(aiAnalysis?.nodeDescription, fallbackDescriptionLead); + const capturedAt = new Date().toISOString(); const createResponse = await fetch(`${getInternalApiBaseUrl()}/api/nodes`, { method: 'POST', @@ -172,13 +173,18 @@ export const paperExtractTool = tool({ captured_method: 'paper_extract', captured_by: 'human', source_metadata: { + capture_origin: 'extraction', + capture_path: 'paper_extract', + explicit_capture: true, + source_url: url, hostname: new URL(url).hostname, author: result.metadata?.author || result.metadata?.info?.Author, pages: result.metadata?.pages, file_size: result.metadata?.file_size, content_length: result.source.length, extraction_method: result.metadata?.extraction_method || 'python_pdfplumber', - refined_at: new Date().toISOString(), + captured_at: capturedAt, + refined_at: capturedAt, } } }) diff --git a/src/tools/other/websiteExtract.ts b/src/tools/other/websiteExtract.ts index c1c8208..9df4a69 100644 --- a/src/tools/other/websiteExtract.ts +++ b/src/tools/other/websiteExtract.ts @@ -162,6 +162,7 @@ export const websiteExtractTool = tool({ const nodeTitle = title || result.metadata?.title || `Website: ${new URL(url).hostname}`; const fallbackDescriptionLead = `${contentType === 'tweet' ? 'Tweet' : 'Website article'} from ${result.metadata?.author || result.metadata?.site_name || new URL(url).hostname} titled "${nodeTitle}"`; const finalDescription = ensureNodeDescription(aiAnalysis?.nodeDescription, fallbackDescriptionLead); + const capturedAt = new Date().toISOString(); const createResponse = await fetch(`${getInternalApiBaseUrl()}/api/nodes`, { method: 'POST', @@ -178,12 +179,17 @@ export const websiteExtractTool = tool({ captured_method: 'website_extract', captured_by: 'human', source_metadata: { + capture_origin: 'extraction', + capture_path: 'website_extract', + explicit_capture: true, + source_url: url, hostname: new URL(url).hostname, author: result.metadata?.author, published_date: result.metadata?.published_date || result.metadata?.date, content_length: result.source.length, extraction_method: result.metadata?.extraction_method || 'python_beautifulsoup', - refined_at: new Date().toISOString(), + captured_at: capturedAt, + refined_at: capturedAt, } } }) diff --git a/src/tools/other/youtubeExtract.ts b/src/tools/other/youtubeExtract.ts index fd7e788..d898db1 100644 --- a/src/tools/other/youtubeExtract.ts +++ b/src/tools/other/youtubeExtract.ts @@ -193,6 +193,7 @@ export const youtubeExtractTool = tool({ const transcriptSummary = await summariseTranscript(nodeTitle, result.source); const fallbackDescriptionLead = `YouTube video from ${result.metadata?.channel_name || 'an unknown channel'} titled "${nodeTitle}"`; const finalDescription = ensureNodeDescription(aiAnalysis?.nodeDescription, fallbackDescriptionLead); + const capturedAt = new Date().toISOString(); const createResponse = await fetch(`${getInternalApiBaseUrl()}/api/nodes`, { method: 'POST', @@ -208,6 +209,10 @@ export const youtubeExtractTool = tool({ captured_method: 'youtube_extract', captured_by: 'human', source_metadata: { + capture_origin: 'extraction', + capture_path: 'youtube_extract', + explicit_capture: true, + source_url: url, video_id: result.metadata?.video_id, channel_name: result.metadata?.channel_name, channel_url: result.metadata?.channel_url, @@ -218,7 +223,8 @@ export const youtubeExtractTool = tool({ extraction_method: result.metadata?.extraction_method, summary_origin: transcriptSummary ? 'transcript_summary' : 'metadata_description', transcript_summary: transcriptSummary, - refined_at: new Date().toISOString(), + captured_at: capturedAt, + refined_at: capturedAt, } } })