diff --git a/README.md b/README.md index 3372aba..a289923 100644 --- a/README.md +++ b/README.md @@ -58,7 +58,7 @@ Open [localhost:3000](http://localhost:3000). Done. With a key, you get: - Auto-generated descriptions when you add nodes -- Automatic dimension/tag assignment +- Automatic node descriptions - Semantic search (find similar content, not just keyword matches) **Cost:** Less than $0.10/day for heavy use. Most users spend $1-2/month. @@ -115,13 +115,13 @@ Restart Claude Code fully (**Cmd+Q on Mac**, not just closing the window). } ``` -**What happens:** Once connected, Claude calls `getContext` first to orient itself (stats, contexts, hub nodes, 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, available skills). It proactively captures knowledge. When a new insight, decision, person, or reference surfaces, it should propose a specific node with a strong title, description, source, and metadata. Context is optional and should only be set when the primary scope is obvious. Available tools: | Tool | What it does | |------|--------------| -| `getContext` | Get graph overview — stats, contexts, hub nodes, dimensions, recent activity | +| `getContext` | Get graph overview — stats, contexts, hub nodes, recent activity | | `queryContexts` | List contexts, inspect a context, or search contexts | | `queryNodes` | Find nodes by keyword | | `createNode` | Create a new node | @@ -130,10 +130,6 @@ Available tools: | `createEdge` | Link two nodes together | | `updateEdge` | Update an edge explanation | | `queryEdge` | Find connections | -| `queryDimensions` | List all tags/categories | -| `createDimension` | Create a new dimension | -| `updateDimension` | Update/rename a dimension | -| `deleteDimension` | Delete a dimension | | `listSkills` | List available skills | | `readSkill` | Read a skill by name | | `writeSkill` | Create or update a custom skill | @@ -145,7 +141,7 @@ Available tools: - "What's in my knowledge graph?" - "Search my knowledge base for notes about React performance" - "Add a node about the article I just read on transformers" -- "What nodes are connected to my 'research' dimension?" +- "Show me the nodes connected to my research context" --- diff --git a/app/api/dimensions/[name]/context/route.ts b/app/api/dimensions/[name]/context/route.ts deleted file mode 100644 index 6b57b86..0000000 --- a/app/api/dimensions/[name]/context/route.ts +++ /dev/null @@ -1,62 +0,0 @@ -import { NextRequest, NextResponse } from 'next/server'; -import { getSQLiteClient } from '@/services/database/sqlite-client'; - -export interface DimensionContext { - name: string; - description: string | null; - isPriority: boolean; - nodeCount: number; -} - -export async function GET( - request: NextRequest, - { params }: { params: Promise<{ name: string }> } -) { - try { - const { name } = await params; - const decodedName = decodeURIComponent(name); - - const db = getSQLiteClient(); - - // Get dimension metadata - const dimension = db.query<{ - name: string; - description: string | null; - is_priority: number; - }>( - 'SELECT name, description, is_priority FROM dimensions WHERE name = ?', - [decodedName] - ).rows[0]; - - if (!dimension) { - return NextResponse.json( - { success: false, error: 'Dimension not found' }, - { status: 404 } - ); - } - - // Count nodes in this dimension (via node_dimensions join table) - const countResult = db.query<{ count: number }>( - `SELECT COUNT(DISTINCT node_id) as count FROM node_dimensions WHERE dimension = ?`, - [decodedName] - ).rows[0]; - - const context: DimensionContext = { - name: dimension.name, - description: dimension.description, - isPriority: false, - nodeCount: countResult?.count || 0, - }; - - return NextResponse.json({ - success: true, - data: context, - }); - } catch (error) { - console.error('Error fetching dimension context:', error); - return NextResponse.json( - { success: false, error: 'Failed to fetch dimension context' }, - { status: 500 } - ); - } -} diff --git a/app/api/dimensions/popular/route.ts b/app/api/dimensions/popular/route.ts deleted file mode 100644 index 40f44d5..0000000 --- a/app/api/dimensions/popular/route.ts +++ /dev/null @@ -1,76 +0,0 @@ -import { NextRequest, NextResponse } from 'next/server'; -import { getSQLiteClient } from '@/services/database/sqlite-client'; - -export const runtime = 'nodejs'; - -export async function GET() { - try { - return getPopularDimensionsSQLite(); - } catch (error) { - console.error('Error fetching popular dimensions:', error); - - return NextResponse.json({ - success: false, - error: error instanceof Error ? error.message : 'Failed to fetch popular dimensions' - }, { status: 500 }); - } -} - -// PostgreSQL path removed in SQLite-only consolidation - -async function getPopularDimensionsSQLite() { - const sqlite = getSQLiteClient(); - - const result = sqlite.query(` - WITH dimension_counts AS ( - SELECT nd.dimension, COUNT(*) AS count - FROM node_dimensions nd - GROUP BY nd.dimension - ) - SELECT d.name AS dimension, - COALESCE(dc.count, 0) AS count, - d.description - FROM dimensions d - LEFT JOIN dimension_counts dc ON dc.dimension = d.name - ORDER BY LOWER(d.name) ASC - `); - - return NextResponse.json({ - success: true, - // eslint-disable-next-line @typescript-eslint/no-explicit-any - data: result.rows.map((row: any) => ({ - dimension: row.dimension, - count: Number(row.count), - isPriority: false, - description: row.description || null - })) - }); -} - -export async function POST(request: NextRequest) { - try { - const { dimension } = await request.json(); - - if (!dimension || typeof dimension !== 'string') { - return NextResponse.json({ - success: false, - error: 'Dimension name is required' - }, { status: 400 }); - } - - return NextResponse.json({ - success: true, - data: { - dimension, - is_priority: false - }, - message: 'Priority dimensions are no longer part of the product model.' - }); - } catch (error) { - console.error('Error toggling dimension priority:', error); - return NextResponse.json({ - success: false, - error: 'Internal server error' - }, { status: 500 }); - } -} diff --git a/app/api/dimensions/route.ts b/app/api/dimensions/route.ts deleted file mode 100644 index 4b18cda..0000000 --- a/app/api/dimensions/route.ts +++ /dev/null @@ -1,347 +0,0 @@ -import { NextRequest, NextResponse } from 'next/server'; -import { getSQLiteClient } from '@/services/database/sqlite-client'; -import { eventBroadcaster } from '@/services/events'; -import { normalizeDimensionName, validateDimensionDescription } from '@/services/database/quality'; - -export const runtime = 'nodejs'; - -export async function GET() { - try { - const sqlite = getSQLiteClient(); - - // Get all dimensions with their counts - const result = sqlite.query(` - WITH dimension_counts AS ( - SELECT nd.dimension, COUNT(*) AS count - FROM node_dimensions nd - GROUP BY nd.dimension - ) - SELECT - d.name AS dimension, - d.description, - d.icon, - COALESCE(dc.count, 0) AS count - FROM dimensions d - LEFT JOIN dimension_counts dc ON dc.dimension = d.name - ORDER BY d.name ASC - `); - - return NextResponse.json({ - success: true, - data: result.rows.map((row: any) => ({ - dimension: row.dimension, - description: row.description, - icon: row.icon || null, - isPriority: false, - count: Number(row.count) - })) - }); - } catch (error) { - console.error('Error fetching dimensions:', error); - return NextResponse.json({ - success: false, - error: 'Failed to fetch dimensions' - }, { status: 500 }); - } -} - -export async function POST(request: NextRequest) { - try { - const body = await request.json(); - const rawName = typeof body?.name === 'string' ? normalizeDimensionName(body.name) : ''; - const description = typeof body?.description === 'string' ? body.description.trim() : null; - const icon = typeof body?.icon === 'string' ? body.icon.trim() || null : null; - - if (!rawName) { - return NextResponse.json({ - success: false, - error: 'Dimension name is required' - }, { status: 400 }); - } - - const descriptionError = description !== null - ? validateDimensionDescription(description) - : null; - if (descriptionError) { - return NextResponse.json({ - success: false, - error: descriptionError - }, { status: 400 }); - } - - const sqlite = getSQLiteClient(); - const result = sqlite.query(` - INSERT INTO dimensions(name, description, icon, is_priority, updated_at) - VALUES (?, ?, ?, ?, CURRENT_TIMESTAMP) - ON CONFLICT(name) DO UPDATE SET - description = COALESCE(?, description), - icon = COALESCE(?, icon), - updated_at = CURRENT_TIMESTAMP - RETURNING name, description, icon, is_priority - `, [rawName, description, icon, 0, description, icon]); - - if (result.rows.length === 0) { - throw new Error('Failed to create dimension'); - } - - const row = result.rows[0]; - const dimension = row.name as string; - const descriptionValue = row.description as string | null; - const iconValue = (row.icon as string | null) || null; - - eventBroadcaster.broadcast({ - type: 'DIMENSION_UPDATED', - data: { dimension, isPriority: false, description: descriptionValue, icon: iconValue, count: 0 } - }); - - return NextResponse.json({ - success: true, - data: { - dimension, - description: descriptionValue, - icon: iconValue, - isPriority: false - } - }); - } catch (error) { - console.error('Error creating dimension:', error); - return NextResponse.json({ - success: false, - error: 'Failed to create dimension' - }, { status: 500 }); - } -} - -export async function PUT(request: NextRequest) { - try { - const body = await request.json(); - const currentName = typeof body?.currentName === 'string' ? normalizeDimensionName(body.currentName) : ''; - const newName = typeof body?.newName === 'string' ? normalizeDimensionName(body.newName) : ''; - const name = typeof body?.name === 'string' ? normalizeDimensionName(body.name) : ''; - const description = typeof body?.description === 'string' ? body.description.trim() : ''; - const icon = body?.icon !== undefined ? (typeof body.icon === 'string' ? body.icon.trim() || null : null) : undefined; - - // Handle dimension name change - if (currentName && newName && currentName !== newName) { - if (!newName) { - return NextResponse.json({ - success: false, - error: 'New dimension name is required' - }, { status: 400 }); - } - - const sqlite = getSQLiteClient(); - - // Check if new name already exists - const existingCheck = sqlite.query(` - SELECT name FROM dimensions WHERE name = ? - `, [newName]); - - if (existingCheck.rows.length > 0) { - return NextResponse.json({ - success: false, - error: 'A dimension with this name already exists' - }, { status: 400 }); - } - - // Update dimension name in transaction (also handle description and isPriority if provided) - const updateResult = sqlite.transaction(() => { - // Build update query with optional fields - const updates: string[] = ['name = ?', 'updated_at = CURRENT_TIMESTAMP']; - const values: any[] = [newName]; - - if (description !== '') { - updates.push('description = ?'); - values.push(description || null); - } - - if (icon !== undefined) { - updates.push('icon = ?'); - values.push(icon); - } - - values.push(currentName); - - const dimUpdate = sqlite.prepare(` - UPDATE dimensions - SET ${updates.join(', ')} - WHERE name = ? - `).run(...values); - - // Update node_dimensions table - const nodeDimUpdate = sqlite.prepare(` - UPDATE node_dimensions - SET dimension = ? - WHERE dimension = ? - `).run(newName, currentName); - - return { - dimensionUpdated: dimUpdate.changes > 0, - nodeLinksUpdated: nodeDimUpdate.changes - }; - }); - - if (!updateResult.dimensionUpdated) { - return NextResponse.json({ - success: false, - error: 'Dimension not found' - }, { status: 404 }); - } - - eventBroadcaster.broadcast({ - type: 'DIMENSION_UPDATED', - data: { - dimension: newName, - previousName: currentName, - description: description || undefined, - isPriority: false, - renamed: true - } - }); - - return NextResponse.json({ - success: true, - data: { - dimension: newName, - previousName: currentName, - description: description || undefined, - isPriority: false, - nodeLinksUpdated: updateResult.nodeLinksUpdated - } - }); - } - - // Handle description and/or isPriority update (existing functionality) - const targetName = name || currentName; - if (!targetName) { - return NextResponse.json({ - success: false, - error: 'Dimension name is required' - }, { status: 400 }); - } - - if (description) { - const descriptionError = validateDimensionDescription(description); - if (descriptionError) { - return NextResponse.json({ - success: false, - error: descriptionError - }, { status: 400 }); - } - } - - if (description !== '' || icon !== undefined) { - const sqlite = getSQLiteClient(); - - // Build update query - const updates: string[] = ['updated_at = CURRENT_TIMESTAMP']; - const values: any[] = []; - - if (description !== '') { - updates.push('description = ?'); - values.push(description || null); - } - - if (icon !== undefined) { - updates.push('icon = ?'); - values.push(icon); - } - - values.push(targetName); - - const updateResult = sqlite.prepare(` - UPDATE dimensions - SET ${updates.join(', ')} - WHERE name = ? - `).run(...values); - - if (updateResult.changes === 0) { - return NextResponse.json({ - success: false, - error: 'Dimension not found' - }, { status: 404 }); - } - } else { - return NextResponse.json({ - success: false, - error: 'At least one update field (description, icon, or newName) must be provided' - }, { status: 400 }); - } - - eventBroadcaster.broadcast({ - type: 'DIMENSION_UPDATED', - data: { - dimension: targetName, - description: description !== '' ? description : undefined, - icon: icon !== undefined ? icon : undefined, - isPriority: false - } - }); - - return NextResponse.json({ - success: true, - data: { - dimension: targetName, - description: description !== '' ? description : undefined, - icon: icon !== undefined ? icon : undefined, - isPriority: false - } - }); - } catch (error) { - console.error('Error updating dimension:', error); - return NextResponse.json({ - success: false, - error: 'Failed to update dimension' - }, { status: 500 }); - } -} - -export async function DELETE(request: NextRequest) { - try { - const dimension = (request.nextUrl.searchParams.get('name') || '').trim(); - if (!dimension) { - return NextResponse.json({ - success: false, - error: 'Dimension name is required' - }, { status: 400 }); - } - - const sqlite = getSQLiteClient(); - const removal = sqlite.transaction(() => { - const nodeDimStmt = sqlite.prepare('DELETE FROM node_dimensions WHERE dimension = ?'); - const dimStmt = sqlite.prepare('DELETE FROM dimensions WHERE name = ?'); - const removedLinks = nodeDimStmt.run(dimension).changes ?? 0; - const removedRow = dimStmt.run(dimension).changes ?? 0; - return { - removedLinks, - removedRow - }; - }); - - if (!removal.removedLinks && !removal.removedRow) { - return NextResponse.json({ - success: false, - error: 'Dimension not found' - }, { status: 404 }); - } - - eventBroadcaster.broadcast({ - type: 'DIMENSION_UPDATED', - data: { dimension, deleted: true } - }); - - return NextResponse.json({ - success: true, - data: { - dimension, - deleted: true - } - }); - } catch (error) { - console.error('Error deleting dimension:', error); - return NextResponse.json({ - success: false, - error: 'Failed to delete dimension' - }, { status: 500 }); - } -} diff --git a/app/api/dimensions/search/route.ts b/app/api/dimensions/search/route.ts deleted file mode 100644 index 28ab47f..0000000 --- a/app/api/dimensions/search/route.ts +++ /dev/null @@ -1,51 +0,0 @@ -import { NextRequest, NextResponse } from 'next/server'; -import { getSQLiteClient } from '@/services/database/sqlite-client'; - -export const runtime = 'nodejs'; - -export async function GET(request: NextRequest) { - try { - const searchParams = request.nextUrl.searchParams; - const query = searchParams.get('q') || ''; - - if (!query.trim()) { - return NextResponse.json({ - success: false, - error: 'Search query is required' - }, { status: 400 }); - } - - return searchDimensionsSQLite(query); - } catch (error) { - console.error('Error searching dimensions:', error); - - return NextResponse.json({ - success: false, - error: error instanceof Error ? error.message : 'Failed to search dimensions' - }, { status: 500 }); - } -} - -// PostgreSQL path removed in SQLite-only consolidation - -async function searchDimensionsSQLite(query: string) { - const sqlite = getSQLiteClient(); - - const result = sqlite.query(` - SELECT nd.dimension, COUNT(*) AS count - FROM node_dimensions nd - WHERE LOWER(nd.dimension) LIKE LOWER(?) - GROUP BY nd.dimension - ORDER BY count DESC, nd.dimension ASC - LIMIT 20 - `, [`%${query}%`]); - - return NextResponse.json({ - success: true, - // eslint-disable-next-line @typescript-eslint/no-explicit-any - data: result.rows.map((row: any) => ({ - dimension: row.dimension, - count: Number(row.count) - })) - }); -} diff --git a/app/api/mcp/route.ts b/app/api/mcp/route.ts index ba72547..108639f 100644 --- a/app/api/mcp/route.ts +++ b/app/api/mcp/route.ts @@ -1,103 +1,126 @@ -/** - * RA-H Remote MCP Endpoint - * - * A stateless MCP server for Vercel/serverless deployments. - * Exposes rah_* tools for external agents to query (and optionally modify) the knowledge graph. - * - * Environment variables: - * MCP_ALLOW_WRITES=true - Enable write tools (add_node, create_edge, etc.) - * - * Usage: - * claude mcp add --transport http my-rah https://my-deployment.vercel.app/api/mcp - */ - import { NextRequest, NextResponse } from 'next/server'; import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; import { WebStandardStreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/webStandardStreamableHttp.js'; import { z } from 'zod'; -import { nodeService, edgeService } from '@/services/database'; -import { getSQLiteClient } from '@/services/database/sqlite-client'; - export const runtime = 'nodejs'; export const maxDuration = 30; -const ALLOW_WRITES = process.env.MCP_ALLOW_WRITES === 'true'; - const SERVER_INFO = { name: 'ra-h-mcp', version: '1.0.0', }; -function buildInstructions(): string { - const lines = [ - 'RA-H Knowledge Graph - a local-first research workspace.', - 'Use rah_search_nodes to find content by keyword.', - 'Use rah_get_nodes to load full node content by ID.', - 'Use rah_query_edges to explore connections between nodes.', - 'Use rah_list_dimensions to see content categories.', - ]; +const instructions = [ + 'RA-H is a personal knowledge graph — local-first, vendor-neutral.', + 'Core concepts: contexts (optional soft scopes), nodes (knowledge units), and edges (connections with explanations).', + 'Always call rah_get_context first to orient yourself — it returns contexts, hub nodes, stats, and available guides.', + 'Use contexts only when they are explicit and helpful. Do not expect automatic context assignment.', + 'Search before creating: use rah_search_nodes to check if content already exists.', + 'Every edge needs an explanation: why does this connection exist?', +].join(' '); - if (ALLOW_WRITES) { - lines.push('Write operations are enabled. Use rah_add_node to create new nodes.'); - } else { - lines.push('This is a read-only endpoint.'); - } - - return lines.join(' '); +function getBaseUrl(request: NextRequest): string { + const envBase = process.env.RAH_MCP_TARGET_URL || process.env.NEXT_PUBLIC_BASE_URL; + return (envBase || request.nextUrl.origin).replace(/\/+$/, ''); } -/** - * Create a fresh MCP server instance with rah_* tools - */ -function createRAHServer(): McpServer { +async function callRaHApi(request: NextRequest, pathname: string, options: RequestInit = {}) { + const response = await fetch(`${getBaseUrl(request)}${pathname}`, { + ...options, + headers: { + 'Content-Type': 'application/json', + ...(options.headers || {}), + }, + }); + + const payload = await response.json().catch(() => null); + if (!response.ok || !payload || payload.success === false) { + throw new Error(payload?.error || `RA-H API request failed at ${pathname}`); + } + + return payload; +} + +function createServer(request: NextRequest): McpServer { const server = new McpServer(SERVER_INFO, { - instructions: buildInstructions(), + instructions, capabilities: { tools: {} }, }); - // ───────────────────────────────────────────────────────────────────────────── - // READ TOOLS (always enabled) - // ───────────────────────────────────────────────────────────────────────────── + server.registerTool( + 'rah_add_node', + { + title: 'Add RA-H node', + description: 'Create a new node in the local RA-H knowledge base. Set context explicitly when clear and useful; otherwise leave it empty.', + inputSchema: { + title: z.string().min(1).max(160), + content: z.string().max(20000).optional(), + source: z.string().max(50000).optional(), + link: z.string().url().optional(), + description: z.string().max(500).optional(), + context_id: z.number().int().positive().nullable().optional(), + context_name: z.string().optional(), + metadata: z.record(z.any()).optional(), + chunk: z.string().max(50000).optional(), + }, + }, + async ({ title, content, source, link, description, context_id, context_name, metadata, chunk }) => { + const payload = await callRaHApi(request, '/api/nodes', { + method: 'POST', + body: JSON.stringify({ + title: title.trim(), + source: source?.trim() || content?.trim() || chunk?.trim() || undefined, + link: link?.trim() || undefined, + description: description?.trim() || undefined, + context_id, + context_name: context_name?.trim() || undefined, + metadata: metadata || {}, + }), + }); + + const node = payload.data; + return { + content: [{ type: 'text', text: `Created node #${node.id}: ${node.title}` }], + structuredContent: { + nodeId: node.id, + title: node.title, + message: payload.message || `Created node #${node.id}: ${node.title}`, + }, + }; + } + ); - // rah_search_nodes - Full-text search server.registerTool( 'rah_search_nodes', { title: 'Search RA-H nodes', - description: 'Search the knowledge graph by keyword. Returns matching nodes with title, description, dimensions.', + description: 'Find existing RA-H entries that mention a topic before adding new ones.', inputSchema: { - query: z.string().min(1).max(400).describe('Search query (keywords)'), - limit: z.number().min(1).max(50).optional().describe('Max results (default 20)'), - dimensions: z.array(z.string()).max(5).optional().describe('Filter by dimensions'), + query: z.string().min(1).max(400), + limit: z.number().min(1).max(25).optional(), + contextId: z.number().int().positive().optional(), }, }, - async ({ query, limit = 20, dimensions }) => { - const filters: any = { - search: query.trim(), - limit: Math.min(Math.max(limit, 1), 50), - }; + async ({ query, limit = 10, contextId }) => { + const params = new URLSearchParams(); + params.set('search', query.trim()); + params.set('limit', String(Math.min(Math.max(limit, 1), 25))); + if (contextId) params.set('contextId', String(contextId)); - if (dimensions && dimensions.length > 0) { - filters.dimensions = dimensions; - } - - const nodes = await nodeService.getNodes(filters); - - const summary = nodes.length === 0 - ? `No results found for "${query}".` - : `Found ${nodes.length} result(s) for "${query}".`; + const payload = await callRaHApi(request, `/api/nodes?${params.toString()}`); + const nodes = Array.isArray(payload.data) ? payload.data : []; return { - content: [{ type: 'text', text: summary }], + content: [{ type: 'text', text: nodes.length === 0 ? 'No existing RA-H nodes mention that topic yet.' : `Found ${nodes.length} node(s) mentioning that topic.` }], structuredContent: { count: nodes.length, nodes: nodes.map((node: any) => ({ 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, })), }, @@ -105,441 +128,447 @@ function createRAHServer(): McpServer { } ); - // rah_get_nodes - Load full node content by ID + server.registerTool( + 'rah_query_contexts', + { + title: 'Query RA-H contexts', + description: 'List or inspect contexts, the soft organizational scope layer for the graph.', + inputSchema: { + 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(), + }, + }, + async ({ contextId, name, search, limit = 50, includeNodes = false }) => { + const normalizedName = typeof name === 'string' ? name.trim().toLowerCase() : ''; + const normalizedSearch = typeof search === 'string' ? search.trim().toLowerCase() : ''; + + let contexts: any[] = []; + if (contextId) { + const payload = await callRaHApi(request, `/api/contexts/${contextId}`); + contexts = payload.data ? [payload.data] : []; + } else { + const payload = await callRaHApi(request, '/api/contexts'); + contexts = Array.isArray(payload.data) ? payload.data : []; + } + + if (normalizedName) { + contexts = contexts.filter((context) => context.name.toLowerCase() === normalizedName); + } + + if (normalizedSearch) { + contexts = contexts.filter((context) => + context.name.toLowerCase().includes(normalizedSearch) || + (context.description || '').toLowerCase().includes(normalizedSearch) + ); + } + + contexts = contexts.slice(0, Math.min(Math.max(limit, 1), 100)); + + const structuredContexts = await Promise.all( + contexts.map(async (context) => { + if (!(includeNodes && contexts.length === 1)) { + return { + id: context.id, + name: context.name, + description: context.description ?? null, + icon: context.icon ?? null, + count: context.count ?? 0, + }; + } + + const nodesPayload = await callRaHApi(request, `/api/contexts/${context.id}/nodes`); + const nodes = Array.isArray(nodesPayload.data) ? nodesPayload.data : []; + + return { + id: context.id, + name: context.name, + description: context.description ?? null, + icon: context.icon ?? null, + count: context.count ?? nodes.length, + nodes: nodes.map((node: any) => ({ + id: node.id, + title: node.title, + description: node.description ?? null, + link: node.link ?? null, + context_id: node.context_id ?? null, + updated_at: node.updated_at, + })), + }; + }) + ); + + return { + content: [{ type: 'text', text: structuredContexts.length === 0 ? 'No contexts found.' : `Found ${structuredContexts.length} context(s).` }], + structuredContent: { + count: structuredContexts.length, + contexts: structuredContexts, + }, + }; + } + ); + + server.registerTool( + 'rah_update_node', + { + title: 'Update RA-H node', + description: 'Update an existing node. Context remains optional and explicit.', + inputSchema: { + id: z.number().int().positive(), + updates: z.object({ + title: z.string().optional(), + description: z.string().max(500).optional(), + content: z.string().optional(), + source: z.string().optional(), + link: z.string().optional(), + context_id: z.number().int().positive().nullable().optional(), + metadata: z.record(z.any()).optional(), + }), + }, + }, + async ({ id, updates }) => { + const mappedUpdates = { ...updates } as Record; + if (mappedUpdates.content !== undefined && mappedUpdates.source === undefined) { + mappedUpdates.source = mappedUpdates.content; + } + delete mappedUpdates.content; + + const payload = await callRaHApi(request, `/api/nodes/${id}`, { + method: 'PUT', + body: JSON.stringify(mappedUpdates), + }); + + return { + content: [{ type: 'text', text: `Updated node #${id}` }], + structuredContent: { + success: true, + nodeId: payload.node?.id || id, + message: payload.message || `Updated node #${id}`, + }, + }; + } + ); + server.registerTool( 'rah_get_nodes', { title: 'Get RA-H nodes by ID', - description: 'Load full content of specific nodes by their IDs.', + description: 'Load full node records by their IDs.', inputSchema: { - nodeIds: z.array(z.number().int().positive()).min(1).max(10).describe('Node IDs to load (max 10)'), + nodeIds: z.array(z.number().int().positive()).min(1).max(10), }, }, async ({ nodeIds }) => { - const uniqueIds = Array.from(new Set(nodeIds.filter(id => Number.isFinite(id) && id > 0))); - - if (uniqueIds.length === 0) { - return { - content: [{ type: 'text', text: 'No valid node IDs provided.' }], - structuredContent: { count: 0, nodes: [] }, - }; - } - + const uniqueIds = Array.from(new Set(nodeIds.filter((id) => Number.isFinite(id) && id > 0))); const nodes: any[] = []; + for (const id of uniqueIds) { try { - const node = await nodeService.getNodeById(id); - if (node) { + const payload = await callRaHApi(request, `/api/nodes/${id}`); + if (payload.node) { nodes.push({ - id: node.id, - title: node.title, - description: node.description ?? null, - source: node.source ?? null, - link: node.link ?? null, - dimensions: node.dimensions || [], - metadata: node.metadata || {}, - created_at: node.created_at, - updated_at: node.updated_at, + id: payload.node.id, + title: payload.node.title, + source: payload.node.source ?? null, + link: payload.node.link ?? null, + updated_at: payload.node.updated_at, }); } - } catch (e) { - // Skip missing nodes + } catch { + // Skip missing nodes. } } return { content: [{ type: 'text', text: `Loaded ${nodes.length} of ${uniqueIds.length} nodes.` }], - structuredContent: { count: nodes.length, nodes }, + structuredContent: { + count: nodes.length, + nodes, + }, + }; + } + ); + + server.registerTool( + 'rah_create_edge', + { + title: 'Create RA-H edge', + description: 'Create a connection between two nodes.', + inputSchema: { + sourceId: z.number().int().positive(), + targetId: z.number().int().positive(), + explanation: z.string().min(1), + }, + }, + async ({ sourceId, targetId, explanation }) => { + const payload = await callRaHApi(request, '/api/edges', { + method: 'POST', + body: JSON.stringify({ + from_node_id: sourceId, + to_node_id: targetId, + explanation: explanation.trim(), + source: 'helper_name', + created_via: 'mcp', + }), + }); + + const edge = payload.edge || payload.data; + return { + content: [{ type: 'text', text: `Created edge from #${sourceId} to #${targetId}` }], + structuredContent: { + success: true, + edgeId: edge?.id || 0, + message: payload.message || `Created edge from #${sourceId} to #${targetId}`, + }, }; } ); - // rah_query_edges - Find connections server.registerTool( 'rah_query_edges', { title: 'Query RA-H edges', - description: 'Find connections (edges) between nodes. Use nodeId to get all connections for a specific node.', + description: 'Find connections between nodes.', inputSchema: { - nodeId: z.number().int().positive().optional().describe('Find edges connected to this node'), - limit: z.number().min(1).max(100).optional().describe('Max edges to return (default 50)'), + nodeId: z.number().int().positive().optional(), + limit: z.number().min(1).max(50).optional(), }, }, - async ({ nodeId, limit = 50 }) => { - let edges: any[]; + async ({ nodeId, limit = 25 }) => { + const params = new URLSearchParams(); + if (nodeId) params.set('nodeId', String(nodeId)); + params.set('limit', String(Math.min(Math.max(limit, 1), 50))); - if (nodeId) { - const connections = await edgeService.getNodeConnections(nodeId); - edges = connections.slice(0, limit).map(c => c.edge); - } else { - edges = await edgeService.getEdges(); - edges = edges.slice(0, limit); - } - - const parseContext = (ctx: any) => { - if (typeof ctx === 'string') { - try { return JSON.parse(ctx); } catch { return {}; } - } - return ctx || {}; - }; + const payload = await callRaHApi(request, `/api/edges?${params.toString()}`); + const edges = Array.isArray(payload.data) ? payload.data : []; return { - content: [{ type: 'text', text: `Found ${edges.length} connection(s).` }], + content: [{ type: 'text', text: `Found ${edges.length} edge(s).` }], structuredContent: { count: edges.length, - edges: edges.map((e: any) => { - const ctx = parseContext(e.context); - return { - id: e.id, - from_node_id: e.from_node_id, - to_node_id: e.to_node_id, - explanation: ctx.explanation ?? null, - type: ctx.type ?? null, - created_at: e.created_at, - }; - }), + edges: edges.map((edge: any) => ({ + id: edge.id, + source_id: edge.from_node_id, + target_id: edge.to_node_id, + type: edge.context?.type ?? null, + weight: typeof edge.context?.confidence === 'number' ? edge.context.confidence : null, + })), }, }; } ); - // rah_list_dimensions - List all dimensions server.registerTool( - 'rah_list_dimensions', + 'rah_update_edge', { - title: 'List RA-H dimensions', - description: 'List all dimensions (categories/tags) in the knowledge graph with node counts.', + title: 'Update RA-H edge', + description: 'Update an existing edge connection.', + inputSchema: { + id: z.number().int().positive(), + explanation: z.string().min(1), + }, + }, + async ({ id, explanation }) => { + const payload = await callRaHApi(request, `/api/edges/${id}`, { + method: 'PUT', + body: JSON.stringify({ + context: { explanation: explanation.trim(), created_via: 'mcp' }, + }), + }); + + return { + content: [{ type: 'text', text: `Updated edge #${id}` }], + structuredContent: { + success: true, + message: payload.message || `Updated edge #${id}`, + }, + }; + } + ); + + server.registerTool( + 'rah_search_embeddings', + { + title: 'Semantic search RA-H', + description: 'Search node content using semantic similarity.', + inputSchema: { + query: z.string().min(1), + limit: z.number().min(1).max(20).optional(), + }, + }, + async ({ query, limit = 10 }) => { + const params = new URLSearchParams(); + params.set('q', query); + params.set('limit', String(Math.min(Math.max(limit, 1), 20))); + + const payload = await callRaHApi(request, `/api/nodes/search?${params.toString()}`); + const results = Array.isArray(payload.data) ? payload.data : []; + + return { + content: [{ type: 'text', text: `Found ${results.length} semantically similar result(s).` }], + structuredContent: { + count: results.length, + results: results.map((result: any) => ({ + nodeId: result.node_id || result.nodeId || result.id, + title: result.title || 'Untitled', + chunkPreview: (result.source || '').slice(0, 200), + similarity: result.similarity || result.score || 0, + })), + }, + }; + } + ); + + server.registerTool( + 'rah_extract_url', + { + title: 'Extract URL content', + description: 'Extract content from a webpage URL.', + inputSchema: { + url: z.string().url(), + }, + }, + async ({ url }) => { + const payload = await callRaHApi(request, '/api/extract/url', { + method: 'POST', + body: JSON.stringify({ url }), + }); + + return { + content: [{ type: 'text', text: `Extracted content from: ${payload.title || 'webpage'}` }], + structuredContent: { + success: true, + title: payload.title || 'Untitled', + source: payload.source || '', + metadata: payload.metadata || {}, + }, + }; + } + ); + + server.registerTool( + 'rah_extract_youtube', + { + title: 'Extract YouTube transcript', + description: 'Extract transcript from a YouTube video.', + inputSchema: { + url: z.string(), + }, + }, + async ({ url }) => { + const payload = await callRaHApi(request, '/api/extract/youtube', { + method: 'POST', + body: JSON.stringify({ url }), + }); + + return { + content: [{ type: 'text', text: `Extracted transcript from: ${payload.title || 'YouTube video'}` }], + structuredContent: { + success: true, + title: payload.title || 'Untitled', + channel: payload.channel || 'Unknown', + source: payload.source || '', + metadata: payload.metadata || {}, + }, + }; + } + ); + + server.registerTool( + 'rah_extract_pdf', + { + title: 'Extract PDF content', + description: 'Extract content from a PDF file URL.', + inputSchema: { + url: z.string().url(), + }, + }, + async ({ url }) => { + const payload = await callRaHApi(request, '/api/extract/pdf', { + method: 'POST', + body: JSON.stringify({ url }), + }); + + return { + content: [{ type: 'text', text: `Extracted content from: ${payload.title || 'PDF document'}` }], + structuredContent: { + success: true, + title: payload.title || 'Untitled PDF', + source: payload.source || '', + metadata: payload.metadata || {}, + }, + }; + } + ); + + server.registerTool( + 'rah_get_context', + { + title: 'Get RA-H context', + description: 'Get orientation context: contexts, hub nodes, stats, and available guides.', inputSchema: {}, }, async () => { - const sqlite = getSQLiteClient(); + const [hubPayload, contextsPayload, guidesPayload, countPayload] = await Promise.all([ + callRaHApi(request, '/api/nodes?sortBy=edges&limit=5'), + callRaHApi(request, '/api/contexts'), + callRaHApi(request, '/api/guides').catch(() => ({ data: [] })), + callRaHApi(request, '/api/nodes?limit=1').catch(() => ({ total: 0 })), + ]); - const result = sqlite.query(` - WITH dimension_counts AS ( - SELECT nd.dimension, COUNT(*) AS count - FROM node_dimensions nd - GROUP BY nd.dimension - ) - SELECT - d.name AS dimension, - d.description, - d.is_priority AS isPriority, - COALESCE(dc.count, 0) AS count - FROM dimensions d - LEFT JOIN dimension_counts dc ON dc.dimension = d.name - ORDER BY dc.count DESC, d.name ASC - `); + const hubNodes = Array.isArray(hubPayload.data) ? hubPayload.data.map((node: any) => ({ + id: node.id, + title: node.title, + description: node.description ?? null, + edgeCount: node.edge_count ?? 0, + })) : []; - const dimensions = result.rows.map((row: any) => ({ - name: row.dimension, - description: row.description ?? null, - isPriority: Boolean(row.isPriority), - count: Number(row.count), - })); + const contexts = Array.isArray(contextsPayload.data) ? contextsPayload.data.map((context: any) => ({ + id: context.id, + name: context.name, + description: context.description ?? null, + icon: context.icon ?? null, + count: context.count ?? 0, + })) : []; - const totalNodes = dimensions.reduce((sum, d) => sum + d.count, 0); + const guides = Array.isArray(guidesPayload.data) ? guidesPayload.data.map((guide: any) => guide.name) : []; return { - content: [{ type: 'text', text: `${dimensions.length} dimensions, ${totalNodes} total nodes.` }], + content: [{ type: 'text', text: `Knowledge graph: ${contexts.length} contexts, ${hubNodes.length} hub nodes, ${guides.length} guides available.` }], structuredContent: { - count: dimensions.length, - totalNodes, - dimensions, + stats: { + nodeCount: countPayload.total ?? 0, + edgeCount: 0, + contextCount: contexts.length, + }, + hubNodes, + contexts, + guides, }, }; } ); - // ───────────────────────────────────────────────────────────────────────────── - // WRITE TOOLS (only when MCP_ALLOW_WRITES=true) - // ───────────────────────────────────────────────────────────────────────────── - - if (ALLOW_WRITES) { - // rah_add_node - server.registerTool( - 'rah_add_node', - { - title: 'Add RA-H node', - description: 'Create a new node in the knowledge graph.', - inputSchema: { - title: z.string().min(1).max(160).describe('Node title'), - 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().max(2000).optional().describe('Short description'), - dimensions: z.array(z.string()).min(1).max(5).describe('Categories/tags (at least 1)'), - metadata: z.record(z.any()).optional().describe('Additional metadata'), - }, - }, - async ({ title, content, source, link, description, dimensions, metadata }) => { - // Call the nodes API internally - const response = await fetch(`${process.env.NEXT_PUBLIC_BASE_URL || 'http://localhost:3000'}/api/nodes`, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ - title: title.trim(), - source: source?.trim() || content?.trim(), - link: link?.trim(), - description: description?.trim(), - dimensions, - metadata: metadata || {}, - }), - }); - - const result = await response.json(); - if (!result.success) { - throw new Error(result.error || 'Failed to create node'); - } - - const node = result.data; - return { - content: [{ type: 'text', text: `Created node #${node.id}: ${node.title}` }], - structuredContent: { - success: true, - nodeId: node.id, - title: node.title, - dimensions: node.dimensions || dimensions, - }, - }; - } - ); - - // rah_update_node - server.registerTool( - 'rah_update_node', - { - title: 'Update RA-H node', - description: 'Update an existing node. Source content is canonical and dimensions are replaced.', - inputSchema: { - id: z.number().int().positive().describe('Node ID to update'), - updates: z.object({ - title: z.string().optional().describe('New title'), - content: z.string().optional().describe('Legacy alias for source'), - source: z.string().optional().describe('Canonical source text'), - link: z.string().optional().describe('New link'), - dimensions: z.array(z.string()).optional().describe('New dimensions (replaces existing)'), - }).describe('Fields to update'), - }, - }, - async ({ id, updates }) => { - const mappedUpdates = { ...updates } as Record; - if (mappedUpdates.content !== undefined && mappedUpdates.source === undefined) { - mappedUpdates.source = mappedUpdates.content; - } - delete mappedUpdates.content; - - const response = await fetch(`${process.env.NEXT_PUBLIC_BASE_URL || 'http://localhost:3000'}/api/nodes/${id}`, { - method: 'PUT', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify(mappedUpdates), - }); - - const result = await response.json(); - if (!result.success && !result.node) { - throw new Error(result.error || 'Failed to update node'); - } - - return { - content: [{ type: 'text', text: `Updated node #${id}` }], - structuredContent: { success: true, nodeId: id }, - }; - } - ); - - // rah_create_edge - server.registerTool( - 'rah_create_edge', - { - title: 'Create RA-H edge', - description: 'Create a connection between two nodes.', - inputSchema: { - fromNodeId: z.number().int().positive().describe('Source node ID'), - toNodeId: z.number().int().positive().describe('Target node ID'), - explanation: z.string().min(1).describe('Why does this connection exist?'), - }, - }, - async ({ fromNodeId, toNodeId, explanation }) => { - const response = await fetch(`${process.env.NEXT_PUBLIC_BASE_URL || 'http://localhost:3000'}/api/edges`, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ - from_node_id: fromNodeId, - to_node_id: toNodeId, - explanation: explanation.trim(), - source: 'helper_name', - created_via: 'mcp', - }), - }); - - const result = await response.json(); - if (!result.success) { - throw new Error(result.error || 'Failed to create edge'); - } - - const edge = result.data; - return { - content: [{ type: 'text', text: `Created edge from #${fromNodeId} to #${toNodeId}` }], - structuredContent: { success: true, edgeId: edge?.id }, - }; - } - ); - - // rah_update_edge - server.registerTool( - 'rah_update_edge', - { - title: 'Update RA-H edge', - description: 'Update an existing edge explanation.', - inputSchema: { - id: z.number().int().positive().describe('Edge ID to update'), - explanation: z.string().min(1).describe('New explanation'), - }, - }, - async ({ id, explanation }) => { - const response = await fetch(`${process.env.NEXT_PUBLIC_BASE_URL || 'http://localhost:3000'}/api/edges/${id}`, { - method: 'PUT', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ - context: { explanation: explanation.trim(), created_via: 'mcp' }, - }), - }); - - const result = await response.json(); - if (!result.success && !result.edge) { - throw new Error(result.error || 'Failed to update edge'); - } - - return { - content: [{ type: 'text', text: `Updated edge #${id}` }], - structuredContent: { success: true, edgeId: id }, - }; - } - ); - - // rah_create_dimension - server.registerTool( - 'rah_create_dimension', - { - title: 'Create RA-H dimension', - description: 'Create a new dimension (category/tag) for organizing nodes.', - inputSchema: { - name: z.string().min(1).describe('Dimension name'), - description: z.string().max(500).optional().describe('Description'), - isPriority: z.boolean().optional().describe('Lock for auto-assignment'), - }, - }, - async ({ name, description, isPriority }) => { - const response = await fetch(`${process.env.NEXT_PUBLIC_BASE_URL || 'http://localhost:3000'}/api/dimensions`, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ name, description, isPriority }), - }); - - const result = await response.json(); - if (!result.success) { - throw new Error(result.error || 'Failed to create dimension'); - } - - return { - content: [{ type: 'text', text: `Created dimension: ${name}` }], - structuredContent: { success: true, dimension: name }, - }; - } - ); - - // rah_update_dimension - server.registerTool( - 'rah_update_dimension', - { - title: 'Update RA-H dimension', - description: 'Update dimension properties (rename, description, priority).', - inputSchema: { - name: z.string().min(1).describe('Current dimension name'), - newName: z.string().optional().describe('New name (for renaming)'), - description: z.string().max(500).optional().describe('New description'), - isPriority: z.boolean().optional().describe('Lock/unlock dimension'), - }, - }, - async ({ name, newName, description, isPriority }) => { - const payload: any = {}; - if (newName) { - payload.currentName = name; - payload.newName = newName; - } else { - payload.name = name; - } - if (description !== undefined) payload.description = description; - if (isPriority !== undefined) payload.isPriority = isPriority; - - const response = await fetch(`${process.env.NEXT_PUBLIC_BASE_URL || 'http://localhost:3000'}/api/dimensions`, { - method: 'PUT', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify(payload), - }); - - const result = await response.json(); - if (!result.success) { - throw new Error(result.error || 'Failed to update dimension'); - } - - return { - content: [{ type: 'text', text: `Updated dimension: ${newName || name}` }], - structuredContent: { success: true, dimension: newName || name }, - }; - } - ); - - // rah_delete_dimension - server.registerTool( - 'rah_delete_dimension', - { - title: 'Delete RA-H dimension', - description: 'Delete a dimension and remove it from all nodes.', - inputSchema: { - name: z.string().min(1).describe('Dimension name to delete'), - }, - }, - async ({ name }) => { - const response = await fetch(`${process.env.NEXT_PUBLIC_BASE_URL || 'http://localhost:3000'}/api/dimensions?name=${encodeURIComponent(name)}`, { - method: 'DELETE', - }); - - const result = await response.json(); - if (!result.success) { - throw new Error(result.error || 'Failed to delete dimension'); - } - - return { - content: [{ type: 'text', text: `Deleted dimension: ${name}` }], - structuredContent: { success: true, dimension: name }, - }; - } - ); - } - return server; } -/** - * Handle MCP POST requests (tool calls) - */ -export async function POST(req: NextRequest) { +export async function POST(request: NextRequest) { try { - const server = createRAHServer(); + const server = createServer(request); const transport = new WebStandardStreamableHTTPServerTransport({ sessionIdGenerator: undefined, }); await server.connect(transport); - const response = await transport.handleRequest(req); - + const response = await transport.handleRequest(request); await transport.close(); await server.close(); return response; } catch (error) { console.error('MCP request error:', error); - return NextResponse.json( { jsonrpc: '2.0', @@ -559,9 +588,6 @@ export async function POST(req: NextRequest) { } } -/** - * Handle preflight CORS requests - */ export async function OPTIONS() { return new NextResponse(null, { status: 204, @@ -573,28 +599,30 @@ export async function OPTIONS() { }); } -/** - * GET returns server info - */ -export async function GET() { - const tools = ['rah_search_nodes', 'rah_get_nodes', 'rah_query_edges', 'rah_list_dimensions']; - - if (ALLOW_WRITES) { - tools.push( - 'rah_add_node', 'rah_update_node', - 'rah_create_edge', 'rah_update_edge', - 'rah_create_dimension', 'rah_update_dimension', 'rah_delete_dimension' - ); - } +export async function GET(request: NextRequest) { + const tools = [ + 'rah_add_node', + 'rah_search_nodes', + 'rah_query_contexts', + 'rah_update_node', + 'rah_get_nodes', + 'rah_create_edge', + 'rah_query_edges', + 'rah_update_edge', + 'rah_search_embeddings', + 'rah_extract_url', + 'rah_extract_youtube', + 'rah_extract_pdf', + 'rah_get_context', + ]; return NextResponse.json( { name: SERVER_INFO.name, version: SERVER_INFO.version, description: 'RA-H Knowledge Graph - Remote MCP Server', - writesEnabled: ALLOW_WRITES, + target: getBaseUrl(request), tools, - usage: 'claude mcp add --transport http my-rah https://your-deployment.vercel.app/api/mcp', }, { headers: { 'Access-Control-Allow-Origin': '*' }, diff --git a/app/api/nodes/[id]/regenerate-description/route.ts b/app/api/nodes/[id]/regenerate-description/route.ts index 2426fc3..973e6d1 100644 --- a/app/api/nodes/[id]/regenerate-description/route.ts +++ b/app/api/nodes/[id]/regenerate-description/route.ts @@ -4,7 +4,14 @@ import { generateDescription } from '@/services/database/descriptionService'; export const runtime = 'nodejs'; -type NodeMetadata = { source?: string; channel_name?: string; author?: string; site_name?: string; type?: string } & Record; +type NodeMetadata = { + source?: string; + channel_name?: string; + author?: string; + site_name?: string; + type?: string; + source_metadata?: Record; +} & Record; function parseMetadata(raw: unknown): NodeMetadata | undefined { if (!raw) return undefined; @@ -27,9 +34,14 @@ async function enrichYoutubeMetadataIfMissing(link: string, metadata: NodeMetada if (!url.includes('youtube.com') && !url.includes('youtu.be')) return metadata; const existing = metadata || {}; + const existingSourceMetadata = typeof existing.source_metadata === 'object' && existing.source_metadata + ? existing.source_metadata + : {}; const hasCreatorHint = Boolean( (typeof existing.author === 'string' && existing.author.trim()) || - (typeof existing.channel_name === 'string' && existing.channel_name.trim()) + (typeof existing.channel_name === 'string' && existing.channel_name.trim()) || + (typeof existingSourceMetadata.author === 'string' && existingSourceMetadata.author.trim()) || + (typeof existingSourceMetadata.channel_name === 'string' && existingSourceMetadata.channel_name.trim()) ); if (hasCreatorHint) return existing; @@ -44,9 +56,20 @@ async function enrichYoutubeMetadataIfMissing(link: string, metadata: NodeMetada return { ...existing, - source: typeof existing.source === 'string' && existing.source.trim().length > 0 ? existing.source : 'youtube', - channel_name: typeof existing.channel_name === 'string' && existing.channel_name.trim().length > 0 ? existing.channel_name : authorName, - site_name: typeof existing.site_name === 'string' && existing.site_name.trim().length > 0 ? existing.site_name : (providerName || 'YouTube'), + type: typeof existing.type === 'string' && existing.type.trim().length > 0 ? existing.type : 'youtube', + source_metadata: { + ...existingSourceMetadata, + channel_name: typeof existing.channel_name === 'string' && existing.channel_name.trim().length > 0 + ? existing.channel_name + : (typeof existingSourceMetadata.channel_name === 'string' && existingSourceMetadata.channel_name.trim().length > 0 + ? existingSourceMetadata.channel_name + : authorName), + site_name: typeof existing.site_name === 'string' && existing.site_name.trim().length > 0 + ? existing.site_name + : (typeof existingSourceMetadata.site_name === 'string' && existingSourceMetadata.site_name.trim().length > 0 + ? existingSourceMetadata.site_name + : (providerName || 'YouTube')), + }, }; } catch { return existing; @@ -88,8 +111,6 @@ export async function POST( source: node.source || node.description || undefined, link: node.link || undefined, metadata: enrichedMetadata, - - dimensions: node.dimensions || [] }); // Update the node with the new description diff --git a/app/api/nodes/[id]/route.ts b/app/api/nodes/[id]/route.ts index 133deeb..6b6d3af 100644 --- a/app/api/nodes/[id]/route.ts +++ b/app/api/nodes/[id]/route.ts @@ -2,8 +2,7 @@ import { NextRequest, NextResponse } from 'next/server'; import { contextService, nodeService } from '@/services/database'; import { autoEmbedQueue } from '@/services/embedding/autoEmbedQueue'; import { hasSufficientContent } from '@/services/embedding/constants'; -import { coerceDescriptionForStorage, normalizeDimensions } from '@/services/database/quality'; -import { formatUnknownDimensionsError, getUnknownDimensions } from '@/services/database/dimensionValidation'; +import { coerceDescriptionForStorage } from '@/services/database/quality'; import { normalizeNodeLink } from '@/utils/nodeLink'; import { mergeNodeMetadata } from '@/services/nodes/metadata'; @@ -94,26 +93,26 @@ export async function PUT( }); } - if (Array.isArray(body.dimensions)) { - updates.dimensions = normalizeDimensions(body.dimensions, 5); - const unknownDimensions = getUnknownDimensions(updates.dimensions as string[]); - if (unknownDimensions.length > 0) { - return NextResponse.json({ - success: false, - error: formatUnknownDimensionsError(unknownDimensions) - }, { status: 400 }); - } - } - delete updates.notes; delete updates.chunk; - if (Object.prototype.hasOwnProperty.call(body, 'context_id') || Object.prototype.hasOwnProperty.call(body, 'context_name')) { + if (body.metadata !== undefined) { + updates.metadata = mergeNodeMetadata(existingNode.metadata, body.metadata); + } + + if (Object.prototype.hasOwnProperty.call(body, 'context_name')) { + return NextResponse.json({ + success: false, + error: 'context_name is only supported on node creation. Use context_id for updates.' + }, { status: 400 }); + } + + if (Object.prototype.hasOwnProperty.call(body, 'context_id')) { try { - updates.context_id = await contextService.resolveContextId({ + const resolvedContextId = await contextService.resolveContextId({ context_id: body.context_id, - context_name: body.context_name, }); + updates.context_id = resolvedContextId; } catch (error) { return NextResponse.json({ success: false, @@ -122,10 +121,6 @@ export async function PUT( } } - 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 52c400b..87213cc 100644 --- a/app/api/nodes/route.ts +++ b/app/api/nodes/route.ts @@ -4,11 +4,9 @@ import { Node, NodeFilters } from '@/types/database'; import { autoEmbedQueue } from '@/services/embedding/autoEmbedQueue'; import { generateDescription } from '@/services/database/descriptionService'; import { scheduleAutoEdgeCreation } from '@/services/agents/autoEdge'; -import { coerceDescriptionForStorage, normalizeDimensions } from '@/services/database/quality'; -import { formatUnknownDimensionsError, getUnknownDimensions } from '@/services/database/dimensionValidation'; +import { coerceDescriptionForStorage } from '@/services/database/quality'; import { normalizeNodeLink } from '@/utils/nodeLink'; import { buildCanonicalNodeMetadata } from '@/services/nodes/metadata'; -import { inferBestContextIdForNode } from '@/services/context/contextAssignment'; export const runtime = 'nodejs'; @@ -30,18 +28,6 @@ export async function GET(request: NextRequest) { } } - // Handle dimensions parameter (comma-separated) - const dimensionsParam = searchParams.get('dimensions'); - if (dimensionsParam) { - filters.dimensions = dimensionsParam.split(',').map(dim => dim.trim()).filter(Boolean); - } - - // Handle dimensionsMatch parameter (any|all) - const dimensionsMatchParam = searchParams.get('dimensionsMatch'); - if (dimensionsMatchParam === 'all') { - filters.dimensionsMatch = 'all'; - } - // Handle sortBy parameter (sortBy=edges|updated|created) const sortByParam = searchParams.get('sortBy'); if (sortByParam === 'edges' || sortByParam === 'updated' || sortByParam === 'created' || sortByParam === 'event_date') { @@ -115,16 +101,6 @@ export async function POST(request: NextRequest) { } const eventDate = typeof body.event_date === 'string' ? body.event_date : null; - // Process provided dimensions first (needed for description generation) - const trimmedProvidedDimensions = normalizeDimensions(body.dimensions, 5); - const unknownDimensions = getUnknownDimensions(trimmedProvidedDimensions); - if (unknownDimensions.length > 0) { - return NextResponse.json({ - success: false, - error: formatUnknownDimensionsError(unknownDimensions) - }, { status: 400 }); - } - // Use provided description if present, otherwise auto-generate const isUserSuppliedDescription = typeof body.description === 'string' && body.description.trim().length > 0; let nodeDescription: string | undefined = isUserSuppliedDescription @@ -138,7 +114,6 @@ export async function POST(request: NextRequest) { source: rawSource?.slice(0, 2000) || undefined, link: normalizedLink || undefined, metadata: body.metadata, - dimensions: trimmedProvidedDimensions }); } catch (error) { console.error('Error generating description:', error); @@ -160,8 +135,6 @@ export async function POST(request: NextRequest) { console.warn(`[DescriptionQuality] Weak description for node "${body.title}": "${finalDescription}"`); } - // Use only provided dimensions (no auto-assignment) - const finalDimensions = trimmedProvidedDimensions; const sourceToStore = rawSource || [body.title, nodeDescription].filter(Boolean).join('\n\n').trim() || null; let chunkStatus: Node['chunk_status']; @@ -169,6 +142,13 @@ export async function POST(request: NextRequest) { chunkStatus = 'not_chunked'; } + const inferredType = + typeof body.metadata?.type === 'string' + ? body.metadata.type + : typeof body.metadata?.source === 'string' + ? body.metadata.source + : undefined; + let resolvedContextId: number | null | undefined; try { resolvedContextId = await contextService.resolveContextId({ @@ -176,25 +156,10 @@ export async function POST(request: NextRequest) { 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, - }); + return NextResponse.json({ + success: false, + error: error instanceof Error ? error.message : 'Invalid context input' + }, { status: 400 }); } const node = await nodeService.createNode({ @@ -203,16 +168,11 @@ export async function POST(request: NextRequest) { source: sourceToStore ?? undefined, event_date: eventDate ?? undefined, link: normalizedLink ?? undefined, - dimensions: finalDimensions, chunk_status: chunkStatus, 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, + type: inferredType, state: body.metadata?.state === 'processed' ? 'processed' : 'not_processed', }) }); @@ -229,7 +189,7 @@ export async function POST(request: NextRequest) { return NextResponse.json({ success: true, data: node, - message: `Node created successfully with dimensions: ${finalDimensions.join(', ')}` + message: `Node created successfully` }, { status: 201 }); } catch (error) { console.error('Error creating node:', error); diff --git a/app/api/nodes/search/route.ts b/app/api/nodes/search/route.ts index 2a038f4..91cf7a5 100644 --- a/app/api/nodes/search/route.ts +++ b/app/api/nodes/search/route.ts @@ -29,7 +29,6 @@ export async function GET(request: NextRequest) { const results = nodes.map(node => ({ id: node.id, title: node.title, - dimensions: node.dimensions })); return NextResponse.json({ @@ -46,4 +45,4 @@ export async function GET(request: NextRequest) { error: error instanceof Error ? error.message : 'Failed to search nodes' }, { status: 500 }); } -} \ No newline at end of file +} diff --git a/app/api/system/auto-context/route.ts b/app/api/system/auto-context/route.ts deleted file mode 100644 index f9a098a..0000000 --- a/app/api/system/auto-context/route.ts +++ /dev/null @@ -1,41 +0,0 @@ -import { NextRequest, NextResponse } from 'next/server'; -import { - getAutoContextSettings, - setAutoContextEnabled, -} from '@/services/settings/autoContextSettings'; - -export const runtime = 'nodejs'; - -export async function GET() { - try { - const settings = getAutoContextSettings(); - return NextResponse.json({ success: true, data: settings }); - } catch (error) { - console.error('Failed to read auto-context settings:', error); - return NextResponse.json( - { success: false, error: 'Unable to read auto-context settings' }, - { status: 500 } - ); - } -} - -export async function PUT(request: NextRequest) { - try { - const body = await request.json(); - if (!body || typeof body.autoContextEnabled !== 'boolean') { - return NextResponse.json( - { success: false, error: 'autoContextEnabled boolean is required' }, - { status: 400 } - ); - } - - const updated = setAutoContextEnabled(body.autoContextEnabled); - return NextResponse.json({ success: true, data: updated }); - } catch (error) { - console.error('Failed to update auto-context settings:', error); - return NextResponse.json( - { success: false, error: 'Unable to update auto-context settings' }, - { status: 500 } - ); - } -} diff --git a/app/evals/EvalsClient.tsx b/app/evals/EvalsClient.tsx index 8183c59..3cd4330 100644 --- a/app/evals/EvalsClient.tsx +++ b/app/evals/EvalsClient.tsx @@ -9,28 +9,99 @@ type Props = { id: string; name: string; description?: string; + categories?: string[]; tools?: string[]; enabled?: boolean; notes?: string; }>; + ingestionGoldenDataset?: { + id: string; + name: string; + status: string; + created_at: string; + purpose: string; + notes: string[]; + shared_assertions: Record; + cases: Array<{ + id: string; + surface: string; + kind: string; + fixture: string; + input_description: string; + priority: string; + link_expected: boolean; + auto_edge_expected: string; + known_risk?: string; + subcases?: string[]; + }>; + }; }; -function formatPreview(text: string | null, max = 80) { +type TraceView = { + trace: EvalTrace; + id: string; + source: 'live' | 'scenario'; + sourceLabel: string; + scenario: string; + categories: string[]; + model: string; + latency: number | null; + totalTokens: number | null; + cost: number | null; + cacheHit: boolean | null; + cacheTokensLabel: string; + toolCount: number; + toolsUsed: string[]; + status: 'success' | 'fail' | 'n/a'; + userPreview: string; + timestamp: string; + mode: string; + workflow: string; + activityType: 'adding' | 'interacting' | 'memory' | 'scenario' | 'other'; + domain: 'ingestion' | 'interaction' | 'other'; + domainLabel: string; + activityLabel: string; + activityReason: string; + issues: string[]; + needsReview: boolean; + evidenceSummary: string; +}; + +function formatPreview(text: string | null, max = 110) { if (!text) return ''; const compact = text.replace(/\s+/g, ' ').trim(); return compact.length > max ? `${compact.slice(0, max)}...` : compact; } -function statusLabel(success: number | null) { +function statusLabel(success: number | null): 'success' | 'fail' | 'n/a' { if (success === null) return 'n/a'; return success ? 'success' : 'fail'; } -function badgeStyle(kind: 'success' | 'fail' | 'neutral') { - const base = { display: 'inline-block', padding: '2px 8px', borderRadius: 999, fontSize: 12 }; - if (kind === 'success') return { ...base, background: '#e6f4ea', color: '#146c2e' }; - if (kind === 'fail') return { ...base, background: '#fdecea', color: '#b42318' }; - return { ...base, background: '#f2f2f2', color: '#333' }; +function badgeStyle(kind: 'success' | 'fail' | 'neutral' | 'warning' | 'accent') { + const base = { + display: 'inline-block', + padding: '3px 9px', + borderRadius: 999, + fontSize: 12, + fontWeight: 700, + letterSpacing: '0.01em', + }; + + if (kind === 'success') return { ...base, background: '#dff7e5', color: '#0f6a2d' }; + if (kind === 'fail') return { ...base, background: '#fee2e2', color: '#b42318' }; + if (kind === 'warning') return { ...base, background: '#fff4d6', color: '#8a5a00' }; + if (kind === 'accent') return { ...base, background: '#dbeafe', color: '#1d4ed8' }; + return { ...base, background: '#eef2f7', color: '#4b5563' }; +} + +function panelStyle() { + return { + border: '1px solid #d7dde5', + borderRadius: 18, + background: 'linear-gradient(180deg, #ffffff 0%, #f9fbfd 100%)', + boxShadow: '0 10px 30px rgba(15, 23, 42, 0.06)', + } as const; } function prettyJson(value: string | null) { @@ -52,205 +123,725 @@ function parseJsonArray(value: string | null): string[] { } } -export default function EvalsClient({ traces, scenarioList }: Props) { +function parseObject(value: string | null): Record | null { + if (!value) return null; + try { + const parsed = JSON.parse(value); + return parsed && typeof parsed === 'object' ? parsed as Record : null; + } catch { + return null; + } +} + +function formatMoney(value: number | null) { + if (value == null) return 'n/a'; + return `$${value.toFixed(4)}`; +} + +function formatLatency(value: number | null) { + if (value == null) return 'n/a'; + if (value < 1000) return `${value} ms`; + return `${(value / 1000).toFixed(1)} s`; +} + +function average(values: Array) { + const usable = values.filter((value): value is number => typeof value === 'number'); + if (usable.length === 0) return null; + return usable.reduce((sum, value) => sum + value, 0) / usable.length; +} + +function classifyActivity(trace: EvalTrace, categories: string[], toolsUsed: string[]) { + const toolSet = new Set(toolsUsed); + const userText = (trace.chat.user_message || '').toLowerCase(); + + if ( + toolSet.has('createNode') || + toolSet.has('createEdge') || + toolSet.has('updateNode') || + toolSet.has('websiteExtract') || + toolSet.has('youtubeExtract') || + toolSet.has('paperExtract') || + categories.includes('ingestion') + ) { + return { + type: 'adding' as const, + domain: 'ingestion' as const, + domainLabel: 'Ingestion', + label: 'Adding Stuff', + reason: 'Creates or ingests graph content', + }; + } + + if ( + userText.includes('remember') || + userText.includes('main project') || + userText.includes('preferred name') || + categories.includes('tools') && trace.chat.scenario_id?.startsWith('context-capsule-') + ) { + return { + type: 'memory' as const, + domain: 'interaction' as const, + domainLabel: 'Interaction', + label: 'Memory & Profile', + reason: 'Cross-session identity, preferences, or memory retrieval', + }; + } + + if ( + toolSet.has('queryNodes') || + toolSet.has('searchContentEmbeddings') || + toolSet.has('queryEdge') || + toolSet.has('readSkill') || + toolSet.has('rah_read_skill') || + categories.includes('search') || + categories.includes('skills') || + categories.includes('database') + ) { + return { + type: trace.chat.scenario_id ? 'scenario' as const : 'interacting' as const, + domain: 'interaction' as const, + domainLabel: 'Interaction', + label: trace.chat.scenario_id ? 'Scenario Eval' : 'Interacting With Stuff', + reason: trace.chat.scenario_id ? 'Synthetic scenario coverage' : 'Looks up, traverses, or reasons over existing graph content', + }; + } + + if (trace.chat.scenario_id) { + return { + type: 'scenario' as const, + domain: 'other' as const, + domainLabel: 'Other', + label: 'Scenario Eval', + reason: 'Synthetic test scenario', + }; + } + + return { + type: 'other' as const, + domain: 'other' as const, + domainLabel: 'Other', + label: 'General Chat', + reason: 'Conversation is logged, but not strongly classified yet', + }; +} + +function findIssues(trace: EvalTrace, toolsUsed: string[]) { + const issues: string[] = []; + const firstToolArgs = parseObject(trace.toolCalls[0]?.args_json ?? null); + const firstToolResult = parseObject(trace.toolCalls[0]?.result_json ?? null); + const firstResultData = + firstToolResult && typeof firstToolResult.data === 'object' && firstToolResult.data + ? firstToolResult.data as Record + : null; + + if (trace.chat.success === 0) issues.push('Trace failed'); + if ((trace.chat.latency_ms ?? 0) >= 15000) issues.push('Slow trace'); + if ((trace.chat.estimated_cost_usd ?? 0) >= 0.005) issues.push('Higher-cost trace'); + if (trace.toolCalls.some((call) => call.success === 0 || call.error)) issues.push('Tool error'); + if (trace.chat.scenario_id && trace.chat.success !== 1) issues.push('Scenario regression'); + if ( + trace.toolCalls[0]?.tool_name === 'queryNodes' && + Array.isArray(firstResultData?.nodes) && + (firstResultData.nodes as unknown[]).length === 0 + ) { + issues.push('Zero-result first search'); + } + if (toolsUsed.length === 0 && (trace.chat.total_tokens ?? 0) > 7000) issues.push('Large prompt with no tool use'); + + return issues; +} + +function summarySentence(view: TraceView) { + if (view.activityType === 'adding') { + return `${view.activityLabel}: this run appears to create or ingest graph content.`; + } + if (view.activityType === 'memory') { + return `${view.activityLabel}: this run is mostly about durable user or agent context.`; + } + if (view.activityType === 'interacting') { + return `${view.activityLabel}: this run looks up or reasons over existing graph content.`; + } + if (view.activityType === 'scenario') { + return `${view.activityLabel}: this is automated coverage for a named workflow.`; + } + return `${view.activityLabel}: trace is visible, but the product-level action is not strongly tagged yet.`; +} + +export default function EvalsClient({ traces, scenarioList, ingestionGoldenDataset }: Props) { const [openTraceId, setOpenTraceId] = useState(traces[0]?.chat.trace_id || null); + const [modeFilter, setModeFilter] = useState<'ingestion' | 'interaction'>('interaction'); const [comments, setComments] = useState>(() => { const initial: Record = {}; traces.forEach((trace) => { - if (trace.comment) { - initial[trace.chat.trace_id] = trace.comment; - } + if (trace.comment) initial[trace.chat.trace_id] = trace.comment; }); return initial; }); - const [sourceFilter, setSourceFilter] = useState('all'); // 'all' | 'live' | 'scenario' + const [sourceFilter, setSourceFilter] = useState('all'); const [scenarioFilter, setScenarioFilter] = useState('all'); + const [categoryFilter, setCategoryFilter] = useState('all'); const [statusFilter, setStatusFilter] = useState('all'); + const [reviewFilter, setReviewFilter] = useState('all'); const [searchFilter, setSearchFilter] = useState(''); - const rows = useMemo(() => { + const scenarioCategoryMap = useMemo( + () => new Map(scenarioList.map((scenario) => [scenario.id, scenario.categories || []])), + [scenarioList] + ); + + const availableCategories = useMemo(() => { + const values = new Set(); + scenarioList.forEach((scenario) => (scenario.categories || []).forEach((category) => values.add(category))); + return Array.from(values).sort(); + }, [scenarioList]); + + const views = useMemo(() => { return traces.map((trace) => { const { chat, toolCalls } = trace; + const source = chat.scenario_id ? 'scenario' : 'live'; + const toolsUsed = parseJsonArray(chat.tools_used_json); + const fallbackTools = toolCalls.map((call) => call.tool_name); + const mergedTools = Array.from(new Set([...toolsUsed, ...fallbackTools])); + const categories = chat.scenario_id ? (scenarioCategoryMap.get(chat.scenario_id) || []) : []; + const activity = classifyActivity(trace, categories, mergedTools); + const issues = findIssues(trace, mergedTools); const status = statusLabel(chat.success); - const isLive = !chat.scenario_id; + const needsReview = status === 'fail' || issues.length > 0 || !(comments[chat.trace_id] || trace.comment || '').trim(); + return { trace, id: chat.trace_id, - source: isLive ? 'live' : 'scenario', + source, + sourceLabel: source === 'live' ? 'Live App Run' : 'Scenario Eval', scenario: chat.scenario_id || '—', + categories, model: chat.model || 'n/a', - latency: chat.latency_ms ?? 'n/a', - tokens: `${chat.input_tokens ?? 0}/${chat.output_tokens ?? 0} (${chat.total_tokens ?? 0})`, + latency: chat.latency_ms ?? null, + totalTokens: chat.total_tokens ?? null, cost: chat.estimated_cost_usd ?? null, - cache: chat.cache_hit == null ? 'n/a' : chat.cache_hit ? 'hit' : 'miss', - cacheTokens: `${chat.cache_read_tokens ?? 0}/${chat.cache_write_tokens ?? 0}`, + cacheHit: chat.cache_hit == null ? null : Boolean(chat.cache_hit), + cacheTokensLabel: `${chat.cache_read_tokens ?? 0}/${chat.cache_write_tokens ?? 0}`, toolCount: chat.tool_calls_count ?? toolCalls.length, + toolsUsed: mergedTools, status, userPreview: formatPreview(chat.user_message), timestamp: chat.ts, mode: chat.mode || 'n/a', workflow: chat.workflow_key || '—', + activityType: activity.type, + domain: activity.domain, + domainLabel: activity.domainLabel, + activityLabel: activity.label, + activityReason: activity.reason, + issues, + needsReview, + evidenceSummary: summarySentence({ + trace, + id: chat.trace_id, + source, + sourceLabel: '', + scenario: chat.scenario_id || '—', + categories, + model: chat.model || 'n/a', + latency: chat.latency_ms ?? null, + totalTokens: chat.total_tokens ?? null, + cost: chat.estimated_cost_usd ?? null, + cacheHit: chat.cache_hit == null ? null : Boolean(chat.cache_hit), + cacheTokensLabel: '', + toolCount: chat.tool_calls_count ?? toolCalls.length, + toolsUsed: mergedTools, + status, + userPreview: formatPreview(chat.user_message), + timestamp: chat.ts, + mode: chat.mode || 'n/a', + workflow: chat.workflow_key || '—', + activityType: activity.type, + domain: activity.domain, + domainLabel: activity.domainLabel, + activityLabel: activity.label, + activityReason: activity.reason, + issues, + needsReview, + evidenceSummary: '', + }), }; }); - }, [traces]); + }, [traces, scenarioCategoryMap, comments]); - const filteredRows = useMemo(() => { - return rows.filter((row) => { - if (sourceFilter !== 'all' && row.source !== sourceFilter) return false; - if (scenarioFilter !== 'all' && row.scenario !== scenarioFilter) return false; - if (statusFilter !== 'all' && row.status !== statusFilter) return false; + const domainViews = useMemo(() => { + return views.filter((view) => view.domain === modeFilter); + }, [views, modeFilter]); + + const filteredViews = useMemo(() => { + return views.filter((view) => { + if (sourceFilter !== 'all' && view.source !== sourceFilter) return false; + if (scenarioFilter !== 'all' && view.scenario !== scenarioFilter) return false; + if (categoryFilter !== 'all' && !view.categories.includes(categoryFilter)) return false; + if (statusFilter !== 'all' && view.status !== statusFilter) return false; + if (reviewFilter === 'needs-review' && !view.needsReview) return false; + if (reviewFilter === 'reviewed' && view.needsReview) return false; if (searchFilter.trim()) { const needle = searchFilter.toLowerCase(); - if (!row.userPreview.toLowerCase().includes(needle)) return false; + if ( + !view.userPreview.toLowerCase().includes(needle) && + !view.toolsUsed.join(' ').toLowerCase().includes(needle) && + !view.activityLabel.toLowerCase().includes(needle) && + !view.domainLabel.toLowerCase().includes(needle) + ) { + return false; + } } return true; }); - }, [rows, sourceFilter, scenarioFilter, statusFilter, searchFilter]); + }, [views, sourceFilter, scenarioFilter, categoryFilter, statusFilter, reviewFilter, searchFilter]); - const openTrace = traces.find((trace) => trace.chat.trace_id === openTraceId) || traces[0]; + const filteredScenarioList = useMemo(() => { + if (categoryFilter === 'all') return scenarioList; + return scenarioList.filter((scenario) => (scenario.categories || []).includes(categoryFilter)); + }, [scenarioList, categoryFilter]); + + const inferredScenarioList = useMemo(() => { + return filteredScenarioList.filter((scenario) => { + const categories = scenario.categories || []; + const tools = scenario.tools || []; + if (modeFilter === 'ingestion') { + return categories.includes('ingestion') || tools.some((tool) => ['createNode', 'createEdge', 'updateNode', 'websiteExtract', 'youtubeExtract', 'paperExtract'].includes(tool)); + } + return categories.some((category) => ['search', 'skills', 'tools', 'database'].includes(category)) || tools.some((tool) => ['queryNodes', 'searchContentEmbeddings', 'queryEdge', 'readSkill', 'rah_read_skill'].includes(tool)); + }); + }, [filteredScenarioList, modeFilter]); + + const goldenDatasetCards = useMemo(() => { + if (modeFilter === 'ingestion' && ingestionGoldenDataset) { + return ingestionGoldenDataset.cases.map((item) => ({ + id: item.id, + name: item.id, + title: item.id, + description: item.input_description, + enabled: true, + source: item.surface, + kind: item.kind, + fixture: item.fixture, + priority: item.priority, + hasExample: false, + exampleTrace: null as TraceView | null, + note: item.known_risk || null, + })); + } + + return inferredScenarioList.map((scenario) => { + const scenarioExample = views.find((view) => view.scenario === scenario.id); + return { + id: scenario.id, + name: scenario.name, + title: scenario.name, + description: scenario.description || 'No description', + enabled: scenario.enabled !== false, + source: 'scenario', + kind: (scenario.categories || []).join(', ') || 'interaction', + fixture: scenario.id, + priority: 'n/a', + hasExample: Boolean(scenarioExample), + exampleTrace: scenarioExample || null, + note: scenario.notes || null, + }; + }); + }, [modeFilter, ingestionGoldenDataset, inferredScenarioList, views]); + + const recentDomainExample = useMemo(() => { + return domainViews[0] || null; + }, [domainViews]); + + const recentScenarioExample = useMemo(() => { + const match = views.find((view) => view.source === 'scenario' && view.domain === modeFilter); + return match || null; + }, [views, modeFilter]); + + const overview = useMemo(() => { + const live = views.filter((view) => view.source === 'live'); + const scenarios = views.filter((view) => view.source === 'scenario'); + const needsReview = views.filter((view) => view.needsReview); + const adding = views.filter((view) => view.activityType === 'adding'); + const interacting = views.filter((view) => view.activityType === 'interacting' || view.activityType === 'memory'); + const successfulScenarios = scenarios.filter((view) => view.status === 'success'); + return { + total: views.length, + liveCount: live.length, + scenarioCount: scenarios.length, + needsReviewCount: needsReview.length, + addingCount: adding.length, + interactingCount: interacting.length, + avgLatency: average(views.map((view) => view.latency)), + avgCost: average(views.map((view) => view.cost)), + avgTokens: average(views.map((view) => view.totalTokens)), + scenarioPassRate: scenarios.length > 0 ? (successfulScenarios.length / scenarios.length) * 100 : null, + }; + }, [views]); + + const reviewQueue = useMemo( + () => views.filter((view) => view.needsReview).slice(0, 6), + [views] + ); + + const activityBreakdown = useMemo(() => { + const defs: Array<{ key: TraceView['activityType']; label: string }> = [ + { key: 'adding', label: 'Adding Stuff' }, + { key: 'interacting', label: 'Interacting With Stuff' }, + { key: 'memory', label: 'Memory & Profile' }, + { key: 'scenario', label: 'Scenario Evals' }, + { key: 'other', label: 'Unclear / Untagged' }, + ]; + + return defs.map((def) => ({ + ...def, + count: views.filter((view) => view.activityType === def.key).length, + })); + }, [views]); + + const visibleCoverage = useMemo(() => { + const hasAdding = views.some((view) => view.activityType === 'adding'); + return { + good: [ + 'Internal agent chat runs are visible end to end.', + 'Tool calls, results, timing, tokens, cost, and system prompts are all inspectable.', + 'Scenario runs are visible beside real app usage.', + ], + gaps: [ + hasAdding + ? 'Graph writes through the internal agent are partially visible as “Adding Stuff”.' + : 'The current sample has little visible “Adding Stuff” activity in this trace store.', + 'Quick Add, MCP ingestion, and other entry surfaces are not explicitly tagged yet.', + 'This page can infer workflows from tool calls, but it cannot yet prove the exact UI surface the user used.', + ], + }; + }, [views]); return ( -
-
-
Scenario Set
-
- - - - - - - - - - - - - {scenarioList.map((scenario) => ( - - - - - - - - - ))} - -
IDNameDescriptionToolsEnabledNotes
{scenario.id}{scenario.name}{scenario.description || '—'}{scenario.tools?.join(', ') || '—'}{scenario.enabled === false ? 'no' : 'yes'}{scenario.notes || '—'}
+
+
+
+ {(['interaction', 'ingestion'] as const).map((mode) => ( + + ))}
-
-
-
+
+ {[ + { label: 'Mode', value: modeFilter, meta: `${modeFilter === 'interaction' ? overview.interactingCount : overview.addingCount} visible traces` }, + { label: 'Golden Scenarios', value: String(goldenDatasetCards.length), meta: `Scenarios matching ${modeFilter}` }, + { label: 'Recent Example', value: recentDomainExample ? recentDomainExample.sourceLabel : 'none', meta: recentDomainExample ? recentDomainExample.userPreview : 'No matching trace yet' }, + { label: 'Needs Review', value: String(domainViews.filter((view) => view.needsReview).length), meta: `Within ${modeFilter}` }, + ].map((card) => ( +
+
{card.label}
+
{card.value}
+
{card.meta}
+
+ ))} +
+ + +
+
+
+

Golden Dataset

+
+ You and me defining the expected action, trace, and output for {modeFilter} +
+
+
+ {goldenDatasetCards.length === 0 ? ( +
+ No scenario set is clearly tagged for {modeFilter} yet. +
+ ) : ( + goldenDatasetCards.map((scenario) => { + const scenarioExample = scenario.exampleTrace; + const isOpen = scenarioExample ? openTraceId === scenarioExample.id : false; + return ( +
+ + + {scenarioExample && isOpen ? ( +
+
Most recent example
+
{scenarioExample.userPreview || 'No user message logged'}
+
+ + {scenarioExample.status} + + {formatLatency(scenarioExample.latency)} + {formatMoney(scenarioExample.cost)} +
+
{scenarioExample.evidenceSummary}
+
{new Date(scenarioExample.timestamp).toLocaleString()}
+
+ ) : !scenarioExample && modeFilter === 'ingestion' ? ( +
+ No matching trace is wired to this frozen ingestion case yet. The dataset is loaded, but surface-level trace attribution still needs instrumentation. +
+ ) : null} +
+ ); + }) + )} +
+
+ +
+
+

Most Recent Example

+
+ Latest {modeFilter} trace, expandable in place +
+
+ {recentDomainExample ? ( +
+ + + {openTraceId === recentDomainExample.id ? ( +
+
+ Tokens: {recentDomainExample.totalTokens ?? 'n/a'} + Latency: {formatLatency(recentDomainExample.latency)} + Cost: {formatMoney(recentDomainExample.cost)} +
+
{recentDomainExample.evidenceSummary}
+
+ {recentDomainExample.toolsUsed.map((tool) => ( + {tool} + ))} +
+
+ ) : null} +
+ ) : ( +
+ No recent {modeFilter} example found in the loaded traces. +
+ )} +
+ {visibleCoverage.gaps.join(' ')} +
+
+
+ +
+
+
+

Everything

+
+ Full trace list stays here as a separate section +
+
+
+ Showing {filteredViews.length} of {views.length} visible traces +
+
+ +
+ +
- - - - - - - - - - - - - - - - - - - - - {filteredRows.map((row) => { - const isOpen = row.id === openTraceId; - const statusKind = row.status === 'success' ? 'success' : row.status === 'fail' ? 'fail' : 'neutral'; - const commentValue = comments[row.id] || ''; - return ( - setOpenTraceId(row.id)} - style={{ - cursor: 'pointer', - background: isOpen ? '#eef4ff' : '#fff', - borderBottom: '1px solid #eee', - }} - > - - - - - - - - - - - - - -
SourceScenarioStatusLatencyTokensCostCacheToolsModelModeWorkflowUser InputTimeComment
- - {row.source === 'live' ? '🟢 Live' : '🔬 Scenario'} + +
+ {filteredViews.map((view) => { + const isOpen = openTraceId === view.id; + const note = comments[view.id] || view.trace.comment || ''; + + return ( +
setOpenTraceId(isOpen ? null : view.id)} + style={{ + padding: 16, + borderRadius: 16, + border: isOpen ? '1px solid #60a5fa' : '1px solid #dbe3ec', + background: isOpen ? '#f5faff' : '#fff', + cursor: 'pointer', + }} + > +
+
+ {view.sourceLabel} + + {view.status} -
{row.scenario}{row.status}{row.latency} ms{row.tokens}{row.cost == null ? 'n/a' : `$${row.cost.toFixed(4)}`}{row.cache} ({row.cacheTokens}){row.toolCount}{row.model}{row.mode}{row.workflow}{row.userPreview}{row.timestamp} + + {view.domainLabel} + + {view.issues.map((issue) => ( + {issue} + ))} + +
{new Date(view.timestamp).toLocaleString()}
+ + +
+ {view.userPreview || 'No user message logged'} +
+ +
+
+
{view.evidenceSummary}
+
+ {view.activityReason} + {view.scenario !== '—' ? ` • Scenario: ${view.scenario}` : ''} +
+ {view.toolsUsed.length > 0 ? ( +
+ {view.toolsUsed.map((tool) => ( + {tool} + ))} +
+ ) : null} +
+ +
+
Latency: {formatLatency(view.latency)}
+
Tokens: {view.totalTokens ?? 'n/a'}
+
Cost: {formatMoney(view.cost)}
+
Tools: {view.toolCount}
+
Cache: {view.cacheHit == null ? 'n/a' : view.cacheHit ? 'hit' : 'miss'}
+
+
+ +
+
+ {note.trim() ? `Note: ${formatPreview(note, 100)}` : 'Open the trace to add a note or review comment.'} +
+
+ {note.trim() ? 'Reviewed' : 'Needs note'} +
+
+ + {isOpen ? ( +
event.stopPropagation()} + style={{ marginTop: 14, borderTop: '1px solid #e2e8f0', paddingTop: 14, display: 'grid', gap: 14 }} + > +
+ {[ + ['Trace ID', view.trace.chat.trace_id], + ['Scenario', view.trace.chat.scenario_id || '—'], + ['Model', view.trace.chat.model || 'n/a'], + ['Latency', formatLatency(view.trace.chat.latency_ms ?? null)], + ['Tokens', view.trace.chat.total_tokens == null ? 'n/a' : String(view.trace.chat.total_tokens)], + ['Cost', formatMoney(view.trace.chat.estimated_cost_usd ?? null)], + ].map(([label, value]) => ( +
+
{label}
+
{value}
+
+ ))} +
+