diff --git a/README.md b/README.md index 3d1b672..3372aba 100644 --- a/README.md +++ b/README.md @@ -115,13 +115,14 @@ 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, hub nodes, dimensions, available skills). It proactively captures knowledge — when a new insight, decision, person, or reference surfaces, it proposes a specific node (title, dimensions, description) so you can approve with minimal friction. For complex tasks it reads skills to follow your graph conventions and workflows. +**What happens:** Once connected, Claude calls `getContext` first to orient itself (stats, contexts, hub nodes, dimensions, available skills). It proactively captures knowledge — when a new insight, decision, person, or reference surfaces, it proposes a specific node (title, dimensions, description) so you can approve with minimal friction. For complex tasks it reads skills to follow your graph conventions and workflows. Available tools: | Tool | What it does | |------|--------------| -| `getContext` | Get graph overview — stats, hub nodes, dimensions, recent activity | +| `getContext` | Get graph overview — stats, contexts, hub nodes, dimensions, recent activity | +| `queryContexts` | List contexts, inspect a context, or search contexts | | `queryNodes` | Find nodes by keyword | | `createNode` | Create a new node | | `getNodesById` | Fetch nodes by ID | diff --git a/app/api/contexts/[id]/nodes/route.ts b/app/api/contexts/[id]/nodes/route.ts new file mode 100644 index 0000000..20a77a6 --- /dev/null +++ b/app/api/contexts/[id]/nodes/route.ts @@ -0,0 +1,22 @@ +import { NextRequest, NextResponse } from 'next/server'; +import { contextService } from '@/services/database'; + +export const runtime = 'nodejs'; + +export async function GET( + request: NextRequest, + { params }: { params: Promise<{ id: string }> } +) { + try { + const { id } = await params; + const contextId = parseInt(id, 10); + if (Number.isNaN(contextId)) { + return NextResponse.json({ success: false, error: 'Invalid context ID' }, { status: 400 }); + } + + const nodes = await contextService.getNodesForContext(contextId); + return NextResponse.json({ success: true, data: nodes, count: nodes.length }); + } catch (error) { + return NextResponse.json({ success: false, error: error instanceof Error ? error.message : 'Failed to fetch context nodes' }, { status: 500 }); + } +} diff --git a/app/api/contexts/[id]/route.ts b/app/api/contexts/[id]/route.ts new file mode 100644 index 0000000..a633e81 --- /dev/null +++ b/app/api/contexts/[id]/route.ts @@ -0,0 +1,26 @@ +import { NextRequest, NextResponse } from 'next/server'; +import { contextService } from '@/services/database'; + +export const runtime = 'nodejs'; + +export async function GET( + request: NextRequest, + { params }: { params: Promise<{ id: string }> } +) { + try { + const { id } = await params; + const contextId = parseInt(id, 10); + if (Number.isNaN(contextId)) { + return NextResponse.json({ success: false, error: 'Invalid context ID' }, { status: 400 }); + } + + const context = await contextService.getContextById(contextId); + if (!context) { + return NextResponse.json({ success: false, error: 'Context not found' }, { status: 404 }); + } + + return NextResponse.json({ success: true, data: context }); + } catch (error) { + return NextResponse.json({ success: false, error: error instanceof Error ? error.message : 'Failed to fetch context' }, { status: 500 }); + } +} diff --git a/app/api/contexts/route.ts b/app/api/contexts/route.ts new file mode 100644 index 0000000..2969194 --- /dev/null +++ b/app/api/contexts/route.ts @@ -0,0 +1,47 @@ +import { NextRequest, NextResponse } from 'next/server'; +import { contextService } from '@/services/database'; + +export const runtime = 'nodejs'; + +export async function GET() { + try { + const contexts = await contextService.listContexts(); + return NextResponse.json({ success: true, data: contexts }); + } catch (error) { + return NextResponse.json({ success: false, error: error instanceof Error ? error.message : 'Failed to fetch contexts' }, { status: 500 }); + } +} + +export async function POST(request: NextRequest) { + try { + const body = await request.json(); + const context = await contextService.createContext({ + name: body.name, + description: body.description, + icon: body.icon, + }); + return NextResponse.json({ success: true, data: context }, { status: 201 }); + } catch (error) { + return NextResponse.json({ success: false, error: error instanceof Error ? error.message : 'Failed to create context' }, { status: 400 }); + } +} + +export async function PUT(request: NextRequest) { + try { + const body = await request.json(); + if (typeof body.id !== 'number' || !Number.isInteger(body.id) || body.id <= 0) { + return NextResponse.json({ success: false, error: 'Context id is required' }, { status: 400 }); + } + + const context = await contextService.updateContext({ + id: body.id, + name: body.name, + description: body.description, + icon: body.icon, + }); + + return NextResponse.json({ success: true, data: context }); + } catch (error) { + return NextResponse.json({ success: false, error: error instanceof Error ? error.message : 'Failed to update context' }, { status: 400 }); + } +} diff --git a/app/api/nodes/[id]/route.ts b/app/api/nodes/[id]/route.ts index 5116be0..133deeb 100644 --- a/app/api/nodes/[id]/route.ts +++ b/app/api/nodes/[id]/route.ts @@ -1,9 +1,11 @@ import { NextRequest, NextResponse } from 'next/server'; -import { nodeService } from '@/services/database'; +import { contextService, nodeService } from '@/services/database'; import { autoEmbedQueue } from '@/services/embedding/autoEmbedQueue'; import { hasSufficientContent } from '@/services/embedding/constants'; -import { normalizeDimensions, validateExplicitDescription } from '@/services/database/quality'; +import { coerceDescriptionForStorage, normalizeDimensions } from '@/services/database/quality'; import { formatUnknownDimensionsError, getUnknownDimensions } from '@/services/database/dimensionValidation'; +import { normalizeNodeLink } from '@/utils/nodeLink'; +import { mergeNodeMetadata } from '@/services/nodes/metadata'; export const runtime = 'nodejs'; @@ -73,13 +75,23 @@ export async function PUT( const updates: Record = { ...body }; let shouldQueueEmbed = false; - if (typeof body.description === 'string') { - const descriptionError = validateExplicitDescription(body.description); - if (descriptionError) { - console.warn( - `[DescriptionQuality] User-updated description failed validation for node ${nodeId}: ${descriptionError}` - ); + if (typeof body.link === 'string') { + const trimmedLink = body.link.trim(); + const normalizedLink = normalizeNodeLink(trimmedLink); + if (trimmedLink && !normalizedLink) { + return NextResponse.json({ + success: false, + error: 'Invalid link. Use a full URL like https://example.com' + }, { status: 400 }); } + updates.link = normalizedLink ?? null; + } + + if (typeof body.description === 'string') { + updates.description = coerceDescriptionForStorage({ + title: typeof updates.title === 'string' ? updates.title : existingNode.title, + description: body.description + }); } if (Array.isArray(body.dimensions)) { @@ -96,6 +108,24 @@ export async function PUT( delete updates.notes; delete updates.chunk; + if (Object.prototype.hasOwnProperty.call(body, 'context_id') || Object.prototype.hasOwnProperty.call(body, 'context_name')) { + try { + updates.context_id = await contextService.resolveContextId({ + context_id: body.context_id, + context_name: body.context_name, + }); + } catch (error) { + return NextResponse.json({ + success: false, + error: error instanceof Error ? error.message : 'Invalid context input' + }, { status: 400 }); + } + } + + if (body.metadata !== undefined) { + updates.metadata = mergeNodeMetadata(existingNode.metadata, body.metadata); + } + const incomingSource = typeof body.source === 'string' ? body.source : undefined; const existingSource = existingNode.source ?? ''; diff --git a/app/api/nodes/route.ts b/app/api/nodes/route.ts index 69d4fa1..52c400b 100644 --- a/app/api/nodes/route.ts +++ b/app/api/nodes/route.ts @@ -1,11 +1,14 @@ import { NextRequest, NextResponse } from 'next/server'; -import { nodeService } from '@/services/database'; +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 { normalizeDimensions, validateExplicitDescription } from '@/services/database/quality'; +import { coerceDescriptionForStorage, normalizeDimensions } from '@/services/database/quality'; import { formatUnknownDimensionsError, getUnknownDimensions } from '@/services/database/dimensionValidation'; +import { normalizeNodeLink } from '@/utils/nodeLink'; +import { buildCanonicalNodeMetadata } from '@/services/nodes/metadata'; +import { inferBestContextIdForNode } from '@/services/context/contextAssignment'; export const runtime = 'nodejs'; @@ -19,6 +22,14 @@ export async function GET(request: NextRequest) { offset: searchParams.get('offset') ? parseInt(searchParams.get('offset')!) : 0 }; + const contextIdParam = searchParams.get('contextId'); + if (contextIdParam) { + const parsed = parseInt(contextIdParam, 10); + if (!Number.isNaN(parsed)) { + filters.contextId = parsed; + } + } + // Handle dimensions parameter (comma-separated) const dimensionsParam = searchParams.get('dimensions'); if (dimensionsParam) { @@ -94,6 +105,14 @@ export async function POST(request: NextRequest) { body.title = sanitizeTitle(body.title); const rawSource = typeof body.source === 'string' ? body.source.trim() : null; + const rawLink = typeof body.link === 'string' ? body.link : null; + const normalizedLink = normalizeNodeLink(rawLink); + if (rawLink && !normalizedLink) { + return NextResponse.json({ + success: false, + error: 'Invalid link. Use a full URL like https://example.com' + }, { status: 400 }); + } const eventDate = typeof body.event_date === 'string' ? body.event_date : null; // Process provided dimensions first (needed for description generation) @@ -109,7 +128,7 @@ export async function POST(request: NextRequest) { // Use provided description if present, otherwise auto-generate const isUserSuppliedDescription = typeof body.description === 'string' && body.description.trim().length > 0; let nodeDescription: string | undefined = isUserSuppliedDescription - ? body.description.trim().slice(0, 280) + ? body.description.trim().slice(0, 500) : undefined; if (!nodeDescription) { @@ -117,7 +136,7 @@ export async function POST(request: NextRequest) { nodeDescription = await generateDescription({ title: body.title, source: rawSource?.slice(0, 2000) || undefined, - link: body.link || undefined, + link: normalizedLink || undefined, metadata: body.metadata, dimensions: trimmedProvidedDimensions }); @@ -128,28 +147,16 @@ export async function POST(request: NextRequest) { // Final safety net — never store null/empty description if (!nodeDescription || nodeDescription.trim().length === 0) { - nodeDescription = body.title.slice(0, 280); + nodeDescription = `${body.title}. Added via Quick Add with no further context yet, so the reason it belongs in the graph is not fully inferred. It has not been reviewed yet.`.slice(0, 500); } - let finalDescription = nodeDescription ?? body.title.slice(0, 280); - - const descriptionError = validateExplicitDescription(finalDescription); - if (descriptionError) { - if (isUserSuppliedDescription) { - return NextResponse.json({ - success: false, - error: descriptionError - }, { status: 400 }); - } - - console.warn( - `[DescriptionQuality] Auto-generated description failed validation for "${body.title}": ${descriptionError}. Falling back to title.` - ); - finalDescription = body.title.slice(0, 280); - } + const finalDescription = coerceDescriptionForStorage({ + title: body.title, + description: nodeDescription + }); // Monitor description quality - if (WEAK_PATTERNS.test(finalDescription)) { + if (WEAK_PATTERNS.test(nodeDescription ?? finalDescription)) { console.warn(`[DescriptionQuality] Weak description for node "${body.title}": "${finalDescription}"`); } @@ -162,15 +169,52 @@ export async function POST(request: NextRequest) { chunkStatus = 'not_chunked'; } + let resolvedContextId: number | null | undefined; + try { + resolvedContextId = await contextService.resolveContextId({ + context_id: body.context_id, + context_name: body.context_name, + }); + } catch (error) { + console.warn('[nodes.create] Invalid explicit context input, falling back to inheritance/inference:', error); + resolvedContextId = undefined; + } + + if (resolvedContextId === undefined && typeof body.active_context_id === 'number' && Number.isInteger(body.active_context_id) && body.active_context_id > 0) { + const inherited = await contextService.getContextById(body.active_context_id); + if (inherited) { + resolvedContextId = inherited.id; + } + } + + if (resolvedContextId == null) { + resolvedContextId = await inferBestContextIdForNode({ + title: body.title, + description: finalDescription, + source: sourceToStore, + dimensions: finalDimensions, + metadata: body.metadata, + }); + } + const node = await nodeService.createNode({ title: body.title, description: finalDescription, source: sourceToStore ?? undefined, event_date: eventDate ?? undefined, - link: body.link, + link: normalizedLink ?? undefined, dimensions: finalDimensions, chunk_status: chunkStatus, - metadata: body.metadata || {} + context_id: resolvedContextId, + metadata: buildCanonicalNodeMetadata({ + metadata: body.metadata || {}, + type: typeof body.metadata?.type === 'string' + ? body.metadata.type + : typeof body.metadata?.source === 'string' + ? body.metadata.source + : undefined, + state: body.metadata?.state === 'processed' ? 'processed' : 'not_processed', + }) }); if (chunkStatus === 'not_chunked' && node.id) { diff --git a/apps/mcp-server-standalone/README.md b/apps/mcp-server-standalone/README.md index c464c8f..3bdfde9 100644 --- a/apps/mcp-server-standalone/README.md +++ b/apps/mcp-server-standalone/README.md @@ -41,7 +41,7 @@ Restart Claude. Done. ## What to Expect Once connected, Claude will: -- **Call `getContext` first** to orient itself (stats, hub nodes, dimensions, skills) +- **Call `getContext` first** to orient itself (stats, contexts, hub nodes, dimensions, skills) - **Proactively capture knowledge** — when a new insight, decision, person, or reference surfaces, it proposes a specific node (title, dimensions, description) so you can approve with minimal friction - **Read skills for complex tasks** — skills are editable and shared across internal + external agents - **Search before creating** to avoid duplicates @@ -50,9 +50,10 @@ Once connected, Claude will: | Tool | Description | |------|-------------| -| `getContext` | Get graph overview — stats, hub nodes, dimensions, recent activity | +| `getContext` | Get graph overview — stats, contexts, hub nodes, dimensions, recent activity | | `createNode` | Create a new node | | `queryNodes` | Search nodes by keyword | +| `queryContexts` | List or inspect contexts | | `getNodesById` | Load nodes by ID (includes chunk + metadata) | | `updateNode` | Update an existing node | | `createEdge` | Create connection between nodes | diff --git a/apps/mcp-server-standalone/index.js b/apps/mcp-server-standalone/index.js index 4562bdd..2efc4da 100644 --- a/apps/mcp-server-standalone/index.js +++ b/apps/mcp-server-standalone/index.js @@ -31,13 +31,14 @@ const { StdioServerTransport } = require('@modelcontextprotocol/sdk/server/stdio const { initDatabase, getDatabasePath, closeDatabase, getDb, query } = require('./services/sqlite-client'); const nodeService = require('./services/nodeService'); const edgeService = require('./services/edgeService'); +const contextService = require('./services/contextService'); const dimensionService = require('./services/dimensionService'); const skillService = require('./services/skillService'); // Server info const serverInfo = { name: 'ra-h-standalone', - version: '1.8.0' + version: '1.10.1' }; function buildInstructions() { @@ -58,7 +59,7 @@ function buildInstructions() { return `Today's date: ${now}. RA-H is the user's personal knowledge graph — local SQLite, fully on-device. ## Quick start -1. Call getContext for orientation (stats, hubs, dimensions). +1. Call getContext for orientation (stats, contexts, dimensions, anchors/hubs). 2. For simple tasks, tool descriptions have everything you need. 3. For complex tasks, call readSkill("db-operations"). @@ -69,6 +70,7 @@ Always search before creating to avoid duplicates. Use only existing dimensions returned by getContext or queryDimensions. Do not invent new dimensions from node titles, concepts, or phrasing. Only call createDimension when the user explicitly instructs you to create a new dimension. +Use contexts as the primary scope layer. Query contexts before assigning when needed. ## Available skills ${skillIndex} @@ -83,15 +85,18 @@ const addNodeInputSchema = { content: z.string().max(20000).optional().describe('Legacy content field; mapped to source'), source: z.string().max(50000).optional().describe('Full source text'), link: z.string().url().optional().describe('Source URL'), - description: z.string().min(24).max(280).describe('REQUIRED. One-sentence summary: WHAT this is (explicit, concrete) + WHY it matters. No weak verbs (discusses, explores, examines). Example: "Podcast — Lex Fridman interviews Sam Altman on AGI timelines. First public comments since board drama."'), + 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.'), + context_name: z.string().optional().describe('Optional convenience context name.'), dimensions: z.array(z.string()).min(1).max(5).describe('1-5 existing categories. Call queryDimensions first to use existing ones. Do not invent new dimensions.'), - metadata: z.record(z.any()).optional().describe('Additional metadata'), + 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('Full 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.'), dimensions: z.array(z.string()).max(5).optional().describe('Filter by dimensions'), 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.'), @@ -107,9 +112,11 @@ const updateNodeInputSchema = { id: z.number().int().positive().describe('Node ID'), updates: z.object({ title: z.string().optional().describe('New title'), - description: z.string().min(24).max(280).describe('REQUIRED. Explicitly state WHAT this is (podcast, conversation summary, user insight, etc.) + WHY it matters for context grounding. No vague verbs like "discusses/explores/examines".'), + description: z.string().optional().describe('Recommended replacement description. Keep it as natural prose that says what this artifact is and any surrounding context available. RA-H will accept whatever description is provided and will not block the write.'), content: z.string().optional().describe('Content to APPEND'), + 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('Primary context ID. Omit to preserve existing context; use null to clear it.'), dimensions: z.array(z.string()).optional().describe('New dimensions (replaces existing)'), metadata: z.record(z.any()).optional().describe('New metadata') }).describe('Fields to update') @@ -131,6 +138,14 @@ const queryEdgesInputSchema = { limit: z.number().min(1).max(50).optional().describe('Max edges (default 25)') }; +const queryContextsInputSchema = { + contextId: z.number().int().positive().optional().describe('Exact context ID lookup'), + name: z.string().optional().describe('Exact context name lookup'), + search: z.string().optional().describe('Case-insensitive search across context names and descriptions'), + limit: z.number().min(1).max(100).optional().describe('Maximum number of contexts to return'), + includeNodes: z.boolean().optional().describe('Include nodes for an exact single-context lookup') +}; + const listDimensionsInputSchema = {}; const createDimensionInputSchema = { @@ -192,26 +207,6 @@ function sanitizeDimensions(raw) { return result; } -function validateExplicitDescription(description) { - if (typeof description !== 'string') { - return 'Description is required and must be a string.'; - } - const text = description.trim(); - if (text.length < 24) { - return 'Description must be explicit and substantial (at least 24 characters).'; - } - const weakPatterns = /\b(discusses|explores|examines|talks about|is about|delves into)\b/i; - const explicitEntityPatterns = /\b(article|artifact|book|brief|claim|company|concept|conversation|dataset|decision|dimension|document|episode|essay|event|guide|idea|insight|interview|lesson|link|node|note|paper|person|plan|placeholder|podcast|post|presentation|project|question|record|research|resource|skill|source|status|summary|talk|target|test node|thread|tool|transcript|tweet|update|video|website|workflow)\b/i; - const uncertaintyPatterns = /\b(likely|probably|possibly|appears to be|seems to be|unclear|uncertain)\b/i; - if (weakPatterns.test(text)) { - return 'Description is too vague. State exactly what this is and why it matters.'; - } - if (!explicitEntityPatterns.test(text) && !uncertaintyPatterns.test(text)) { - return 'Description must explicitly identify what this thing is, or state uncertainty explicitly.'; - } - return null; -} - // FTS5 helpers function sanitizeFtsQuery(input) { return input @@ -319,7 +314,7 @@ async function main() { 'getContext', { title: 'Get RA-H context', - description: 'Get knowledge graph overview: stats, hub nodes (most connected), dimensions, recent activity, and available skills. Call this first to orient yourself. For deeper operating policy, follow up with readSkill("db-operations").', + description: 'Get knowledge graph overview: stats, contexts, hub nodes (secondary diagnostics), dimensions, recent activity, and available skills. Call this first to orient yourself. For deeper operating policy, follow up with readSkill("db-operations").', inputSchema: {} }, async () => { @@ -340,7 +335,7 @@ async function main() { }; } - const summary = `Graph: ${context.stats.nodeCount} nodes, ${context.stats.edgeCount} edges, ${context.stats.dimensionCount} dimensions, ${skills.length} skills.`; + const summary = `Graph: ${context.stats.contextCount || 0} contexts, ${context.stats.nodeCount} nodes, ${context.stats.edgeCount} edges, ${context.stats.dimensionCount} dimensions, ${skills.length} skills.`; return { content: [{ type: 'text', text: summary }], structuredContent: context @@ -354,24 +349,28 @@ async function main() { 'createNode', { title: 'Add RA-H node', - description: 'Create a new node. Always search first (queryNodes) to avoid duplicates. Title: max 160 chars, clear and descriptive. Description is REQUIRED and must be explicit about what the thing is and why it matters for contextual grounding. 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. Assign 1-5 existing dimensions — call queryDimensions first to use existing ones. Do not invent new dimensions.', + description: 'Create a new node. Always search first (queryNodes) to avoid duplicates. Set context explicitly when clear; otherwise RA-H will infer the best-fit context automatically on create. Title: max 160 chars, clear and descriptive. Description is REQUIRED and should be natural prose that makes clear what the thing is, why it belongs in the graph, and workflow status. 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. Assign 1-5 dimensions — call queryDimensions first to use existing ones.', inputSchema: addNodeInputSchema }, - async ({ title, content, source, link, description, dimensions, metadata, chunk }) => { + async ({ title, content, source, link, description, context_id, context_name, dimensions, metadata, chunk }) => { const normalizedDimensions = sanitizeDimensions(dimensions); if (normalizedDimensions.length === 0) { throw new Error('At least one dimension is required.'); } - const descriptionError = validateExplicitDescription(description); - if (descriptionError) { - throw new Error(descriptionError); + let resolvedContextId; + try { + resolvedContextId = contextService.resolveContextId({ context_id, context_name }); + } catch (error) { + log('Warning: invalid explicit context input on createNode; falling back to automatic inference:', error.message); + resolvedContextId = undefined; } const node = nodeService.createNode({ title: title.trim(), source: source?.trim() || chunk?.trim() || content?.trim(), link: link?.trim(), - description: description?.trim(), + description: typeof description === 'string' ? description.trim() : description, + context_id: resolvedContextId, dimensions: normalizedDimensions, metadata: metadata || {} }); @@ -397,12 +396,41 @@ async function main() { description: 'Search nodes by keyword across title, description, and source fields. Multi-word queries find nodes containing all words (not exact phrases). Returns up to 25 results (default 10). Call before creating nodes to check for duplicates. Optionally filter by dimensions. NOT for searching source documents (transcripts, articles) — use searchContentEmbeddings for that.', inputSchema: searchNodesInputSchema }, - async ({ query: searchQuery, limit = 10, dimensions, created_after, created_before, event_after, event_before }) => { + async ({ query: searchQuery, limit = 10, contextId, dimensions, created_after, created_before, event_after, event_before }) => { const normalizedDimensions = sanitizeDimensions(dimensions || []); const safeLimit = Math.min(Math.max(limit, 1), 25); const trimmedQuery = searchQuery.trim(); const fts = checkFtsAvailability(); + if (contextId) { + const nodes = nodeService.getNodes({ + search: trimmedQuery, + limit: safeLimit, + contextId, + dimensions: normalizedDimensions, + }); + + const summary = nodes.length === 0 + ? 'No matching RA-H nodes found in that context.' + : `Found ${nodes.length} node(s) in that context.`; + + return { + content: [{ type: 'text', text: summary }], + structuredContent: { + count: nodes.length, + nodes: nodes.map((node) => ({ + id: node.id, + title: node.title, + source: node.source ?? null, + description: node.description ?? null, + link: node.link ?? null, + dimensions: node.dimensions || [], + updated_at: node.updated_at, + })), + }, + }; + } + // Build temporal filter clauses const temporalClauses = []; const temporalParams = []; @@ -596,21 +624,13 @@ async function main() { 'updateNode', { title: 'Update RA-H node', - description: 'Update an existing node. Description is REQUIRED on every update and must explicitly state WHAT this thing is + WHY it matters for contextual grounding. Source content lives in "source". Legacy "content" and "chunk" are mapped to source for compatibility. Dimensions are REPLACED entirely with the new array. Title, description, and link are overwritten. Call getNodesById first to verify current state before updating.', + description: 'Update an existing node. Description is REQUIRED on every update and should be natural prose that makes clear what this thing is, why it belongs in the graph, and workflow status. Source content lives in "source". Legacy "content" and "chunk" are mapped to source for compatibility. Dimensions are REPLACED entirely with the new array. Title, description, and link are overwritten. Call getNodesById first to verify current state before updating.', inputSchema: updateNodeInputSchema }, async ({ id, updates }) => { if (!updates || Object.keys(updates).length === 0) { throw new Error('At least one field must be provided in updates.'); } - if (!updates.description) { - throw new Error('Every node update requires an explicit description (WHAT this is + WHY it matters).'); - } - const descriptionError = validateExplicitDescription(updates.description); - if (descriptionError) { - throw new Error(descriptionError); - } - // Map MCP legacy fields to canonical source const mappedUpdates = { ...updates }; if (mappedUpdates.content !== undefined) { @@ -621,6 +641,11 @@ async function main() { } delete mappedUpdates.content; delete mappedUpdates.chunk; + if (Object.prototype.hasOwnProperty.call(mappedUpdates, 'description')) { + mappedUpdates.description = typeof mappedUpdates.description === 'string' + ? mappedUpdates.description.trim() + : mappedUpdates.description; + } const node = nodeService.updateNode(id, mappedUpdates); @@ -735,6 +760,55 @@ async function main() { } ); + registerToolWithAliases( + 'queryContexts', + { + title: 'Query RA-H contexts', + description: 'List contexts, inspect a specific context, or search contexts by name/description.', + inputSchema: queryContextsInputSchema + }, + async ({ contextId, name, search, limit = 50, includeNodes = false }) => { + const normalizedName = typeof name === 'string' ? name.trim().toLowerCase() : ''; + let contexts = []; + + if (contextId) { + const context = contextService.getContextById(contextId); + if (context) { + contexts = [context]; + } + } else if (normalizedName) { + const context = contextService.getContextByName(normalizedName); + if (context) { + contexts = [context]; + } + } else { + const all = contextService.listContexts(); + contexts = all.filter((context) => { + if (search) { + const haystack = `${context.name || ''} ${context.description || ''}`.toLowerCase(); + return haystack.includes(search.trim().toLowerCase()); + } + return true; + }).slice(0, Math.min(Math.max(limit, 1), 100)); + } + + const includeContextNodes = includeNodes && contexts.length === 1 && (contextId || normalizedName); + const enriched = contexts.map((context) => { + if (!includeContextNodes) return context; + const nodes = nodeService.getNodes({ contextId: context.id, limit: 500 }); + return { ...context, nodes }; + }); + + return { + content: [{ type: 'text', text: enriched.length === 0 ? 'No matching contexts found.' : `Found ${enriched.length} context(s).` }], + structuredContent: { + count: enriched.length, + contexts: enriched + } + }; + } + ); + registerToolWithAliases( 'createDimension', { diff --git a/apps/mcp-server-standalone/package-lock.json b/apps/mcp-server-standalone/package-lock.json index cd460c4..539ad59 100644 --- a/apps/mcp-server-standalone/package-lock.json +++ b/apps/mcp-server-standalone/package-lock.json @@ -1,12 +1,12 @@ { "name": "@ra-h/mcp-server", - "version": "1.8.0", + "version": "1.10.1", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@ra-h/mcp-server", - "version": "1.8.0", + "version": "1.10.1", "license": "MIT", "dependencies": { "@modelcontextprotocol/sdk": "^1.0.0", diff --git a/apps/mcp-server-standalone/package.json b/apps/mcp-server-standalone/package.json index cfae7af..0bd5c3f 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.8.0", + "version": "1.10.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/contextService.js b/apps/mcp-server-standalone/services/contextService.js new file mode 100644 index 0000000..575ebc1 --- /dev/null +++ b/apps/mcp-server-standalone/services/contextService.js @@ -0,0 +1,135 @@ +'use strict'; + +const { getDb } = require('./sqlite-client'); + +function mapContext(row) { + if (!row) return null; + return { + id: row.id, + name: row.name, + description: row.description ?? null, + icon: row.icon ?? null, + count: Number(row.count ?? 0), + }; +} + +function listContexts() { + const db = getDb(); + const rows = db.prepare(` + SELECT c.id, c.name, c.description, c.icon, COUNT(n.id) AS count + FROM contexts c + LEFT JOIN nodes n ON n.context_id = c.id + GROUP BY c.id + ORDER BY c.name COLLATE NOCASE ASC + `).all(); + return rows.map(mapContext); +} + +function getContextById(id) { + const db = getDb(); + const row = db.prepare(` + SELECT c.id, c.name, c.description, c.icon, COUNT(n.id) AS count + FROM contexts c + LEFT JOIN nodes n ON n.context_id = c.id + WHERE c.id = ? + GROUP BY c.id + `).get(id); + return mapContext(row); +} + +function getContextByName(name) { + const trimmed = typeof name === 'string' ? name.trim() : ''; + if (!trimmed) return null; + const db = getDb(); + const row = db.prepare(` + SELECT c.id, c.name, c.description, c.icon, COUNT(n.id) AS count + FROM contexts c + LEFT JOIN nodes n ON n.context_id = c.id + WHERE lower(c.name) = lower(?) + GROUP BY c.id + `).get(trimmed); + return mapContext(row); +} + +function createContext({ name, description = null, icon = null }) { + const db = getDb(); + const trimmedName = typeof name === 'string' ? name.trim() : ''; + if (!trimmedName) { + throw new Error('Context name is required.'); + } + + const now = new Date().toISOString(); + const info = db.prepare(` + INSERT INTO contexts (name, description, icon, created_at, updated_at) + VALUES (?, ?, ?, ?, ?) + `).run(trimmedName, description ?? null, icon ?? null, now, now); + + return getContextById(Number(info.lastInsertRowid)); +} + +function updateContext({ id, name, description, icon }) { + const db = getDb(); + const existing = getContextById(id); + if (!existing) { + throw new Error(`Context ${id} not found.`); + } + + const nextName = typeof name === 'string' && name.trim() ? name.trim() : existing.name; + const nextDescription = description === undefined ? existing.description : description; + const nextIcon = icon === undefined ? existing.icon : icon; + const now = new Date().toISOString(); + + db.prepare(` + UPDATE contexts + SET name = ?, description = ?, icon = ?, updated_at = ? + WHERE id = ? + `).run(nextName, nextDescription ?? null, nextIcon ?? null, now, id); + + return getContextById(id); +} + +function resolveContextId(input = {}) { + const hasContextId = Object.prototype.hasOwnProperty.call(input, 'context_id'); + const hasContextName = typeof input.context_name === 'string' && input.context_name.trim().length > 0; + + if (!hasContextId && !hasContextName) { + return undefined; + } + + if (hasContextId && input.context_id === null) { + if (hasContextName) { + throw new Error('context_name cannot be combined with context_id: null.'); + } + return null; + } + + let resolvedById = null; + if (hasContextId) { + resolvedById = getContextById(input.context_id); + if (!resolvedById) { + throw new Error(`Context ${input.context_id} not found.`); + } + } + + if (!hasContextName) { + return resolvedById ? resolvedById.id : undefined; + } + + const byName = getContextByName(input.context_name); + if (!byName) { + throw new Error(`Context "${input.context_name}" not found.`); + } + if (resolvedById && resolvedById.id !== byName.id) { + throw new Error('context_id and context_name refer to different contexts.'); + } + return byName.id; +} + +module.exports = { + listContexts, + getContextById, + getContextByName, + createContext, + updateContext, + resolveContextId, +}; diff --git a/apps/mcp-server-standalone/services/nodeService.js b/apps/mcp-server-standalone/services/nodeService.js index ac4ddb7..800b968 100644 --- a/apps/mcp-server-standalone/services/nodeService.js +++ b/apps/mcp-server-standalone/services/nodeService.js @@ -1,47 +1,83 @@ 'use strict'; const { query, transaction, getDb } = require('./sqlite-client'); +const contextService = require('./contextService'); -function normalizeDimensionName(value) { - return String(value || '').trim().replace(/\s+/g, ' '); -} - -function getUnknownDimensions(dimensions) { - if (!Array.isArray(dimensions) || dimensions.length === 0) return []; - - const normalized = dimensions - .map(normalizeDimensionName) - .filter(Boolean); - - if (normalized.length === 0) return []; - - const placeholders = normalized.map(() => '?').join(', '); - const rows = query(`SELECT name FROM dimensions WHERE name IN (${placeholders})`, normalized); - const existing = new Set(rows.map(row => normalizeDimensionName(row.name))); - - return normalized.filter(value => !existing.has(value)); -} - -function formatUnknownDimensionsError(values) { - if (values.length === 1) { - return `Unknown dimension: "${values[0]}". Create it first or use an existing dimension.`; +function parseMetadata(metadata) { + if (!metadata) return {}; + if (typeof metadata === 'string') { + try { + const parsed = JSON.parse(metadata); + return parsed && typeof parsed === 'object' && !Array.isArray(parsed) ? { ...parsed } : {}; + } catch { + return {}; + } } + return metadata && typeof metadata === 'object' && !Array.isArray(metadata) ? { ...metadata } : {}; +} - return `Unknown dimensions: ${values.map(value => `"${value}"`).join(', ')}. Create them first or use existing dimensions.`; +function normalizeString(value) { + if (typeof value !== 'string') return undefined; + const trimmed = value.trim(); + return trimmed.length > 0 ? trimmed : undefined; +} + +function buildCanonicalMetadata({ existing, metadata }) { + const prior = parseMetadata(existing); + const incoming = parseMetadata(metadata); + const sourceMetadata = { + ...(prior.source_metadata && typeof prior.source_metadata === 'object' ? prior.source_metadata : {}), + ...(incoming.source_metadata && typeof incoming.source_metadata === 'object' ? incoming.source_metadata : {}), + }; + + const merged = { + ...prior, + ...incoming, + state: incoming.state === 'processed' ? 'processed' : (prior.state === 'processed' ? 'processed' : 'not_processed'), + captured_by: incoming.captured_by || prior.captured_by || 'human', + source_metadata: sourceMetadata, + }; + + const type = normalizeString(incoming.type) || normalizeString(prior.type); + const capturedMethod = normalizeString(incoming.captured_method) || normalizeString(prior.captured_method); + + if (type) merged.type = type; + else delete merged.type; + + if (capturedMethod) merged.captured_method = capturedMethod; + else delete merged.captured_method; + + return merged; +} + +function mapNodeRow(row) { + return { + ...row, + dimensions: JSON.parse(row.dimensions_json || '[]'), + metadata: row.metadata ? (typeof row.metadata === 'string' ? JSON.parse(row.metadata) : row.metadata) : null, + context: row.context_json ? JSON.parse(row.context_json) : null, + dimensions_json: undefined, + context_json: undefined, + }; } /** * Get nodes with optional filtering. */ function getNodes(filters = {}) { - const { dimensions, search, limit = 100, offset = 0 } = filters; + const { dimensions, search, limit = 100, offset = 0, contextId } = filters; let sql = ` SELECT n.id, n.title, n.description, n.source, n.link, n.event_date, n.metadata, - n.created_at, n.updated_at, + n.created_at, n.updated_at, n.context_id, COALESCE((SELECT JSON_GROUP_ARRAY(d.dimension) - FROM node_dimensions d WHERE d.node_id = n.id), '[]') as dimensions_json + FROM node_dimensions d WHERE d.node_id = n.id), '[]') as dimensions_json, + CASE + WHEN c.id IS NULL THEN NULL + ELSE json_object('id', c.id, 'name', c.name, 'description', c.description, 'icon', c.icon) + END as context_json FROM nodes n + LEFT JOIN contexts c ON c.id = n.context_id WHERE 1=1 `; const params = []; @@ -61,6 +97,10 @@ function getNodes(filters = {}) { sql += ` AND (n.title LIKE ? COLLATE NOCASE OR n.description LIKE ? COLLATE NOCASE OR n.source LIKE ? COLLATE NOCASE)`; params.push(`%${search}%`, `%${search}%`, `%${search}%`); } + if (contextId !== undefined) { + sql += ' AND n.context_id = ?'; + params.push(contextId); + } // Sort by search relevance or updated_at if (search) { @@ -85,12 +125,7 @@ function getNodes(filters = {}) { const rows = query(sql, params); - return rows.map(row => ({ - ...row, - dimensions: JSON.parse(row.dimensions_json || '[]'), - metadata: row.metadata ? (typeof row.metadata === 'string' ? JSON.parse(row.metadata) : row.metadata) : null, - dimensions_json: undefined - })); + return rows.map(mapNodeRow); } /** @@ -99,10 +134,15 @@ function getNodes(filters = {}) { function getNodeById(id) { const sql = ` SELECT n.id, n.title, n.description, n.source, n.link, n.event_date, n.metadata, - n.created_at, n.updated_at, + n.created_at, n.updated_at, n.context_id, COALESCE((SELECT JSON_GROUP_ARRAY(d.dimension) - FROM node_dimensions d WHERE d.node_id = n.id), '[]') as dimensions_json + FROM node_dimensions d WHERE d.node_id = n.id), '[]') as dimensions_json, + CASE + WHEN c.id IS NULL THEN NULL + ELSE json_object('id', c.id, 'name', c.name, 'description', c.description, 'icon', c.icon) + END as context_json FROM nodes n + LEFT JOIN contexts c ON c.id = n.context_id WHERE n.id = ? `; @@ -110,12 +150,7 @@ function getNodeById(id) { if (rows.length === 0) return null; const row = rows[0]; - return { - ...row, - dimensions: JSON.parse(row.dimensions_json || '[]'), - metadata: row.metadata ? (typeof row.metadata === 'string' ? JSON.parse(row.metadata) : row.metadata) : null, - dimensions_json: undefined - }; + return mapNodeRow(row); } /** @@ -129,6 +164,134 @@ function sanitizeTitle(title) { return clean.slice(0, 160); } +const STOP_WORDS = new Set([ + 'a', 'an', 'and', 'are', 'as', 'at', 'be', 'by', 'for', 'from', 'has', 'i', + 'in', 'is', 'it', 'its', 'of', 'on', 'or', 'that', 'the', 'their', 'this', + 'to', 'was', 'with', 'you', 'your' +]); + +function normalizeText(value) { + if (typeof value !== 'string') return ''; + return value.toLowerCase().replace(/[^a-z0-9\s]+/g, ' ').replace(/\s+/g, ' ').trim(); +} + +function tokenize(value) { + return normalizeText(value) + .split(' ') + .map((token) => token.trim()) + .filter((token) => token.length >= 2 && !STOP_WORDS.has(token)); +} + +function uniqueTokens(values) { + return [...new Set(values.flatMap((value) => tokenize(value || '')))]; +} + +function safeStringify(value) { + try { + return JSON.stringify(value ?? {}); + } catch { + return ''; + } +} + +function fetchContextCandidates() { + return query(` + WITH context_counts AS ( + SELECT c.id, c.name, c.description, COUNT(n.id) AS count + FROM contexts c + LEFT JOIN nodes n ON n.context_id = c.id + GROUP BY c.id + ), + ranked_anchors AS ( + SELECT + c.id AS context_id, + n.title AS anchor_title, + n.description AS anchor_description, + ROW_NUMBER() OVER ( + PARTITION BY c.id + ORDER BY COUNT(e.id) DESC, n.updated_at DESC, n.id ASC + ) AS anchor_rank + FROM contexts c + LEFT JOIN nodes n ON n.context_id = c.id + LEFT JOIN edges e ON (e.from_node_id = n.id OR e.to_node_id = n.id) + GROUP BY c.id, n.id + ) + SELECT + cc.id, + cc.name, + cc.description, + cc.count, + ra.anchor_title, + ra.anchor_description + FROM context_counts cc + LEFT JOIN ranked_anchors ra + ON ra.context_id = cc.id + AND ra.anchor_rank = 1 + ORDER BY cc.name COLLATE NOCASE ASC + `).map((row) => ({ + id: Number(row.id), + name: row.name, + description: row.description ?? null, + count: Number(row.count ?? 0), + anchor_title: row.anchor_title ?? null, + anchor_description: row.anchor_description ?? null, + })); +} + +function scoreContextCandidate(candidate, input) { + const titleText = normalizeText(input.title || ''); + const descriptionText = normalizeText(input.description || ''); + const sourceText = normalizeText(String(input.source || '').slice(0, 4000)); + const metadataText = normalizeText(safeStringify(input.metadata)); + const dimensionTokens = uniqueTokens(input.dimensions || []); + const contextName = normalizeText(candidate.name); + const contextNameTokens = tokenize(candidate.name); + const contextDescriptorTokens = uniqueTokens([ + candidate.description, + candidate.anchor_title, + candidate.anchor_description, + ]); + + let score = 0; + if (contextName && (titleText.includes(contextName) || descriptionText.includes(contextName))) score += 80; + if (contextName && sourceText.includes(contextName)) score += 40; + + for (const token of contextNameTokens) { + if (dimensionTokens.includes(token)) score += 30; + if (titleText.includes(token)) score += 16; + if (descriptionText.includes(token)) score += 12; + if (sourceText.includes(token)) score += 6; + if (metadataText.includes(token)) score += 4; + } + + for (const token of contextDescriptorTokens) { + if (dimensionTokens.includes(token)) score += 8; + if (titleText.includes(token)) score += 4; + if (descriptionText.includes(token)) score += 3; + if (sourceText.includes(token)) score += 2; + } + + return score; +} + +function inferBestContextIdForNode(input) { + const contexts = fetchContextCandidates(); + if (contexts.length === 0) return null; + + const ranked = contexts + .map((context) => ({ context, score: scoreContextCandidate(context, input) })) + .sort((a, b) => b.score - a.score || (b.context.count - a.context.count) || a.context.id - b.context.id); + + const best = ranked[0]; + if (!best) return null; + if (best.score > 0) return best.context.id; + + const research = contexts.find((context) => context.name.trim().toLowerCase() === 'research'); + if (research) return research.id; + + return best.context.id; +} + /** * Create a new node. */ @@ -140,26 +303,25 @@ function createNode(nodeData) { link, event_date, dimensions = [], - metadata = {} + metadata = {}, + context_id } = nodeData; const title = sanitizeTitle(rawTitle); - const unknownDimensions = getUnknownDimensions(dimensions); - if (unknownDimensions.length > 0) { - throw new Error(formatUnknownDimensionsError(unknownDimensions)); - } + const canonicalMetadata = buildCanonicalMetadata({ metadata }); const now = new Date().toISOString(); const db = getDb(); - const sourceToStore = source && source.trim() - ? source - : [title, description].filter(Boolean).join('\n\n').trim() || null; + const sourceToStore = source ?? ([title, description].filter(Boolean).join('\n\n').trim() || null); + const effectiveContextId = context_id == null + ? inferBestContextIdForNode({ title, description, source: sourceToStore, dimensions, metadata: canonicalMetadata }) + : context_id; const nodeId = transaction(() => { const stmt = db.prepare(` - INSERT INTO nodes (title, description, source, link, event_date, metadata, created_at, updated_at) - VALUES (?, ?, ?, ?, ?, ?, ?, ?) + INSERT INTO nodes (title, description, source, link, event_date, metadata, context_id, created_at, updated_at) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?) `); const result = stmt.run( @@ -168,7 +330,8 @@ function createNode(nodeData) { sourceToStore, link ?? null, event_date ?? null, - JSON.stringify(metadata), + JSON.stringify(canonicalMetadata), + effectiveContextId ?? null, now, now ); @@ -193,7 +356,6 @@ function createNode(nodeData) { /** * Update an existing node. - * Source-first update path. */ function updateNode(id, updates, options = {}) { const { title, description, source, link, event_date, dimensions, metadata } = updates; @@ -206,12 +368,9 @@ function updateNode(id, updates, options = {}) { throw new Error(`Node with ID ${id} not found. Use rah_search_nodes to find nodes by keyword.`); } - if (Array.isArray(dimensions)) { - const unknownDimensions = getUnknownDimensions(dimensions); - if (unknownDimensions.length > 0) { - throw new Error(formatUnknownDimensionsError(unknownDimensions)); - } - } + const mergedMetadata = metadata !== undefined + ? buildCanonicalMetadata({ existing: existing.metadata, metadata }) + : undefined; transaction(() => { const setFields = []; @@ -237,9 +396,13 @@ function updateNode(id, updates, options = {}) { setFields.push('event_date = ?'); params.push(event_date); } - if (metadata !== undefined) { + if (Object.prototype.hasOwnProperty.call(updates, 'context_id')) { + setFields.push('context_id = ?'); + params.push(updates.context_id ?? null); + } + if (mergedMetadata !== undefined) { setFields.push('metadata = ?'); - params.push(JSON.stringify(metadata)); + params.push(JSON.stringify(mergedMetadata)); } // Always update timestamp @@ -286,7 +449,7 @@ function getNodeCount() { /** * Get knowledge graph context overview. - * Returns stats, hub nodes, dimensions, and recent activity. + * Returns stats, contexts, hub nodes, dimensions, and recent activity. */ function getContext() { const nodeCount = query('SELECT COUNT(*) as count FROM nodes')[0].count; @@ -315,7 +478,8 @@ function getContext() { `); return { - stats: { nodeCount, edgeCount, dimensionCount: dimensions.length }, + stats: { nodeCount, edgeCount, dimensionCount: dimensions.length, contextCount: contextService.listContexts().length }, + contexts: contextService.listContexts(), dimensions, recentNodes, hubNodes diff --git a/apps/mcp-server-standalone/services/skillService.js b/apps/mcp-server-standalone/services/skillService.js index 4f06e5a..dd34ff2 100644 --- a/apps/mcp-server-standalone/services/skillService.js +++ b/apps/mcp-server-standalone/services/skillService.js @@ -26,6 +26,7 @@ const SEEDED_SKILL_IDS = new Set([ 'persona', 'calibration', 'connect', + 'node-context-enrichment', ]); const DEPRECATED_SKILL_IDS = new Set([ diff --git a/apps/mcp-server-standalone/services/sqlite-client.js b/apps/mcp-server-standalone/services/sqlite-client.js index 3668d32..ea04056 100644 --- a/apps/mcp-server-standalone/services/sqlite-client.js +++ b/apps/mcp-server-standalone/services/sqlite-client.js @@ -59,7 +59,9 @@ function initDatabase() { embedding BLOB, embedding_updated_at TEXT, embedding_text TEXT, - chunk_status TEXT DEFAULT 'not_chunked' + chunk_status TEXT DEFAULT 'not_chunked', + context_id INTEGER, + FOREIGN KEY (context_id) REFERENCES contexts(id) ON DELETE SET NULL ); CREATE TABLE IF NOT EXISTS edges ( @@ -93,6 +95,15 @@ function initDatabase() { updated_at TEXT DEFAULT CURRENT_TIMESTAMP ); + CREATE TABLE IF NOT EXISTS contexts ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + name TEXT NOT NULL, + description TEXT NOT NULL DEFAULT '', + icon TEXT, + created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP + ); + -- Seed default dimensions INSERT OR IGNORE INTO dimensions (name, is_priority) VALUES ('research', 1); INSERT OR IGNORE INTO dimensions (name, is_priority) VALUES ('ideas', 1); @@ -143,6 +154,42 @@ function initDatabase() { AND LENGTH(TRIM(chunk)) > 0; `); } + if (!nodeCols.includes('context_id')) { + db.exec('ALTER TABLE nodes ADD COLUMN context_id INTEGER REFERENCES contexts(id) ON DELETE SET NULL;'); + } + + db.exec(` + CREATE TABLE IF NOT EXISTS contexts ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + name TEXT NOT NULL, + description TEXT NOT NULL DEFAULT '', + icon TEXT, + created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP + ); + `); + + const contextCols = db.prepare('PRAGMA table_info(contexts)').all().map(c => c.name); + if (!contextCols.includes('description')) { + db.exec("ALTER TABLE contexts ADD COLUMN description TEXT NOT NULL DEFAULT '';"); + } + if (!contextCols.includes('icon')) { + db.exec('ALTER TABLE contexts ADD COLUMN icon TEXT;'); + } + if (!contextCols.includes('created_at')) { + db.exec("ALTER TABLE contexts ADD COLUMN created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP;"); + } + if (!contextCols.includes('updated_at')) { + db.exec("ALTER TABLE contexts ADD COLUMN updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP;"); + } + db.exec(` + UPDATE contexts + SET description = COALESCE(NULLIF(TRIM(description), ''), name) + WHERE description IS NULL OR LENGTH(TRIM(description)) = 0; + CREATE UNIQUE INDEX IF NOT EXISTS idx_contexts_name_normalized + ON contexts(LOWER(TRIM(name))); + CREATE INDEX IF NOT EXISTS idx_nodes_context_id ON nodes(context_id); + `); const edgeCols = db.prepare('PRAGMA table_info(edges)').all().map(c => c.name); if (!edgeCols.includes('explanation')) { diff --git a/apps/mcp-server-standalone/skills/audit.md b/apps/mcp-server-standalone/skills/audit.md index e257704..3b6d2c5 100644 --- a/apps/mcp-server-standalone/skills/audit.md +++ b/apps/mcp-server-standalone/skills/audit.md @@ -1,9 +1,6 @@ --- name: Audit -description: "Run a structured audit of graph quality, skill quality, and operational consistency." -when_to_use: "User asks for review, QA, cleanup, or governance checks." -when_not_to_use: "Simple one-off write/read requests." -success_criteria: "Findings are prioritized, concrete, and tied to actionable fixes." +description: "Use for structured review, QA, cleanup, or governance checks across graph quality, skill quality, and operational consistency." --- # Audit @@ -27,3 +24,5 @@ success_criteria: "Findings are prioritized, concrete, and tied to actionable fi - Prefer specific evidence over generic commentary. - Propose the smallest high-leverage fixes first. - Separate defects from optional polish. +- Node descriptions must read like natural prose while still making what / why / status clear. +- Flag any node description missing a clear why or status component as a high-priority quality issue. diff --git a/apps/mcp-server-standalone/skills/calibration.md b/apps/mcp-server-standalone/skills/calibration.md index b39e12c..1691127 100644 --- a/apps/mcp-server-standalone/skills/calibration.md +++ b/apps/mcp-server-standalone/skills/calibration.md @@ -3,7 +3,7 @@ name: Calibration description: "Run periodic check-ins to recalibrate goals, projects, preferences, and graph structure." when_to_use: "User asks for a check-in, reset, review, or strategic recalibration." when_not_to_use: "Single isolated question with no strategic update needed." -success_criteria: "Graph reflects current reality: updated hubs, changed priorities, and explicit deltas." +success_criteria: "Graph reflects current reality: updated contexts, changed priorities, and explicit deltas." --- # Calibration @@ -14,7 +14,7 @@ Re-anchor the graph to the user's current state. ## Check-in Sequence -1. Review major hub nodes and active project nodes. +1. Review major contexts, anchor nodes, and active project nodes. 2. Ask what changed: goals, priorities, constraints, beliefs, preferences. 3. Identify stale nodes and missing nodes. 4. Propose precise updates: update existing vs create new. diff --git a/apps/mcp-server-standalone/skills/db-operations.md b/apps/mcp-server-standalone/skills/db-operations.md index 24c265e..91185e3 100644 --- a/apps/mcp-server-standalone/skills/db-operations.md +++ b/apps/mcp-server-standalone/skills/db-operations.md @@ -1,9 +1,6 @@ --- name: DB Operations description: "Use this for all graph read/write operations with strict data quality standards." -when_to_use: "Any request to read, create, update, connect, classify, or traverse graph data." -when_not_to_use: "Pure conversation with no graph interaction needed." -success_criteria: "Writes are explicit and correct; descriptions are concrete; edges and dimensions are high-signal." --- # DB Operations @@ -11,7 +8,7 @@ success_criteria: "Writes are explicit and correct; descriptions are concrete; e ## Core Rules 1. Search before create to avoid duplicates. -2. Every create/update must include an explicit description of WHAT the thing is and WHY it matters. +2. Every create/update should aim for a natural description that makes clear what the thing is and any surrounding context available, but description quality is guidance only. RA-H should never block or rewrite a write because of description quality. 3. Use event dates when known (when it happened, not when saved). 4. Apply dimensions deliberately; prefer existing dimensions over creating noisy new ones. 5. Create edges when relationships are meaningful; edge explanations should read as a sentence. @@ -19,9 +16,10 @@ success_criteria: "Writes are explicit and correct; descriptions are concrete; e ## Write Quality Contract - `title`: clear and specific. -- `description`: concrete object-level description, not vague summaries. -- `notes/content`: extra context, analysis, supporting detail. +- `description`: natural prose, not labels. It should still make what / why / status clear when possible. +- `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 user-authored ideas or dictated notes, preserve the user's original wording with minimal cleanup. - `link`: external source URL only. +- `metadata`: prefer canonical keys `type`, `state`, `captured_method`, `captured_by`, and `source_metadata`. ## Execution Pattern @@ -29,9 +27,11 @@ success_criteria: "Writes are explicit and correct; descriptions are concrete; e 2. Decide: create vs update vs connect. 3. Execute minimum required writes. 4. Verify result reflects user intent exactly. +5. If description framing was materially inferred, complete the write first and then invite one concise user feedback pass instead of blocking creation. ## Do Not - Create duplicate nodes when an update is correct. - Write vague descriptions ("discusses", "explores", "is about"). +- Replace a user's raw idea/source with a thin summary. - Create weak or directionless edges. diff --git a/apps/mcp-server-standalone/skills/node-context-enrichment.md b/apps/mcp-server-standalone/skills/node-context-enrichment.md new file mode 100644 index 0000000..1d330e6 --- /dev/null +++ b/apps/mcp-server-standalone/skills/node-context-enrichment.md @@ -0,0 +1,36 @@ +--- +name: Node Context Enrichment +description: "Use to rewrite thin node descriptions into natural prose that still makes what, why, and status clear, with dimension review and edge suggestions." +--- + +# Node Context Enrichment + +Use this when a node already exists but its description is thin, generic, or missing personal context. + +This skill should not silently rewrite and move on when contextual framing is inferred. If the enrichment depends on interpretation, update the node and then explicitly invite the user to correct or refine the contextual framing. + +## Goal + +Replace weak descriptions with a single clean natural description that captures: + +1. What the artifact literally is +2. Why it is in Brad's graph +3. Status in Brad's workflow + +Also review whether the node needs dimension fixes or obvious edge suggestions. + +## Workflow + +1. Load the node and inspect title, description, source, link, metadata, dimensions, and nearby edges. +2. Search for adjacent context before rewriting. +3. Infer the best available "why" from that context. +4. Rewrite the full description from scratch in natural prose. Do not append to the old text or use labels like WHAT:, WHY:, or STATUS:. +5. Review dimensions. +6. Suggest 1-3 high-signal edges when obvious. +7. Update the node once the description is strong enough to be useful. +8. After the update, tell the user what changed and ask whether they want to refine the important framing: + - what it is + - why it belongs in the graph + - status / current relevance / workflow position + +The user feedback pass is required whenever the enriched "why" or status was inferred rather than directly stated in the node/source. diff --git a/apps/mcp-server-standalone/skills/onboarding.md b/apps/mcp-server-standalone/skills/onboarding.md index 46aa0c3..8355152 100644 --- a/apps/mcp-server-standalone/skills/onboarding.md +++ b/apps/mcp-server-standalone/skills/onboarding.md @@ -3,7 +3,7 @@ name: Onboarding description: "Onboard a new user by mapping goals, projects, worldview, and preferences into a strong initial graph." when_to_use: "New user setup or major reset of account context." when_not_to_use: "User asks for a narrow tactical operation only." -success_criteria: "User has an initial hub-node structure and clear next steps for graph growth." +success_criteria: "User has an initial context structure, anchor candidates, and clear next steps for graph growth." --- # Onboarding @@ -22,13 +22,14 @@ Understand the user deeply enough to bootstrap a useful externalized context gra ## Graph Bootstrap -1. Propose 3-6 hub nodes with clear rationale. -2. Propose starter dimensions that reflect real domains. -3. Create initial edges between hubs and active projects. -4. Confirm with user before writing. +1. Propose 3-6 primary contexts with clear rationale. +2. Identify one strong anchor candidate per context. +3. Propose starter dimensions as secondary metadata and filters. +4. Create initial edges between anchor nodes and active project nodes. +5. Confirm with user before writing. ## Output -- Initial hub map +- Initial context map - Suggested first write actions - Suggested weekly maintenance rhythm diff --git a/apps/mcp-server/server.js b/apps/mcp-server/server.js index ac62e41..38e07df 100644 --- a/apps/mcp-server/server.js +++ b/apps/mcp-server/server.js @@ -44,8 +44,9 @@ let logger = (message) => console.log(`[mcp] ${message}`); const instructions = [ 'RA-H is a personal knowledge graph — local-first, vendor-neutral.', - 'Core concepts: nodes (knowledge units), edges (connections with explanations), dimensions (categories).', - 'Always call rah_get_context first to orient yourself — it returns hub nodes, dimensions, stats, and available guides.', + 'Core concepts: contexts (primary scopes), nodes (knowledge units), edges (connections with explanations), and dimensions (secondary metadata and filters).', + 'Always call rah_get_context first to orient yourself — it returns contexts, hub nodes, dimensions, stats, and available guides.', + 'Use contexts as the primary scope layer. Use rah_query_contexts before assigning or filtering by context when needed.', 'When assigning dimensions, use only existing dimensions returned by rah_get_context or rah_query_dimensions.', 'Do not invent new dimensions from node titles, concepts, or phrasing.', 'Only call rah_create_dimension when the user explicitly instructs you to create a new dimension.', @@ -91,9 +92,11 @@ const addNodeInputSchema = { content: z.string().max(20000).optional(), source: z.string().max(50000).optional(), link: z.string().url().optional(), - description: z.string().max(2000).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(), + context_name: z.string().optional(), dimensions: z.array(z.string()).min(1).max(5), - metadata: z.record(z.any()).optional(), + 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() }; @@ -107,7 +110,16 @@ const addNodeOutputSchema = { const searchNodesInputSchema = { query: z.string().min(1).max(400), limit: z.number().min(1).max(25).optional(), - dimensions: z.array(z.string()).max(5).optional() + dimensions: z.array(z.string()).max(5).optional(), + contextId: z.number().int().positive().optional() +}; + +const queryContextsInputSchema = { + contextId: z.number().int().positive().optional(), + name: z.string().optional(), + search: z.string().optional(), + limit: z.number().min(1).max(100).optional(), + includeNodes: z.boolean().optional() }; const searchNodesOutputSchema = { @@ -130,11 +142,13 @@ const updateNodeInputSchema = { id: z.number().int().positive().describe('The ID of the node to update'), updates: z.object({ title: z.string().optional().describe('New title'), - description: z.string().optional().describe('New description (overwrites existing)'), - content: z.string().optional().describe('Content to APPEND (not replace)'), + 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".'), + 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('Primary context ID. Omit to preserve existing context; use null to clear.'), dimensions: z.array(z.string()).optional().describe('New dimensions (replaces existing)'), - metadata: z.record(z.any()).optional().describe('New metadata (replaces existing)') + 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') }; @@ -269,8 +283,7 @@ const extractUrlInputSchema = { const extractUrlOutputSchema = { success: z.boolean(), title: z.string(), - content: z.string(), - chunk: z.string(), + source: z.string(), metadata: z.record(z.any()) }; @@ -283,7 +296,7 @@ const extractYoutubeOutputSchema = { success: z.boolean(), title: z.string(), channel: z.string(), - transcript: z.string(), + source: z.string(), metadata: z.record(z.any()) }; @@ -295,8 +308,7 @@ const extractPdfInputSchema = { const extractPdfOutputSchema = { success: z.boolean(), title: z.string(), - content: z.string(), - chunk: z.string(), + source: z.string(), metadata: z.record(z.any()) }; @@ -349,11 +361,11 @@ mcpServer.registerTool( 'rah_add_node', { title: 'Add RA-H node', - description: 'Create a new node in the local RA-H knowledge base.', + description: 'Create a new node in the local RA-H knowledge base. Set context explicitly when clear; otherwise RA-H will infer the best-fit context automatically on create. Use only existing dimensions; do not invent new ones.', inputSchema: addNodeInputSchema, outputSchema: addNodeOutputSchema }, - async ({ title, content, source, link, description, dimensions, metadata, chunk }) => { + async ({ title, content, source, link, description, context_id, context_name, dimensions, metadata, chunk }) => { const normalizedDimensions = sanitizeDimensions(dimensions); if (normalizedDimensions.length === 0) { throw new McpError( @@ -364,9 +376,11 @@ mcpServer.registerTool( const payload = { title: title.trim(), - source: source?.trim() || chunk?.trim() || content?.trim() || undefined, + source: source?.trim() || content?.trim() || chunk?.trim() || undefined, link: link?.trim() || undefined, description: description?.trim() || undefined, + context_id: context_id === null ? null : context_id, + context_name: context_name?.trim() || undefined, dimensions: normalizedDimensions, metadata: metadata || {} }; @@ -399,7 +413,7 @@ mcpServer.registerTool( inputSchema: searchNodesInputSchema, outputSchema: searchNodesOutputSchema }, - async ({ query, limit = 10, dimensions }) => { + async ({ query, limit = 10, dimensions, contextId }) => { const params = new URLSearchParams(); params.set('search', query.trim()); params.set('limit', String(Math.min(Math.max(limit, 1), 25))); @@ -408,6 +422,9 @@ mcpServer.registerTool( if (dimensionList.length > 0) { params.set('dimensions', dimensionList.join(',')); } + if (contextId) { + params.set('contextId', String(contextId)); + } const result = await callRaHApi(`/api/nodes?${params.toString()}`, { method: 'GET' @@ -436,11 +453,59 @@ mcpServer.registerTool( } ); +mcpServer.registerTool( + 'rah_query_contexts', + { + title: 'Query RA-H contexts', + description: 'List contexts, inspect a specific context, or search contexts by name/description.', + inputSchema: queryContextsInputSchema + }, + async ({ contextId, name, search, limit = 50, includeNodes = false }) => { + const normalizedName = typeof name === 'string' ? name.trim().toLowerCase() : ''; + let contexts = []; + + if (contextId) { + const result = await callRaHApi(`/api/contexts/${contextId}`, { method: 'GET' }); + if (result?.data) { + contexts = [result.data]; + } + } else { + const result = await callRaHApi('/api/contexts', { method: 'GET' }); + const all = Array.isArray(result.data) ? result.data : []; + contexts = all.filter((context) => { + if (normalizedName && context.name?.trim().toLowerCase() !== normalizedName) { + return false; + } + if (search) { + const haystack = `${context.name || ''} ${context.description || ''}`.toLowerCase(); + return haystack.includes(search.trim().toLowerCase()); + } + return true; + }).slice(0, Math.min(Math.max(limit, 1), 100)); + } + + const includeContextNodes = includeNodes && contexts.length === 1 && (contextId || normalizedName); + const enriched = await Promise.all(contexts.map(async (context) => { + if (!includeContextNodes) return context; + const nodeResult = await callRaHApi(`/api/contexts/${context.id}/nodes`, { method: 'GET' }); + return { ...context, nodes: Array.isArray(nodeResult.data) ? nodeResult.data : [] }; + })); + + return { + content: [{ type: 'text', text: enriched.length === 0 ? 'No matching contexts found.' : `Found ${enriched.length} context(s).` }], + structuredContent: { + count: enriched.length, + contexts: enriched + } + }; + } +); + mcpServer.registerTool( 'rah_update_node', { title: 'Update RA-H node', - description: 'Update an existing node. Content is APPENDED (not replaced). Dimensions are replaced.', + description: 'Update an existing node. Dimensions must be existing canonical dimensions; do not invent new ones.', inputSchema: updateNodeInputSchema, outputSchema: updateNodeOutputSchema }, @@ -449,15 +514,15 @@ mcpServer.registerTool( throw new McpError(ErrorCode.InvalidParams, 'At least one field must be provided in updates.'); } - // Map MCP legacy fields to canonical source + // Backward compatibility: map legacy content/chunk → source const mappedUpdates = { ...updates }; - if (mappedUpdates.content !== undefined) { - mappedUpdates.source = mappedUpdates.content; - } if (mappedUpdates.chunk !== undefined && mappedUpdates.source === undefined) { mappedUpdates.source = mappedUpdates.chunk; } - delete mappedUpdates.content; + if (mappedUpdates.content !== undefined) { + mappedUpdates.source = mappedUpdates.content; + delete mappedUpdates.content; + } delete mappedUpdates.chunk; const result = await callRaHApi(`/api/nodes/${id}`, { @@ -622,7 +687,7 @@ mcpServer.registerTool( 'rah_create_dimension', { title: 'Create RA-H dimension', - description: 'Create a new dimension/tag for organizing nodes.', + description: 'Create a new dimension/tag for organizing nodes only when the user explicitly instructs you to do so.', inputSchema: createDimensionInputSchema, outputSchema: createDimensionOutputSchema }, @@ -760,8 +825,7 @@ mcpServer.registerTool( structuredContent: { success: true, title: result.title || 'Untitled', - content: result.content || '', - chunk: result.chunk || '', + source: result.source || '', metadata: result.metadata || {} } }; @@ -789,7 +853,7 @@ mcpServer.registerTool( success: true, title: result.title || 'Untitled', channel: result.channel || 'Unknown', - transcript: result.transcript || '', + source: result.source || '', metadata: result.metadata || {} } }; @@ -816,8 +880,7 @@ mcpServer.registerTool( structuredContent: { success: true, title: result.title || 'Untitled PDF', - content: result.content || '', - chunk: result.chunk || '', + source: result.source || '', metadata: result.metadata || {} } }; @@ -829,11 +892,12 @@ mcpServer.registerTool( 'rah_get_context', { title: 'Get RA-H context', - description: 'Get orientation context: hub nodes, dimensions, stats, and available guides. Call this first.', + description: 'Get orientation context: contexts, hub nodes, dimensions, stats, and available guides. Call this first.', inputSchema: {}, outputSchema: { - stats: z.object({ nodeCount: z.number(), edgeCount: z.number(), dimensionCount: z.number() }), + stats: z.object({ nodeCount: z.number(), edgeCount: z.number(), dimensionCount: z.number(), contextCount: z.number().optional() }), hubNodes: z.array(z.object({ id: z.number(), title: z.string(), description: z.string().nullable(), edgeCount: z.number() })), + contexts: z.array(z.object({ id: z.number(), name: z.string(), description: z.string().nullable(), icon: z.string().nullable().optional(), count: z.number() })).optional(), dimensions: z.array(z.object({ name: z.string(), nodeCount: z.number(), description: z.string().nullable() })), guides: z.array(z.string()) } @@ -849,18 +913,23 @@ mcpServer.registerTool( name: d.name, nodeCount: d.node_count ?? 0, description: d.description ?? null })) : []; + const contextResult = await callRaHApi('/api/contexts', { method: 'GET' }); + const contexts = Array.isArray(contextResult.data) ? contextResult.data.map(c => ({ + id: c.id, name: c.name, description: c.description ?? null, icon: c.icon ?? null, count: c.count ?? 0 + })) : []; + const guideResult = await callRaHApi('/api/guides', { method: 'GET' }); const guides = Array.isArray(guideResult.data) ? guideResult.data.map(g => g.name) : []; - const stats = { nodeCount: 0, edgeCount: 0, dimensionCount: dimensions.length }; + const stats = { nodeCount: 0, edgeCount: 0, dimensionCount: dimensions.length, contextCount: contexts.length }; try { const countResult = await callRaHApi('/api/nodes?limit=1', { method: 'GET' }); if (countResult.total !== undefined) stats.nodeCount = countResult.total; } catch { /* use defaults */ } return { - content: [{ type: 'text', text: `Knowledge graph: ${stats.dimensionCount} dimensions, ${hubNodes.length} hub nodes. ${guides.length} guides available.` }], - structuredContent: { stats, hubNodes, dimensions, guides } + content: [{ type: 'text', text: `Knowledge graph: ${stats.contextCount} contexts, ${stats.dimensionCount} dimensions, ${hubNodes.length} hub nodes. ${guides.length} guides available.` }], + structuredContent: { stats, hubNodes, contexts, dimensions, guides } }; } ); diff --git a/apps/mcp-server/stdio-server.js b/apps/mcp-server/stdio-server.js index f290dc8..8e71988 100644 --- a/apps/mcp-server/stdio-server.js +++ b/apps/mcp-server/stdio-server.js @@ -11,8 +11,9 @@ const packageJson = require('../../package.json'); const instructions = [ 'RA-H is a personal knowledge graph — local-first, vendor-neutral.', - 'Core concepts: nodes (knowledge units), edges (connections with explanations), dimensions (categories).', - 'Always call rah_get_context first to orient yourself — it returns hub nodes, dimensions, stats, and available guides.', + 'Core concepts: contexts (primary scopes), nodes (knowledge units), edges (connections with explanations), and dimensions (secondary metadata and filters).', + 'Always call rah_get_context first to orient yourself — it returns contexts, hub nodes, dimensions, stats, and available guides.', + 'Use contexts as the primary scope layer. Use rah_query_contexts before assigning or filtering by context when needed.', 'When assigning dimensions, use only existing dimensions returned by rah_get_context or rah_query_dimensions.', 'Do not invent new dimensions from node titles, concepts, or phrasing.', 'Only call rah_create_dimension when the user explicitly instructs you to create a new dimension.', @@ -40,9 +41,11 @@ const addNodeInputSchema = { content: z.string().max(20000).optional(), source: z.string().max(50000).optional(), link: z.string().url().optional(), - description: z.string().max(2000).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(), + context_name: z.string().optional(), dimensions: z.array(z.string()).min(1).max(5), - metadata: z.record(z.any()).optional(), + 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() }; @@ -56,7 +59,16 @@ const addNodeOutputSchema = { const searchNodesInputSchema = { query: z.string().min(1).max(400), limit: z.number().min(1).max(25).optional(), - dimensions: z.array(z.string()).max(5).optional() + dimensions: z.array(z.string()).max(5).optional(), + contextId: z.number().int().positive().optional() +}; + +const queryContextsInputSchema = { + contextId: z.number().int().positive().optional(), + name: z.string().optional(), + search: z.string().optional(), + limit: z.number().min(1).max(100).optional(), + includeNodes: z.boolean().optional() }; const searchNodesOutputSchema = { @@ -65,7 +77,7 @@ const searchNodesOutputSchema = { z.object({ id: z.number(), title: z.string(), - content: z.string().nullable(), + source: z.string().nullable(), description: z.string().nullable(), link: z.string().nullable(), dimensions: z.array(z.string()), @@ -79,10 +91,13 @@ const updateNodeInputSchema = { id: z.number().int().positive().describe('The ID of the node to update'), updates: z.object({ title: z.string().optional().describe('New title'), - content: z.string().optional().describe('Content to APPEND (not replace)'), + 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".'), + 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('Primary context ID. Omit to preserve existing context; use null to clear.'), dimensions: z.array(z.string()).optional().describe('New dimensions (replaces existing)'), - metadata: z.record(z.any()).optional().describe('New metadata (replaces existing)') + 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') }; @@ -103,7 +118,7 @@ const getNodesOutputSchema = { z.object({ id: z.number(), title: z.string(), - content: z.string().nullable(), + source: z.string().nullable(), link: z.string().nullable(), dimensions: z.array(z.string()), updated_at: z.string() @@ -217,8 +232,7 @@ const extractUrlInputSchema = { const extractUrlOutputSchema = { success: z.boolean(), title: z.string(), - content: z.string(), - chunk: z.string(), + source: z.string(), metadata: z.record(z.any()) }; @@ -231,7 +245,7 @@ const extractYoutubeOutputSchema = { success: z.boolean(), title: z.string(), channel: z.string(), - transcript: z.string(), + source: z.string(), metadata: z.record(z.any()) }; @@ -243,8 +257,7 @@ const extractPdfInputSchema = { const extractPdfOutputSchema = { success: z.boolean(), title: z.string(), - content: z.string(), - chunk: z.string(), + source: z.string(), metadata: z.record(z.any()) }; @@ -322,11 +335,11 @@ server.registerTool( 'rah_add_node', { title: 'Add RA-H node', - description: 'Create a new node in the local RA-H knowledge base.', + description: 'Create a new node in the local RA-H knowledge base. Set context explicitly when clear; otherwise RA-H will infer the best-fit context automatically on create. Use only existing dimensions; do not invent new ones.', inputSchema: addNodeInputSchema, outputSchema: addNodeOutputSchema }, - async ({ title, content, source, link, description, dimensions, metadata, chunk }) => { + async ({ title, content, source, link, description, context_id, context_name, dimensions, metadata, chunk }) => { const normalizedDimensions = sanitizeDimensions(dimensions); if (normalizedDimensions.length === 0) { throw new Error('At least one dimension/tag is required when creating a node.'); @@ -334,9 +347,11 @@ server.registerTool( const payload = { title: title.trim(), - source: source?.trim() || chunk?.trim() || content?.trim() || undefined, + source: source?.trim() || content?.trim() || chunk?.trim() || undefined, link: link?.trim() || undefined, description: description?.trim() || undefined, + context_id: context_id === null ? null : context_id, + context_name: context_name?.trim() || undefined, dimensions: normalizedDimensions, metadata: metadata || {} }; @@ -369,7 +384,7 @@ server.registerTool( inputSchema: searchNodesInputSchema, outputSchema: searchNodesOutputSchema }, - async ({ query, limit = 10, dimensions }) => { + async ({ query, limit = 10, dimensions, contextId }) => { const params = new URLSearchParams(); params.set('search', query.trim()); params.set('limit', String(Math.min(Math.max(limit, 1), 25))); @@ -378,6 +393,9 @@ server.registerTool( if (dimensionList.length > 0) { params.set('dimensions', dimensionList.join(',')); } + if (contextId) { + params.set('contextId', String(contextId)); + } const result = await callRaHApi(`/api/nodes?${params.toString()}`, { method: 'GET' @@ -407,11 +425,59 @@ server.registerTool( } ); +server.registerTool( + 'rah_query_contexts', + { + title: 'Query RA-H contexts', + description: 'List contexts, inspect a specific context, or search contexts by name/description.', + inputSchema: queryContextsInputSchema + }, + async ({ contextId, name, search, limit = 50, includeNodes = false }) => { + const normalizedName = typeof name === 'string' ? name.trim().toLowerCase() : ''; + let contexts = []; + + if (contextId) { + const result = await callRaHApi(`/api/contexts/${contextId}`, { method: 'GET' }); + if (result?.data) { + contexts = [result.data]; + } + } else { + const result = await callRaHApi('/api/contexts', { method: 'GET' }); + const all = Array.isArray(result.data) ? result.data : []; + contexts = all.filter((context) => { + if (normalizedName && context.name?.trim().toLowerCase() !== normalizedName) { + return false; + } + if (search) { + const haystack = `${context.name || ''} ${context.description || ''}`.toLowerCase(); + return haystack.includes(search.trim().toLowerCase()); + } + return true; + }).slice(0, Math.min(Math.max(limit, 1), 100)); + } + + const includeContextNodes = includeNodes && contexts.length === 1 && (contextId || normalizedName); + const enriched = await Promise.all(contexts.map(async (context) => { + if (!includeContextNodes) return context; + const nodeResult = await callRaHApi(`/api/contexts/${context.id}/nodes`, { method: 'GET' }); + return { ...context, nodes: Array.isArray(nodeResult.data) ? nodeResult.data : [] }; + })); + + return { + content: [{ type: 'text', text: enriched.length === 0 ? 'No matching contexts found.' : `Found ${enriched.length} context(s).` }], + structuredContent: { + count: enriched.length, + contexts: enriched + } + }; + } +); + server.registerTool( 'rah_update_node', { title: 'Update RA-H node', - description: 'Update an existing node. Content is APPENDED (not replaced). Dimensions are replaced.', + description: 'Update an existing node. Dimensions must be existing canonical dimensions; do not invent new ones.', inputSchema: updateNodeInputSchema, outputSchema: updateNodeOutputSchema }, @@ -420,15 +486,15 @@ server.registerTool( throw new Error('At least one field must be provided in updates.'); } - // Map MCP legacy fields to canonical source + // Backward compatibility: map legacy content/chunk → source const mappedUpdates = { ...updates }; - if (mappedUpdates.content !== undefined) { - mappedUpdates.source = mappedUpdates.content; - } if (mappedUpdates.chunk !== undefined && mappedUpdates.source === undefined) { mappedUpdates.source = mappedUpdates.chunk; } - delete mappedUpdates.content; + if (mappedUpdates.content !== undefined) { + mappedUpdates.source = mappedUpdates.content; + delete mappedUpdates.content; + } delete mappedUpdates.chunk; const result = await callRaHApi(`/api/nodes/${id}`, { @@ -593,7 +659,7 @@ server.registerTool( 'rah_create_dimension', { title: 'Create RA-H dimension', - description: 'Create a new dimension/tag for organizing nodes.', + description: 'Create a new dimension/tag for organizing nodes only when the user explicitly instructs you to do so.', inputSchema: createDimensionInputSchema, outputSchema: createDimensionOutputSchema }, @@ -731,8 +797,7 @@ server.registerTool( structuredContent: { success: true, title: result.title || 'Untitled', - content: result.content || '', - chunk: result.chunk || '', + source: result.source || '', metadata: result.metadata || {} } }; @@ -760,7 +825,7 @@ server.registerTool( success: true, title: result.title || 'Untitled', channel: result.channel || 'Unknown', - transcript: result.transcript || '', + source: result.source || '', metadata: result.metadata || {} } }; @@ -787,8 +852,7 @@ server.registerTool( structuredContent: { success: true, title: result.title || 'Untitled PDF', - content: result.content || '', - chunk: result.chunk || '', + source: result.source || '', metadata: result.metadata || {} } }; @@ -797,10 +861,11 @@ server.registerTool( // rah_get_context — orientation tool for external agents const getContextOutputSchema = { - schema: z.object({ + stats: z.object({ nodeCount: z.number(), edgeCount: z.number(), - dimensionCount: z.number() + dimensionCount: z.number(), + contextCount: z.number().optional() }), hubNodes: z.array(z.object({ id: z.number(), @@ -808,6 +873,13 @@ const getContextOutputSchema = { description: z.string().nullable(), edgeCount: z.number() })), + contexts: z.array(z.object({ + id: z.number(), + name: z.string(), + description: z.string().nullable(), + icon: z.string().nullable().optional(), + count: z.number() + })).optional(), dimensions: z.array(z.object({ name: z.string(), nodeCount: z.number(), @@ -820,7 +892,7 @@ server.registerTool( 'rah_get_context', { title: 'Get RA-H context', - description: 'Get orientation context: hub nodes, dimensions, stats, and available guides. Call this first.', + description: 'Get orientation context: contexts, hub nodes, dimensions, stats, and available guides. Call this first.', inputSchema: {}, outputSchema: getContextOutputSchema }, @@ -842,6 +914,15 @@ server.registerTool( description: d.description ?? null })) : []; + const contextResult = await callRaHApi('/api/contexts', { method: 'GET' }); + const contexts = Array.isArray(contextResult.data) ? contextResult.data.map(c => ({ + id: c.id, + name: c.name, + description: c.description ?? null, + icon: c.icon ?? null, + count: c.count ?? 0 + })) : []; + // Fetch guides const guideResult = await callRaHApi('/api/guides', { method: 'GET' }); const guides = Array.isArray(guideResult.data) ? guideResult.data.map(g => g.name) : []; @@ -851,7 +932,8 @@ server.registerTool( const stats = { nodeCount: nodeCount ?? hubNodes.reduce((_, n) => 0, 0), edgeCount: 0, - dimensionCount: dimensions.length + dimensionCount: dimensions.length, + contextCount: contexts.length }; // Try to get actual counts from a stats endpoint or compute @@ -862,13 +944,14 @@ server.registerTool( } } catch { /* use defaults */ } - const summary = `Knowledge graph: ${stats.dimensionCount} dimensions, ${hubNodes.length} hub nodes. ${guides.length} guides available.`; + const summary = `Knowledge graph: ${stats.contextCount} contexts, ${stats.dimensionCount} dimensions, ${hubNodes.length} hub nodes. ${guides.length} guides available.`; return { content: [{ type: 'text', text: summary }], structuredContent: { - schema: stats, + stats, hubNodes, + contexts, dimensions, guides } diff --git a/docs/4_tools-and-guides.md b/docs/4_tools-and-guides.md index 6443c22..3d5bb89 100644 --- a/docs/4_tools-and-guides.md +++ b/docs/4_tools-and-guides.md @@ -14,7 +14,7 @@ RA-OS exposes these core standalone MCP tools: | Tool | Description | |------|-------------| -| `getContext` | Graph overview: stats, hub nodes, dimensions, recent activity, skills | +| `getContext` | Graph overview: stats, contexts, hub nodes, dimensions, recent activity, skills | | `queryNodes` | Search nodes by keyword/dimensions/date | | `getNodesById` | Fetch full nodes by ID | | `createNode` | Create a node | diff --git a/docs/8_mcp.md b/docs/8_mcp.md index b746529..981b461 100644 --- a/docs/8_mcp.md +++ b/docs/8_mcp.md @@ -76,7 +76,8 @@ If you want real-time UI updates when nodes are created: | Tool | Description | |------|-------------| -| `getContext` | Get graph overview — stats, hub nodes, dimensions, recent activity. Called first automatically. | +| `getContext` | Get graph overview — stats, contexts, hub nodes, dimensions, recent activity. Called first automatically. | +| `queryContexts` | List contexts, inspect a context, or search contexts by name/description. | | `createNode` | Create a new node (title/source/dimensions) | | `queryNodes` | Search existing nodes by keyword | | `updateNode` | Update an existing node | @@ -101,7 +102,7 @@ If you want real-time UI updates when nodes are created: Once connected, the MCP server instructs Claude to: -1. **Call `getContext` first** to orient itself (hub nodes, dimensions, stats, available skills) +1. **Call `getContext` first** to orient itself (contexts, hub nodes, dimensions, stats, available skills) 2. **Proactively capture knowledge** — when a new insight, decision, person, or reference surfaces, it proposes a specific node (title, dimensions, description) so you can approve with minimal friction 3. **Read skills for complex tasks** — skills provide reusable procedural instructions for graph operations and workflows 4. **Search before creating** to avoid duplicates diff --git a/src/components/focus/FocusPanel.tsx b/src/components/focus/FocusPanel.tsx index 7ee9a5c..e223d5e 100644 --- a/src/components/focus/FocusPanel.tsx +++ b/src/components/focus/FocusPanel.tsx @@ -1,7 +1,7 @@ "use client"; import { useEffect, useRef, useState, type DragEvent } from 'react'; -import { Trash2, Loader, Database, RefreshCw, Pencil, X, Save, Plus, Link2, Tag, Share2, AlignLeft } from 'lucide-react'; +import { Trash2, Loader, Database, RefreshCw, Pencil, X, Save, Plus, Link2, Tag, Share2, AlignLeft, ChevronDown, ChevronRight, Check, Folder } from 'lucide-react'; import { parseAndRenderContent } from '@/components/helpers/NodeLabelRenderer'; import { Node, NodeConnection } from '@/types/database'; import DimensionTags from './dimensions/DimensionTags'; @@ -11,7 +11,10 @@ import ConfirmDialog from '../common/ConfirmDialog'; import { SourceReader } from './source'; import SourceEditor from './source/SourceEditor'; import { openExternalUrl, shouldOpenExternally } from '@/utils/openExternalUrl'; +import { normalizeNodeLink } from '@/utils/nodeLink'; import NodeSearchModal from './edges/NodeSearchModal'; +import { getNodeProcessedState, parseNodeMetadata } from '@/services/nodes/metadata'; +import type { ContextSummary } from '@/types/database'; interface FocusPanelProps { openTabs: number[]; @@ -22,9 +25,6 @@ interface FocusPanelProps { refreshTrigger?: number; onTextSelect?: (nodeId: number, nodeTitle: string, text: string) => void; highlightedPassage?: { nodeId: number; selectedText: string } | null; - onReorderTabs?: (fromIndex: number, toIndex: number) => void; - onOpenInOtherSlot?: (nodeId: number) => void; - hideTabBar?: boolean; } type HoverSection = 'description' | 'source' | null; @@ -38,9 +38,6 @@ export default function FocusPanel({ refreshTrigger, onTextSelect, highlightedPassage, - onReorderTabs, - onOpenInOtherSlot, - hideTabBar, }: FocusPanelProps) { const { dimensionIcons } = useDimensionIcons(); const [nodesData, setNodesData] = useState>({}); @@ -72,6 +69,9 @@ export default function FocusPanel({ const [sourceEditMode, setSourceEditMode] = useState(false); const [sourceEditValue, setSourceEditValue] = useState(''); const [sourceSaving, setSourceSaving] = useState(false); + const [availableContexts, setAvailableContexts] = useState([]); + const [contextSaving, setContextSaving] = useState(false); + const [contextMenuOpen, setContextMenuOpen] = useState(false); const [hoveredSection, setHoveredSection] = useState(null); const titleInputRef = useRef(null); @@ -80,8 +80,12 @@ export default function FocusPanel({ const sourceTextareaRef = useRef(null); const skipTitleBlurRef = useRef(false); const skipLinkBlurRef = useRef(false); + const contextMenuRef = useRef(null); const currentNode = activeTab !== null ? nodesData[activeTab] : undefined; + const normalizedCurrentLink = normalizeNodeLink(currentNode?.link || null); + const currentProcessedState = getNodeProcessedState(currentNode?.metadata); + const currentNodeMetadata = parseNodeMetadata(currentNode?.metadata); const currentEdges = activeTab !== null ? edgesData[activeTab] || [] : []; const currentEdgesExpanded = activeTab !== null ? Boolean(edgesExpanded[activeTab]) : false; const visibleEdges = currentEdgesExpanded ? currentEdges : currentEdges.slice(0, 3); @@ -102,6 +106,33 @@ export default function FocusPanel({ if (sourceEditMode) sourceTextareaRef.current?.focus(); }, [sourceEditMode]); + useEffect(() => { + if (!contextMenuOpen) return; + + const handlePointerDown = (event: MouseEvent) => { + if (!contextMenuRef.current?.contains(event.target as globalThis.Node)) { + setContextMenuOpen(false); + } + }; + + document.addEventListener('mousedown', handlePointerDown); + return () => document.removeEventListener('mousedown', handlePointerDown); + }, [contextMenuOpen]); + + useEffect(() => { + void (async () => { + try { + const response = await fetch('/api/contexts'); + const data = await response.json(); + if (response.ok && data.success) { + setAvailableContexts(data.data || []); + } + } catch (error) { + console.error('Failed to fetch contexts:', error); + } + })(); + }, []); + useEffect(() => { openTabs.forEach((tabId) => { @@ -135,6 +166,7 @@ export default function FocusPanel({ setDescEditValue(''); setSourceEditMode(false); setSourceEditValue(''); + setContextMenuOpen(false); setEdgeSearchOpen(false); setEdgeEditingId(null); setEdgeEditingValue(''); @@ -145,6 +177,20 @@ export default function FocusPanel({ setLoadingNodes(prev => new Set(prev).add(id)); try { const response = await fetch(`/api/nodes/${id}`); + if (response.status === 404) { + setNodesData(prev => { + const next = { ...prev }; + delete next[id]; + return next; + }); + setEdgesData(prev => { + const next = { ...prev }; + delete next[id]; + return next; + }); + onTabClose(id); + return; + } const data = await response.json(); if (response.ok && data.node) { setNodesData(prev => ({ ...prev, [id]: data.node })); @@ -199,6 +245,48 @@ export default function FocusPanel({ throw new Error('Missing updated node in response'); }; + const toggleProcessedState = async () => { + if (activeTab === null || !currentNode) return; + + const nextState = currentProcessedState === 'processed' ? 'not_processed' : 'processed'; + + try { + await updateNode(activeTab, { + metadata: { + state: nextState, + }, + }); + } catch (error) { + console.error('Error updating processed state:', error); + window.alert('Failed to update processed state. Please try again.'); + } + }; + + const renderMetadataSection = () => { + const metadataEntries = Object.entries(currentNodeMetadata).filter(([, value]) => value !== undefined); + const rawJson = metadataEntries.length > 0 ? JSON.stringify(currentNodeMetadata, null, 2) : ''; + + return ( +
+
+ Metadata +
+
+ + {metadataEntries.length === 0 ? ( +
+ No metadata stored for this node. +
+ ) : ( +
+
raw
+
{rawJson}
+
+ )} +
+ ); + }; + const startTitleEdit = () => { if (!currentNode) return; setTitleEditValue(currentNode.title || ''); @@ -310,6 +398,20 @@ export default function FocusPanel({ } }; + const saveContext = async (value: string) => { + if (!activeTab) return; + setContextSaving(true); + try { + await updateNode(activeTab, { context_id: value ? Number(value) : null }); + setContextMenuOpen(false); + } catch (error) { + console.error('Error saving context:', error); + window.alert('Failed to save context. Please try again.'); + } finally { + setContextSaving(false); + } + }; + const embedContent = async (nodeId: number) => { setEmbeddingNode(nodeId); try { @@ -597,6 +699,315 @@ export default function FocusPanel({ }, }; + const renderDescriptionSection = () => ( +
setHoveredSection('description')} + onMouseLeave={() => setHoveredSection(prev => prev === 'description' ? null : prev)} + > + {/* Section header with extending rule */} +
+ Description +
+
+ {!descEditMode && ( + <> + + {activeTab !== null && ( + + )} + + )} +
+
+ + {descEditMode ? ( +
+