diff --git a/README.md b/README.md index f3be215..ae0efac 100644 --- a/README.md +++ b/README.md @@ -29,10 +29,10 @@ Your data stays on your machine. Nothing is sent anywhere unless you configure a Current contract: - no runtime `dimensions` -- optional `contexts` +- no separate runtime `contexts` layer or context capsule - node quality comes from `title`, `description`, `source`, `metadata`, and explicit `edges` - direct node lookup first for specific-node intent -- broader retrieval only when graph context would help +- `getContext` for orientation and `retrieveQueryContext` for broader current-turn grounding - standalone MCP writes node data, but the app owns chunking and embeddings --- @@ -130,18 +130,16 @@ If you publish a newer MCP release and want clients to use it immediately, bump } ``` -**What happens:** Once connected, the agent should use `queryNodes` for specific existing-node lookup, `retrieveQueryContext` when broader graph context would help, and `getContext` only for orientation. It should search before creating, keep context optional by default, and propose durable writeback selectively instead of pestering. The MCP server stores source on the node. The app later turns that source into chunks and embeddings. +**What happens:** Once connected, the agent should use `queryNodes` for specific existing-node lookup, `retrieveQueryContext` when broader graph grounding would help, and `getContext` only for orientation. It should search before creating, propose durable writeback selectively instead of pestering, and treat the graph itself as the source of grounding rather than a separate contexts layer. The MCP server stores source on the node. The app later turns that source into chunks and embeddings. Available tools: | Tool | What it does | |------|--------------| -| `getContext` | Get graph overview — stats, contexts, hub nodes, recent activity | +| `getContext` | Get graph overview — stats, hub nodes, guides, and orientation signals | | `retrieveQueryContext` | Pull relevant graph context for a broader current-turn task | -| `queryContexts` | List contexts, inspect a context, or search contexts | | `queryNodes` | Find nodes by keyword | | `createNode` | Create a new node | -| `writeContext` | Save one confirmed durable context node after explicit user approval | | `getNodesById` | Fetch nodes by ID | | `updateNode` | Edit an existing node | | `createEdge` | Link two nodes together after explicit confirmation | @@ -158,7 +156,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" -- "Show me the nodes connected to my research context" +- "Show me the nodes connected to this project idea" --- diff --git a/app/api/contexts/[id]/nodes/route.ts b/app/api/contexts/[id]/nodes/route.ts deleted file mode 100644 index 20a77a6..0000000 --- a/app/api/contexts/[id]/nodes/route.ts +++ /dev/null @@ -1,22 +0,0 @@ -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 deleted file mode 100644 index a633e81..0000000 --- a/app/api/contexts/[id]/route.ts +++ /dev/null @@ -1,26 +0,0 @@ -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 deleted file mode 100644 index 2969194..0000000 --- a/app/api/contexts/route.ts +++ /dev/null @@ -1,47 +0,0 @@ -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/health/vectors/route.ts b/app/api/health/vectors/route.ts index a6034ec..fb94fe0 100644 --- a/app/api/health/vectors/route.ts +++ b/app/api/health/vectors/route.ts @@ -5,8 +5,6 @@ import { chunkService } from '@/services/database/chunks'; export async function GET() { try { const sqlite = getSQLiteClient(); - const vectorCapability = sqlite.getVectorCapability(); - // Test basic database connection const connectionTest = await sqlite.testConnection(); if (!connectionTest) { @@ -21,7 +19,7 @@ export async function GET() { const vectorExtensionTest = await sqlite.checkVectorExtension(); let vectorStats = null; let chunkStats = null; - let vectorHealth = vectorCapability.available ? 'healthy' : 'unavailable'; + let vectorHealth = vectorExtensionTest ? 'healthy' : 'unavailable'; try { const totalChunks = await chunkService.getChunkCount(); @@ -32,7 +30,7 @@ export async function GET() { coverage_percentage: null, }; - if (vectorCapability.available && vectorExtensionTest) { + if (vectorExtensionTest) { try { const chunksWithoutEmbeddings = await chunkService.getChunksWithoutEmbeddings(); const vectorizedCount = totalChunks - chunksWithoutEmbeddings.length; @@ -62,9 +60,8 @@ export async function GET() { } else { vectorHealth = 'unavailable'; vectorStats = { - backend: vectorCapability.backend, - extension_path: vectorCapability.extensionPath, - reason: vectorCapability.available ? null : vectorCapability.reason, + extension_loaded: false, + reason: 'Vector extension unavailable in this environment.', }; } @@ -81,7 +78,10 @@ export async function GET() { data: { database_connected: connectionTest, vector_extension_loaded: vectorExtensionTest, - vector_capability: vectorCapability, + vector_capability: { + available: vectorExtensionTest, + backend: vectorExtensionTest ? 'sqlite-vec' : 'unavailable', + }, vector_health: vectorHealth, chunk_stats: chunkStats, vector_stats: vectorStats, diff --git a/app/api/mcp/route.ts b/app/api/mcp/route.ts index c8a5668..4df1dc8 100644 --- a/app/api/mcp/route.ts +++ b/app/api/mcp/route.ts @@ -13,15 +13,13 @@ const SERVER_INFO = { const instructions = [ 'RA-H is a personal knowledge graph — local-first, vendor-neutral.', - 'Core concepts: contexts (optional soft scopes, max 10), nodes (knowledge units), and edges (connections with explanations).', + 'Core concepts: nodes (knowledge units) and edges (connections with explanations).', 'If the user is trying to find a specific existing node, use rah_search_nodes first.', 'If the user is asking a broader question or request that would benefit from graph context, use rah_retrieve_query_context.', 'Use rah_get_context only for orientation when high-level graph state would actually help.', - 'Use contexts only when one obvious existing context is explicit and helpful. If unsure or if none exist, leave context empty. Do not expect automatic context assignment.', 'Search before creating: use rah_search_nodes to check if content already exists.', 'Every edge needs an explanation: why does this connection exist?', 'Never create or update an edge unless the user has explicitly confirmed the relationship.', - 'Never write via rah_write_context unless the user has explicitly confirmed yes.', ].join(' '); function getBaseUrl(request: NextRequest): string { @@ -56,19 +54,18 @@ function createServer(request: NextRequest): McpServer { 'rah_add_node', { title: 'Add RA-H node', - description: 'Create a new node in the local RA-H knowledge base after you have already decided a net-new write is correct. If the user explicitly asked to save or import something and the target artifact is clear, write after duplicate/update checks. If you are only suggesting a save, propose the node first and wait for confirmation. Leave context blank by default. If the user explicitly wants context, use `context_name` rather than a numeric ID.', + description: 'Create a new node in the local RA-H knowledge base after you have already decided a net-new write is correct. If the user explicitly asked to save or import something and the target artifact is clear, write after duplicate/update checks. If you are only suggesting a save, propose the node first and wait for confirmation.', 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_name: z.string().optional(), metadata: z.record(z.any()).optional(), chunk: z.string().max(50000).optional(), }, }, - async ({ title, content, source, link, description, context_name, metadata, chunk }) => { + async ({ title, content, source, link, description, metadata, chunk }) => { const payload = await callRaHApi(request, '/api/nodes', { method: 'POST', body: JSON.stringify({ @@ -76,7 +73,6 @@ function createServer(request: NextRequest): McpServer { source: source?.trim() || content?.trim() || chunk?.trim() || undefined, link: link?.trim() || undefined, description: description?.trim() || undefined, - context_name: context_name?.trim() || undefined, metadata: metadata || {}, }), }); @@ -97,20 +93,26 @@ function createServer(request: NextRequest): McpServer { 'rah_search_nodes', { title: 'Search RA-H nodes', - description: 'Find existing RA-H entries that mention a topic before adding new ones. Leave context blank by default. If the user explicitly wants a context-specific lookup, use `context_name` rather than a numeric ID. For full current-turn grounding of a substantive request, prefer `rah_retrieve_query_context`.', + description: 'Find existing RA-H entries that mention a topic before adding new ones. For full current-turn grounding of a substantive request, prefer `rah_retrieve_query_context`.', inputSchema: { query: z.string().min(1).max(400), limit: z.number().min(1).max(25).optional(), - context_name: z.string().optional(), + createdAfter: z.string().optional(), + createdBefore: z.string().optional(), + eventAfter: z.string().optional(), + eventBefore: z.string().optional(), }, }, - async ({ query, limit = 10, context_name }) => { + async ({ query, limit = 10, createdAfter, createdBefore, eventAfter, eventBefore }) => { const payload = await callRaHApi(request, '/api/nodes/direct-search', { method: 'POST', body: JSON.stringify({ query: query.trim(), limit: Math.min(Math.max(limit, 1), 25), - context_name: typeof context_name === 'string' ? context_name.trim() : undefined, + createdAfter: typeof createdAfter === 'string' ? createdAfter.trim() : undefined, + createdBefore: typeof createdBefore === 'string' ? createdBefore.trim() : undefined, + eventAfter: typeof eventAfter === 'string' ? eventAfter.trim() : undefined, + eventBefore: typeof eventBefore === 'string' ? eventBefore.trim() : undefined, }), }); const nodes = Array.isArray(payload.data?.nodes) ? payload.data.nodes : []; @@ -140,17 +142,15 @@ function createServer(request: NextRequest): McpServer { inputSchema: { query: z.string().min(1).max(1000), focused_node_id: z.number().int().positive().nullable().optional(), - active_context_id: z.number().int().positive().nullable().optional(), limit: z.number().min(1).max(20).optional(), }, }, - async ({ query, focused_node_id, active_context_id, limit = 6 }) => { + async ({ query, focused_node_id, limit = 6 }) => { const payload = await callRaHApi(request, '/api/retrieval/query-context', { method: 'POST', body: JSON.stringify({ query, focused_node_id: focused_node_id ?? null, - active_context_id: active_context_id ?? null, limit, }), }); @@ -167,93 +167,11 @@ function createServer(request: NextRequest): McpServer { } ); - server.registerTool( - 'rah_query_contexts', - { - title: 'Query RA-H contexts', - description: 'List or inspect optional contexts. Use this only when a context is already obviously relevant or the user asks for it.', - 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 when it is clearly the same artifact and a net-new node would be redundant. Explicit user-directed updates can proceed once the target node is clear. Context is preserved by default. If the user explicitly wants to change context, use `context_name`. Use `clear_context` only when the user explicitly wants to remove the node context.', + description: 'Update an existing node when it is clearly the same artifact and a net-new node would be redundant. Explicit user-directed updates can proceed once the target node is clear.', inputSchema: { id: z.number().int().positive(), updates: z.object({ @@ -262,8 +180,6 @@ function createServer(request: NextRequest): McpServer { content: z.string().optional(), source: z.string().optional(), link: z.string().optional(), - context_name: z.string().optional(), - clear_context: z.boolean().optional(), metadata: z.record(z.any()).optional(), }), }, @@ -283,10 +199,6 @@ function createServer(request: NextRequest): McpServer { delete mappedUpdates.content; delete mappedUpdates.chunk; - if (mappedUpdates.context_name && mappedUpdates.clear_context) { - throw new Error('context_name cannot be combined with clear_context: true.'); - } - const payload = await callRaHApi(request, `/api/nodes/${id}`, { method: 'PUT', body: JSON.stringify(mappedUpdates), @@ -571,15 +483,15 @@ function createServer(request: NextRequest): McpServer { 'rah_get_context', { title: 'Get RA-H context', - description: 'Get orientation context: high-level graph state, optional contexts, hub nodes, stats, and available guides.', + description: 'Get orientation context: high-level graph state, hub nodes, stats, and available guides.', inputSchema: {}, }, async () => { - const [hubPayload, contextsPayload, guidesPayload, countPayload] = await Promise.all([ - callRaHApi(request, '/api/nodes?sortBy=edges&limit=5'), - callRaHApi(request, '/api/contexts'), + const [hubPayload, guidesPayload, countPayload, edgesPayload] = await Promise.all([ + callRaHApi(request, '/api/nodes?sortBy=edges&limit=10'), callRaHApi(request, '/api/guides').catch(() => ({ data: [] })), - callRaHApi(request, '/api/nodes?limit=1').catch(() => ({ total: 0 })), + callRaHApi(request, '/api/nodes?limit=1').catch(() => ({ total: 0, count: 0 })), + callRaHApi(request, '/api/edges?limit=1').catch(() => ({ count: 0, total: 0 })), ]); const hubNodes = Array.isArray(hubPayload.data) ? hubPayload.data.map((node: any) => ({ @@ -589,81 +501,24 @@ function createServer(request: NextRequest): McpServer { edgeCount: node.edge_count ?? 0, })) : []; - 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 guides = Array.isArray(guidesPayload.data) ? guidesPayload.data.map((guide: any) => guide.name) : []; + const nodeCount = countPayload.total ?? countPayload.count ?? 0; + const edgeCount = edgesPayload.total ?? edgesPayload.count ?? 0; return { - content: [{ type: 'text', text: `Knowledge graph: ${contexts.length} optional contexts, ${hubNodes.length} hub nodes, ${guides.length} guides available.` }], + content: [{ type: 'text', text: `Knowledge graph: ${nodeCount} nodes, ${edgeCount} edges, ${guides.length} guides available.` }], structuredContent: { stats: { - nodeCount: countPayload.total ?? 0, - edgeCount: 0, - contextCount: contexts.length, + nodeCount, + edgeCount, }, hubNodes, - contexts, guides, }, }; } ); - server.registerTool( - 'rah_write_context', - { - title: 'Write RA-H context node', - description: 'Write one atomic durable context node to the graph only after the user has explicitly approved the save. Use this for agent-suggested capture after you already proposed the node briefly and got a clear yes. Prefer ordinary create/update flows for explicit user-directed capture.', - inputSchema: { - title: z.string().min(1).max(160), - description: z.string().min(1).max(500), - source: z.string().max(50000).optional(), - context_name: z.string().optional(), - metadata: z.record(z.any()).optional(), - confirmed_by_user: z.boolean(), - }, - }, - async ({ title, description, source, context_name, metadata, confirmed_by_user }) => { - if (!confirmed_by_user) { - throw new Error('rah_write_context requires explicit user confirmation before writing to the graph.'); - } - - const payload = await callRaHApi(request, '/api/nodes', { - method: 'POST', - body: JSON.stringify({ - title: title.trim(), - description: description.trim(), - source: source?.trim() || undefined, - context_name: context_name?.trim() || undefined, - metadata: { - captured_by: 'human', - captured_method: 'write_context', - ...(metadata || {}), - }, - }), - }); - - const node = payload.data; - const message = payload.message || `Saved context as node #${node.id}: ${node.title}`; - - return { - content: [{ type: 'text', text: message }], - structuredContent: { - success: true, - nodeId: node.id, - title: node.title, - message, - }, - }; - } - ); - return server; } @@ -717,7 +572,6 @@ export async function GET(request: NextRequest) { 'rah_add_node', 'rah_search_nodes', 'rah_retrieve_query_context', - 'rah_query_contexts', 'rah_update_node', 'rah_get_nodes', 'rah_create_edge', @@ -728,7 +582,6 @@ export async function GET(request: NextRequest) { 'rah_extract_youtube', 'rah_extract_pdf', 'rah_get_context', - 'rah_write_context', ]; return NextResponse.json( diff --git a/app/api/nodes/[id]/route.ts b/app/api/nodes/[id]/route.ts index f8ae468..d7f2138 100644 --- a/app/api/nodes/[id]/route.ts +++ b/app/api/nodes/[id]/route.ts @@ -1,8 +1,9 @@ import { NextRequest, NextResponse } from 'next/server'; -import { contextService, nodeService } from '@/services/database'; +import { nodeService } from '@/services/database'; import { autoEmbedQueue } from '@/services/embedding/autoEmbedQueue'; import { hasSufficientContent } from '@/services/embedding/constants'; import { coerceDescriptionForStorage } from '@/services/database/quality'; +import { applyRequestSupabaseAuth, getCurrentSupabaseToken } from '@/services/auth/internalAuth'; import { normalizeNodeLink } from '@/utils/nodeLink'; import { mergeNodeMetadata } from '@/services/nodes/metadata'; @@ -12,6 +13,7 @@ export async function GET( request: NextRequest, { params }: { params: Promise<{ id: string }> } ) { + const cleanupAuth = applyRequestSupabaseAuth(request); try { const { id } = await params; const nodeId = parseInt(id, 10); @@ -43,6 +45,8 @@ export async function GET( success: false, error: error instanceof Error ? error.message : 'Failed to fetch node' }, { status: 500 }); + } finally { + cleanupAuth(); } } @@ -50,6 +54,7 @@ export async function PUT( request: NextRequest, { params }: { params: Promise<{ id: string }> } ) { + const cleanupAuth = applyRequestSupabaseAuth(request); try { const { id } = await params; const nodeId = parseInt(id, 10); @@ -100,34 +105,6 @@ export async function PUT( updates.metadata = mergeNodeMetadata(existingNode.metadata, body.metadata); } - const hasContextName = typeof body.context_name === 'string' && body.context_name.trim().length > 0; - const wantsClearContext = body.clear_context === true; - - delete updates.context_name; - delete updates.clear_context; - - if (hasContextName && wantsClearContext) { - return NextResponse.json({ - success: false, - error: 'context_name cannot be combined with clear_context: true.' - }, { status: 400 }); - } - - if (hasContextName || Object.prototype.hasOwnProperty.call(body, 'context_id') || wantsClearContext) { - try { - const resolvedContextId = await contextService.resolveContextId({ - context_id: wantsClearContext ? null : body.context_id, - context_name: hasContextName ? body.context_name : undefined, - }); - updates.context_id = resolvedContextId; - } catch (error) { - return NextResponse.json({ - success: false, - error: error instanceof Error ? error.message : 'Invalid context input' - }, { status: 400 }); - } - } - const incomingSource = typeof body.source === 'string' ? body.source : undefined; const existingSource = existingNode.source ?? ''; @@ -147,9 +124,11 @@ export async function PUT( const node = await nodeService.updateNode(nodeId, updates); - if (shouldQueueEmbed) { - autoEmbedQueue.enqueue(nodeId, { reason: 'node_updated' }); - } + if (shouldQueueEmbed) { + autoEmbedQueue.enqueue(nodeId, { + reason: 'node_updated', + }); + } return NextResponse.json({ success: true, @@ -162,6 +141,8 @@ export async function PUT( success: false, error: error instanceof Error ? error.message : 'Failed to update node' }, { status: 500 }); + } finally { + cleanupAuth(); } } @@ -169,6 +150,7 @@ export async function DELETE( request: NextRequest, { params }: { params: Promise<{ id: string }> } ) { + const cleanupAuth = applyRequestSupabaseAuth(request); try { const { id } = await params; const nodeId = parseInt(id, 10); @@ -193,5 +175,7 @@ export async function DELETE( success: false, error: error instanceof Error ? error.message : 'Failed to delete node' }, { status: 500 }); + } finally { + cleanupAuth(); } } diff --git a/app/api/nodes/direct-search/route.ts b/app/api/nodes/direct-search/route.ts index be2b9f1..4e719a4 100644 --- a/app/api/nodes/direct-search/route.ts +++ b/app/api/nodes/direct-search/route.ts @@ -1,9 +1,12 @@ import { NextRequest, NextResponse } from 'next/server'; +import { applyRequestSupabaseAuth } from '@/services/auth/internalAuth'; import { directNodeLookup } from '@/services/retrieval/directNodeLookup'; export const runtime = 'nodejs'; export async function POST(request: NextRequest) { + const cleanupAuth = applyRequestSupabaseAuth(request); + try { const body = await request.json(); const query = typeof body.query === 'string' ? body.query : ''; @@ -18,8 +21,6 @@ export async function POST(request: NextRequest) { const result = await directNodeLookup({ search: query, limit: typeof body.limit === 'number' ? body.limit : undefined, - context_name: typeof body.context_name === 'string' ? body.context_name : undefined, - contextId: typeof body.contextId === 'number' ? body.contextId : undefined, createdAfter: typeof body.createdAfter === 'string' ? body.createdAfter : undefined, createdBefore: typeof body.createdBefore === 'string' ? body.createdBefore : undefined, eventAfter: typeof body.eventAfter === 'string' ? body.eventAfter : undefined, @@ -39,5 +40,7 @@ export async function POST(request: NextRequest) { success: false, error: error instanceof Error ? error.message : 'Failed to run direct node lookup', }, { status: 500 }); + } finally { + cleanupAuth(); } } diff --git a/app/api/nodes/route.ts b/app/api/nodes/route.ts index 6fac629..b9f2696 100644 --- a/app/api/nodes/route.ts +++ b/app/api/nodes/route.ts @@ -1,15 +1,17 @@ import { NextRequest, NextResponse } from 'next/server'; -import { contextService, nodeService } from '@/services/database'; +import { nodeService } from '@/services/database'; import { Node, NodeFilters } from '@/types/database'; import { autoEmbedQueue } from '@/services/embedding/autoEmbedQueue'; import { generateDescription } from '@/services/database/descriptionService'; import { coerceDescriptionForStorage } from '@/services/database/quality'; +import { applyRequestSupabaseAuth, getCurrentSupabaseToken } from '@/services/auth/internalAuth'; import { normalizeNodeLink } from '@/utils/nodeLink'; import { buildCanonicalNodeMetadata } from '@/services/nodes/metadata'; export const runtime = 'nodejs'; export async function GET(request: NextRequest) { + const cleanupAuth = applyRequestSupabaseAuth(request); try { const searchParams = request.nextUrl.searchParams; @@ -19,14 +21,6 @@ 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 sortBy parameter (sortBy=edges|updated|created) const sortByParam = searchParams.get('sortBy'); if (sortByParam === 'edges' || sortByParam === 'updated' || sortByParam === 'created' || sortByParam === 'event_date') { @@ -57,6 +51,8 @@ export async function GET(request: NextRequest) { success: false, error: error instanceof Error ? error.message : 'Failed to fetch nodes' }, { status: 500 }); + } finally { + cleanupAuth(); } } @@ -75,6 +71,7 @@ function sanitizeTitle(title: string): string { } export async function POST(request: NextRequest) { + const cleanupAuth = applyRequestSupabaseAuth(request); try { const body = await request.json(); @@ -148,19 +145,6 @@ export async function POST(request: NextRequest) { ? body.metadata.source : undefined; - let resolvedContextId: number | null | undefined; - try { - resolvedContextId = 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 }); - } - const node = await nodeService.createNode({ title: body.title, description: finalDescription, @@ -168,7 +152,6 @@ export async function POST(request: NextRequest) { event_date: eventDate ?? undefined, link: normalizedLink ?? undefined, chunk_status: chunkStatus, - context_id: resolvedContextId, metadata: buildCanonicalNodeMetadata({ metadata: body.metadata || {}, type: inferredType, @@ -177,7 +160,9 @@ export async function POST(request: NextRequest) { }); if (chunkStatus === 'not_chunked' && node.id) { - autoEmbedQueue.enqueue(node.id, { reason: 'node_created' }); + autoEmbedQueue.enqueue(node.id, { + reason: 'node_created', + }); } return NextResponse.json({ @@ -192,5 +177,7 @@ export async function POST(request: NextRequest) { success: false, error: error instanceof Error ? error.message : 'Failed to create node' }, { status: 500 }); + } finally { + cleanupAuth(); } } diff --git a/app/api/retrieval/query-context/route.ts b/app/api/retrieval/query-context/route.ts index 14b9d88..708d15b 100644 --- a/app/api/retrieval/query-context/route.ts +++ b/app/api/retrieval/query-context/route.ts @@ -1,9 +1,12 @@ import { NextRequest, NextResponse } from 'next/server'; +import { applyRequestSupabaseAuth } from '@/services/auth/internalAuth'; import { retrieveQueryContext } from '@/services/retrieval/queryContext'; export const runtime = 'nodejs'; export async function POST(request: NextRequest) { + const cleanupAuth = applyRequestSupabaseAuth(request); + try { const body = await request.json(); const query = typeof body.query === 'string' ? body.query : ''; @@ -18,7 +21,6 @@ export async function POST(request: NextRequest) { const result = await retrieveQueryContext({ query, focused_node_id: typeof body.focused_node_id === 'number' ? body.focused_node_id : null, - active_context_id: typeof body.active_context_id === 'number' ? body.active_context_id : null, limit: typeof body.limit === 'number' ? body.limit : undefined, }); @@ -31,5 +33,7 @@ export async function POST(request: NextRequest) { success: false, error: error instanceof Error ? error.message : 'Failed to retrieve query context', }, { status: 500 }); + } finally { + cleanupAuth(); } } diff --git a/apps/mcp-server-standalone/README.md b/apps/mcp-server-standalone/README.md index 325dc02..35b7f26 100644 --- a/apps/mcp-server-standalone/README.md +++ b/apps/mcp-server-standalone/README.md @@ -80,12 +80,10 @@ Do not create contradictory instruction files. Prefer one short reinforcement li | Tool | Description | |------|-------------| -| `getContext` | Get graph overview - stats, contexts, recent activity | +| `getContext` | Get graph overview - stats, hub nodes, recent activity | | `retrieveQueryContext` | Pull relevant graph context for a broader current-turn task | | `createNode` | Create a new node | -| `writeContext` | Save one confirmed durable context node after explicit user approval | | `queryNodes` | Search nodes by keyword | -| `queryContexts` | List or inspect contexts | | `getNodesById` | Load nodes by ID | | `updateNode` | Update an existing node | | `createEdge` | Create a confirmed connection between nodes | @@ -121,19 +119,11 @@ Rules: - use `captured_by = "human"` for direct user creation and user-requested agent capture - reserve `captured_by = "agent"` for autonomous/background creation only -## Context Rule - -- creating a node never requires context -- normal node lookup and update flows should omit context unless the user explicitly asks for it -- if context is intentionally provided, prefer `context_name` -- numeric `context_id` is treated as an internal implementation detail rather than a normal agent-facing field - ## Writeback Rule - do not ask to save every moderately useful point from the conversation - only suggest a save when the context is unusually durable and valuable - keep the ask terse and concrete, for example: `Add "X" as a node?` -- never call `writeContext` unless the user has explicitly said yes ## Edge Rule diff --git a/apps/mcp-server-standalone/index.js b/apps/mcp-server-standalone/index.js index 1bc5a25..a52838c 100644 --- a/apps/mcp-server-standalone/index.js +++ b/apps/mcp-server-standalone/index.js @@ -32,7 +32,6 @@ const packageJson = require('./package.json'); 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 skillService = require('./services/skillService'); const retrievalService = require('./services/retrievalService'); const { directNodeLookup } = require('./services/directNodeLookupService'); @@ -68,17 +67,10 @@ function buildInstructions() { 5. For simple tasks, tool descriptions have everything you need. 6. For complex tasks, call readSkill("db-operations"). -## Context field rule -Context is optional on writes. -Do not include any context field unless the user explicitly wants one. -If context is intentionally provided, prefer \`context_name\`. Treat numeric \`context_id\` as an internal implementation detail. -Omitting context is the normal default and does not block create or update operations. - ## Knowledge capture -Only suggest saving context when it seems unusually durable and valuable. +Only suggest saving durable knowledge when it seems unusually durable and valuable. Keep the ask brief: Add "X" as a node? Do not pester. Do not keep re-asking if the user says no, ignores it, or moves on. -Never write via writeContext unless the user has explicitly confirmed yes. Do not create edges autonomously. Surface likely edge candidates briefly, then call edge-write tools only after the user explicitly confirms. Always search or retrieve before creating to avoid duplicates. @@ -96,7 +88,6 @@ const addNodeInputSchema = { source: z.string().max(50000).optional().describe('Canonical source content for embedding'), link: z.string().url().optional().describe('Source URL'), description: z.string().optional().describe('Strongly recommended. Write the description as natural prose, not labels or a checklist. It should make clear what the artifact is and any surrounding context available. RA-H will accept whatever description is provided and will not block the write.'), - context_name: z.string().optional().describe('Optional primary context name. Use only when the user explicitly wants this node assigned to a known context.'), metadata: z.record(z.any()).optional().describe('Optional metadata. Prefer canonical keys: type, state, captured_method, captured_by, source_metadata.'), chunk: z.string().max(50000).optional().describe('Legacy alias for source text') }; @@ -104,7 +95,6 @@ const addNodeInputSchema = { const searchNodesInputSchema = { query: z.string().min(1).max(400).describe('Search query'), limit: z.number().min(1).max(50).optional().describe('Max results (default 10)'), - context_name: z.string().optional().describe('Optional primary context name filter. Use only when the user explicitly wants a context-specific lookup.'), created_after: z.string().optional().describe('ISO date (YYYY-MM-DD). Only return nodes created on or after this date.'), created_before: z.string().optional().describe('ISO date (YYYY-MM-DD). Only return nodes created before this date.'), event_after: z.string().optional().describe('ISO date (YYYY-MM-DD). Only return nodes with event_date on or after this date.'), @@ -114,19 +104,9 @@ const searchNodesInputSchema = { const retrieveQueryContextInputSchema = { query: z.string().min(1).max(800).describe('The raw user query for this turn'), focused_node_id: z.number().int().positive().nullable().optional().describe('Optional currently focused node ID'), - active_context_id: z.number().int().positive().nullable().optional().describe('Optional active context ID as a soft hint'), limit: z.number().min(1).max(12).optional().describe('Maximum number of nodes to return') }; -const writeContextInputSchema = { - title: z.string().min(1).max(160).describe('Clear proposed node title'), - description: z.string().min(1).max(500).describe('Natural description of what this context is and why it matters'), - source: z.string().max(50000).optional().describe('Optional source or verbatim user wording to preserve'), - context_name: z.string().optional().describe('Optional primary context name. Use only when the user explicitly wants this saved under a known context.'), - metadata: z.record(z.any()).optional().describe('Optional metadata patch'), - confirmed_by_user: z.boolean().describe('Must be true before the write is allowed') -}; - const getNodesInputSchema = { nodeIds: z.array(z.number().int().positive()).min(1).max(10).describe('Node IDs to load') }; @@ -139,8 +119,6 @@ const updateNodeInputSchema = { content: z.string().optional().describe('Legacy alias for source'), source: z.string().optional().describe('Canonical source content for embedding'), link: z.string().optional().describe('New link'), - context_name: z.string().optional().describe('Optional primary context name. Use only when the user explicitly wants to assign this node to a known context.'), - clear_context: z.boolean().optional().describe('Set true only when the user explicitly wants to remove the node context.'), metadata: z.record(z.any()).optional().describe('Metadata patch. It now merges with existing metadata. Prefer canonical keys: type, state, captured_method, captured_by, source_metadata.') }).describe('Fields to update') }; @@ -163,14 +141,6 @@ 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 readSkillInputSchema = { name: z.string().min(1).describe('Skill name (e.g. "db-operations", "onboarding", "persona")') }; @@ -276,7 +246,7 @@ async function main() { 'getContext', { title: 'Get RA-H context', - description: 'Get knowledge graph overview: stats, contexts, hub nodes (secondary diagnostics), recent activity, and available skills. Use this for orientation only, not as the default retrieval path for substantive requests. For deeper operating policy, follow up with readSkill("db-operations").', + description: 'Get knowledge graph overview: stats, hub nodes, recent activity, and available skills. Use this for orientation only, not as the default retrieval path for substantive requests. For deeper operating policy, follow up with readSkill("db-operations").', inputSchema: {} }, async () => { @@ -288,16 +258,16 @@ async function main() { // First-run welcome message if (context.stats.nodeCount === 0) { return { - content: [{ type: 'text', text: 'Empty knowledge graph. This is a fresh start. Ask what matters right now and help create the first useful node. Contexts are optional and can wait until one is obviously helpful.' }], + content: [{ type: 'text', text: 'Empty knowledge graph. This is a fresh start. Ask what matters right now and help create the first useful node.' }], structuredContent: { ...context, welcome: true, - suggestion: 'Ask what matters right now, create the first useful node, and leave contexts empty unless one is an obvious fit.' + suggestion: 'Ask what matters right now and create the first useful node.' } }; } - const summary = `Graph: ${context.stats.contextCount || 0} contexts, ${context.stats.nodeCount} nodes, ${context.stats.edgeCount} edges, ${skills.length} skills.`; + const summary = `Graph: ${context.stats.nodeCount} nodes, ${context.stats.edgeCount} edges, ${skills.length} skills.`; return { content: [{ type: 'text', text: summary }], structuredContent: context @@ -314,11 +284,10 @@ async function main() { description: 'Given the raw user query plus optional focused node state, retrieve the most relevant graph context for the current turn. It starts with direct graph search and broadens only if useful. Use this when graph context could help answer or complete a broader task. For explicit node lookup, use queryNodes.', inputSchema: retrieveQueryContextInputSchema }, - async ({ query: rawQuery, focused_node_id, active_context_id, limit = 6 }) => { + async ({ query: rawQuery, focused_node_id, limit = 6 }) => { const result = retrievalService.retrieveQueryContext({ query: rawQuery, focused_node_id: focused_node_id ?? null, - active_context_id: active_context_id ?? null, limit, }); @@ -337,26 +306,18 @@ async function main() { 'createNode', { title: 'Add RA-H node', - description: 'Create a new node. Always search first (queryNodes) to avoid duplicates. If the user explicitly asked to save or import something and the target artifact is clear, write after duplicate/update checks. If you are only suggesting a save, propose the node first and wait for confirmation. Leave context blank by default. If the user explicitly wants context, use `context_name` rather than a numeric ID. Title: max 160 chars, clear and descriptive. Description is strongly recommended and should explicitly describe what the thing is and any surrounding context available, but the write will never be blocked over description quality. Use "link" ONLY for external content (URL, video, article) — omit for synthesis/ideas derived from existing nodes. "source" = verbatim or canonical content stored on the node. The RA-H app owns chunking and embedding from source. Legacy "content" and "chunk" are mapped to source for compatibility.', + description: 'Create a new node. Always search first (queryNodes) to avoid duplicates. If the user explicitly asked to save or import something and the target artifact is clear, write after duplicate/update checks. If you are only suggesting a save, propose the node first and wait for confirmation. Title: max 160 chars, clear and descriptive. Description is strongly recommended and should explicitly describe what the thing is and any surrounding context available, but the write will never be blocked over description quality. Use "link" ONLY for external content (URL, video, article) — omit for synthesis/ideas derived from existing nodes. "source" = verbatim or canonical content stored on the node. The RA-H app owns chunking and embedding from source. Legacy "content" and "chunk" are mapped to source for compatibility.', inputSchema: addNodeInputSchema }, - async ({ title, content, source, link, description, context_name, metadata, chunk }) => { + async ({ title, content, source, link, description, metadata, chunk }) => { const sourceText = source?.trim() || content?.trim() || chunk?.trim(); const normalizedDescription = typeof description === 'string' ? description.trim() : description; - let resolvedContextId; - try { - resolvedContextId = contextService.resolveContextId({ context_name }); - } catch (error) { - throw new Error(error.message); - } - const node = nodeService.createNode({ title: title.trim(), source: sourceText, link: link?.trim(), description: normalizedDescription, - context_id: resolvedContextId, metadata: metadata || {} }); @@ -377,15 +338,14 @@ async function main() { 'queryNodes', { title: 'Search RA-H nodes', - description: 'Search nodes by keyword across title, description, and source fields using the same safe direct-lookup behavior as the app. Use this for direct node lookup or duplicate checks. Leave context blank by default. If the user explicitly wants a context-specific lookup, use context_name rather than a numeric ID. For full current-turn grounding of a substantive query, prefer retrieveQueryContext. NOT for searching source documents (transcripts, articles) — use searchContentEmbeddings for that.', + description: 'Search nodes by keyword across title, description, and source fields using the same safe direct-lookup behavior as the app. Use this for direct node lookup or duplicate checks. For full current-turn grounding of a substantive query, prefer retrieveQueryContext. NOT for searching source documents (transcripts, articles) — use searchContentEmbeddings for that.', inputSchema: searchNodesInputSchema }, - async ({ query: searchQuery, limit = 10, context_name, created_after, created_before, event_after, event_before }) => { + async ({ query: searchQuery, limit = 10, created_after, created_before, event_after, event_before }) => { const safeLimit = Math.min(Math.max(limit, 1), 50); const result = directNodeLookup({ search: searchQuery.trim(), limit: safeLimit, - context_name, createdAfter: created_after, createdBefore: created_before, eventAfter: event_after, @@ -416,50 +376,6 @@ async function main() { } ); - registerToolWithAliases( - 'writeContext', - { - title: 'Write RA-H context node', - description: 'Write one atomic durable context node to the graph only after the user has explicitly approved the save. Use this for agent-suggested capture after you already proposed the node briefly and got a clear yes. Prefer ordinary create/update flows for explicit user-directed capture.', - inputSchema: writeContextInputSchema - }, - async ({ title, description, source, context_name, metadata, confirmed_by_user }) => { - if (!confirmed_by_user) { - throw new Error('writeContext requires explicit user confirmation before writing to the graph.'); - } - - let resolvedContextId; - try { - resolvedContextId = contextService.resolveContextId({ context_name }); - } catch (error) { - throw new Error(error.message); - } - - const node = nodeService.createNode({ - title: title.trim(), - description: description.trim(), - source: source?.trim(), - context_id: resolvedContextId, - metadata: { - captured_by: 'human', - captured_method: 'write_context', - ...(metadata || {}) - } - }); - - const summary = `Saved context as node #${node.id}: ${node.title}`; - return { - content: [{ type: 'text', text: summary }], - structuredContent: { - success: true, - nodeId: node.id, - title: node.title, - message: summary - } - }; - } - ); - registerToolWithAliases( 'getNodesById', { @@ -512,7 +428,7 @@ async function main() { 'updateNode', { title: 'Update RA-H node', - description: 'Update an existing node when it is clearly the same artifact and a net-new node would be redundant. Explicit user-directed updates can proceed once the target node is clear. Context is preserved by default. If the user explicitly wants to change context, use `context_name`. Use `clear_context` only when the user explicitly wants the context removed. Description updates should explicitly state what this thing is and any surrounding context available, but the write will never be blocked over description quality. Source content lives in "source". The RA-H app owns chunking and embedding from source. Legacy "content" is mapped to source for compatibility. Title, description, and link are overwritten. Call getNodesById first to verify current state before updating.', + description: 'Update an existing node when it is clearly the same artifact and a net-new node would be redundant. Explicit user-directed updates can proceed once the target node is clear. Description updates should explicitly state what this thing is and any surrounding context available, but the write will never be blocked over description quality. Source content lives in "source". The RA-H app owns chunking and embedding from source. Legacy "content" is mapped to source for compatibility. Title, description, and link are overwritten. Call getNodesById first to verify current state before updating.', inputSchema: updateNodeInputSchema }, async ({ id, updates }) => { @@ -537,19 +453,6 @@ async function main() { : mappedUpdates.description; } - if (mappedUpdates.context_name && mappedUpdates.clear_context) { - throw new Error('context_name cannot be combined with clear_context: true.'); - } - - if (mappedUpdates.context_name || mappedUpdates.clear_context || Object.prototype.hasOwnProperty.call(mappedUpdates, 'context_id')) { - mappedUpdates.context_id = contextService.resolveContextId({ - context_id: mappedUpdates.clear_context ? null : mappedUpdates.context_id, - context_name: mappedUpdates.context_name, - }); - } - delete mappedUpdates.context_name; - delete mappedUpdates.clear_context; - const node = nodeService.updateNode(id, mappedUpdates); const message = `Updated node #${id}`; @@ -652,73 +555,6 @@ async function main() { // ========== DIMENSION TOOLS ========== - registerToolWithAliases( - 'queryContexts', - { - title: 'List RA-H contexts', - description: 'List or inspect optional contexts. Use this only when a context is already obviously relevant or the user asks for it.', - inputSchema: queryContextsInputSchema - }, - async ({ contextId, name, search, limit = 50, includeNodes = false }) => { - const normalizedName = typeof name === 'string' ? name.trim() : ''; - const normalizedSearch = typeof search === 'string' ? search.trim().toLowerCase() : ''; - - let contexts = []; - - if (contextId) { - const context = contextService.getContextById(contextId); - contexts = context ? [context] : []; - } else { - contexts = contextService.listContexts(); - } - - if (normalizedName) { - contexts = contexts.filter((context) => context.name.toLowerCase() === normalizedName.toLowerCase()); - } - - 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 includeContextNodes = includeNodes && contexts.length === 1 && (contextId || normalizedName); - const structuredContexts = contexts.map((context) => { - if (!includeContextNodes) { - return context; - } - - const nodes = nodeService.getNodes({ contextId: context.id, limit: 500 }); - return { - ...context, - nodes: nodes.map((node) => ({ - 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, - })), - }; - }); - - const summary = structuredContexts.length === 0 - ? 'No contexts found.' - : `Found ${structuredContexts.length} context(s).`; - - return { - content: [{ type: 'text', text: summary }], - structuredContent: { - count: structuredContexts.length, - contexts: structuredContexts, - }, - }; - } - ); - // ========== SKILL TOOLS ========== registerToolWithAliases( @@ -934,7 +770,7 @@ async function main() { 'sqliteQuery', { title: 'Execute read-only SQL', - description: 'Execute read-only SQL queries against the knowledge graph database. Tables: nodes, contexts, edges, chunks, and migration snapshots. Use PRAGMA table_info(tablename) for schema. Only SELECT/WITH/PRAGMA allowed. Use when structured tools are insufficient — e.g., complex JOINs, aggregations, or custom filtering. Read readSkill("schema") for table definitions and query patterns.', + description: 'Execute read-only SQL queries against the knowledge graph database. Tables: nodes, edges, chunks, chats, voice_usage, logs, and migration snapshots. Use PRAGMA table_info(tablename) for schema. Only SELECT/WITH/PRAGMA allowed. Use when structured tools are insufficient — e.g., complex JOINs, aggregations, or custom filtering. Read readSkill("schema") for table definitions and query patterns.', inputSchema: sqliteQueryInputSchema }, async ({ sql: userSql, format = 'json' }) => { diff --git a/apps/mcp-server-standalone/services/contextService.js b/apps/mcp-server-standalone/services/contextService.js deleted file mode 100644 index 223f74e..0000000 --- a/apps/mcp-server-standalone/services/contextService.js +++ /dev/null @@ -1,141 +0,0 @@ -'use strict'; - -const { getDb } = require('./sqlite-client'); -const MAX_CONTEXTS_PER_ACCOUNT = 10; - -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 existingCount = Number(db.prepare('SELECT COUNT(*) AS count FROM contexts').get()?.count ?? 0); - if (existingCount >= MAX_CONTEXTS_PER_ACCOUNT) { - throw new Error(`Context limit reached. Maximum ${MAX_CONTEXTS_PER_ACCOUNT} contexts are allowed per account.`); - } - - 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 = { - MAX_CONTEXTS_PER_ACCOUNT, - listContexts, - getContextById, - getContextByName, - createContext, - updateContext, - resolveContextId, -}; diff --git a/apps/mcp-server-standalone/services/directNodeLookupService.js b/apps/mcp-server-standalone/services/directNodeLookupService.js index f14841c..c754f9e 100644 --- a/apps/mcp-server-standalone/services/directNodeLookupService.js +++ b/apps/mcp-server-standalone/services/directNodeLookupService.js @@ -1,7 +1,6 @@ 'use strict'; const nodeService = require('./nodeService'); -const contextService = require('./contextService'); const SEARCH_STOP_WORDS = new Set([ 'a', 'about', 'added', 'already', 'an', 'and', 'are', 'as', 'at', 'be', 'by', @@ -109,41 +108,6 @@ function scoreNodeSearchMatch(node, query) { return score; } -function normalizeContextName(value) { - if (typeof value !== 'string') return undefined; - const normalized = value.trim().replace(/\s+/g, ' '); - return normalized || undefined; -} - -function resolveSearchContext({ context_name, contextId }) { - const normalizedName = normalizeContextName(context_name); - if (normalizedName) { - const context = contextService.getContextByName(normalizedName); - if (!context) { - console.warn(`directNodeLookupService received unknown context_name "${normalizedName}"; ignoring context filter.`); - return {}; - } - return { - contextId: context.id, - context_name: context.name, - }; - } - - if (typeof contextId === 'number') { - const context = contextService.getContextById(contextId); - if (!context) { - console.warn(`directNodeLookupService received invalid legacy contextId ${contextId}; ignoring context filter.`); - return {}; - } - return { - contextId: context.id, - context_name: context.name, - }; - } - - return {}; -} - function hasStrongAnchorMatch(nodes, searchTerm) { if (!searchTerm || nodes.length === 0) return false; const highSignalTerms = getHighSignalSearchTerms(searchTerm); @@ -168,12 +132,9 @@ function directNodeLookup(input = {}) { }; } - const resolvedContext = resolveSearchContext(input); - let safeNodes = nodeService.getNodes({ search: searchTerm || undefined, limit, - contextId: resolvedContext.contextId, createdAfter: input.createdAfter, createdBefore: input.createdBefore, eventAfter: input.eventAfter, @@ -181,7 +142,6 @@ function directNodeLookup(input = {}) { }); const hadExtraFilters = Boolean( - resolvedContext.contextId !== undefined || input.createdAfter || input.createdBefore || input.eventAfter || @@ -209,7 +169,6 @@ function directNodeLookup(input = {}) { filtersApplied: { search: searchTerm || undefined, limit, - context_name: resolvedContext.context_name, createdAfter: input.createdAfter, createdBefore: input.createdBefore, eventAfter: input.eventAfter, diff --git a/apps/mcp-server-standalone/services/nodeService.js b/apps/mcp-server-standalone/services/nodeService.js index 1347ac7..3e64a6a 100644 --- a/apps/mcp-server-standalone/services/nodeService.js +++ b/apps/mcp-server-standalone/services/nodeService.js @@ -1,7 +1,6 @@ 'use strict'; const { query, transaction, getDb } = require('./sqlite-client'); -const contextService = require('./contextService'); function parseMetadata(metadata) { if (!metadata) return {}; @@ -54,8 +53,6 @@ function mapNodeRow(row) { return { ...row, metadata: row.metadata ? (typeof row.metadata === 'string' ? JSON.parse(row.metadata) : row.metadata) : null, - context: row.context_json ? JSON.parse(row.context_json) : null, - context_json: undefined, }; } @@ -63,17 +60,16 @@ function mapNodeRow(row) { * Get nodes with optional filtering. */ function getNodes(filters = {}) { - const { search, limit = 100, offset = 0, contextId } = filters; + const { search, limit = 100, offset = 0 } = filters; + + if (normalizeString(search)) { + return searchNodes(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.context_id, - 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 + n.created_at, n.updated_at FROM nodes n - LEFT JOIN contexts c ON c.id = n.context_id WHERE 1=1 `; const params = []; @@ -83,11 +79,6 @@ 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) { sql += ` ORDER BY @@ -130,13 +121,8 @@ function searchNodes(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.context_id, - 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 + n.created_at, n.updated_at FROM nodes n - LEFT JOIN contexts c ON c.id = n.context_id WHERE n.id = ? `; @@ -172,8 +158,7 @@ function createNode(nodeData) { source, link, event_date, - metadata = {}, - context_id + metadata = {} } = nodeData; const title = sanitizeTitle(rawTitle); @@ -183,13 +168,12 @@ function createNode(nodeData) { const db = getDb(); const sourceToStore = source ?? ([title, description].filter(Boolean).join('\n\n').trim() || null); - const effectiveContextId = context_id ?? null; const chunkStatus = getChunkStatusForSource(sourceToStore); const nodeId = transaction(() => { const stmt = db.prepare(` - INSERT INTO nodes (title, description, source, link, event_date, metadata, chunk_status, context_id, created_at, updated_at) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + INSERT INTO nodes (title, description, source, link, event_date, metadata, chunk_status, created_at, updated_at) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?) `); const result = stmt.run( @@ -200,7 +184,6 @@ function createNode(nodeData) { event_date ?? null, JSON.stringify(canonicalMetadata), chunkStatus, - effectiveContextId ?? null, now, now ); @@ -259,10 +242,6 @@ function updateNode(id, updates, options = {}) { setFields.push('chunk_status = ?'); params.push(getChunkStatusForSource(normalizedSource)); } - 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(mergedMetadata)); @@ -304,7 +283,7 @@ function getNodeCount() { /** * Get knowledge graph context overview. - * Returns stats, contexts, hub nodes, and recent activity. + * Returns stats, hub nodes, and recent activity. */ function getContext() { const nodeCount = query('SELECT COUNT(*) as count FROM nodes')[0].count; @@ -323,12 +302,11 @@ function getContext() { LEFT JOIN edges e ON n.id = e.from_node_id OR n.id = e.to_node_id GROUP BY n.id ORDER BY edge_count DESC - LIMIT 5 + LIMIT 10 `); return { - stats: { nodeCount, edgeCount, dimensionCount: 0, contextCount: contextService.listContexts().length }, - contexts: contextService.listContexts(), + stats: { nodeCount, edgeCount, dimensionCount: 0 }, recentNodes, hubNodes }; diff --git a/apps/mcp-server-standalone/services/retrievalService.js b/apps/mcp-server-standalone/services/retrievalService.js index 8c2bf60..d6dd55a 100644 --- a/apps/mcp-server-standalone/services/retrievalService.js +++ b/apps/mcp-server-standalone/services/retrievalService.js @@ -3,7 +3,6 @@ const { getDb, query } = require('./sqlite-client'); const nodeService = require('./nodeService'); const edgeService = require('./edgeService'); -const contextService = require('./contextService'); const LOW_SIGNAL_PATTERNS = [ /^(yes|yeah|yep|no|nope|nah|ok|okay|cool|great|nice|thanks|thank you|sure|sounds good|go ahead|do it)[.!]?$/i, @@ -475,7 +474,6 @@ function searchChunks(queryText, nodeIds, limit) { function retrieveQueryContext(input = {}) { const queryText = normalizeWhitespace(input.query || ''); const focusedNodeId = input.focused_node_id ?? null; - const requestedActiveContextId = input.active_context_id ?? null; const limit = Math.min(Math.max(input.limit || 6, 1), 12); const shouldRetrieve = shouldRetrieveForQuery(queryText); @@ -486,14 +484,11 @@ function retrieveQueryContext(input = {}) { mode: 'skip', reason: 'Query is too lightweight or conversational to justify retrieval.', focused_node_id: focusedNodeId, - active_context_id: requestedActiveContextId, nodes: [], chunks: [], }; } - const activeContext = requestedActiveContextId ? contextService.getContextById(requestedActiveContextId) : null; - const activeContextId = activeContext ? activeContext.id : null; const focusedRequest = isFocusedSourceRequest(queryText); const directNodeRetrieval = isDirectNodeRetrievalQuery(queryText); const nodesById = new Map(); @@ -527,19 +522,6 @@ function retrieveQueryContext(input = {}) { }); }); - if (activeContextId && !strongRecallMatch) { - const contextMatches = queryText - ? nodeService.getNodes({ search: queryText, contextId: activeContextId, limit: Math.max(limit, 4) }) - : []; - contextMatches.forEach((node, index) => { - addNodeWithReason(nodesById, node, { - kind: 'context_hint', - reason: 'Also matched inside the active context.', - searchRank: directQueryMatches.length + index, - }); - }); - } - if (!strongRecallMatch) { const rankedSeedNodes = rankRetrievedNodes(Array.from(nodesById.values())).slice(0, Math.max(3, limit)); rankedSeedNodes.slice(0, 3).forEach((seed) => { @@ -583,7 +565,6 @@ function retrieveQueryContext(input = {}) { ? 'Direct node retrieval query: search the graph directly first and broaden only if needed.' : 'Substantive query: search the graph directly, then pull additional supporting context if helpful.', focused_node_id: focusedNodeId, - active_context_id: activeContextId, nodes: finalNodes, chunks, }; diff --git a/apps/mcp-server-standalone/services/sqlite-client.js b/apps/mcp-server-standalone/services/sqlite-client.js index dacbdeb..d52b57f 100644 --- a/apps/mcp-server-standalone/services/sqlite-client.js +++ b/apps/mcp-server-standalone/services/sqlite-client.js @@ -32,7 +32,7 @@ function getExistingColumnNames(db, tableName) { } function validateExistingRahSchema(db) { - const requiredTables = ['contexts', 'nodes', 'edges', 'chunks']; + const requiredTables = ['nodes', 'edges', 'chunks']; const missingTables = requiredTables.filter( tableName => !db.prepare("SELECT 1 FROM sqlite_master WHERE type='table' AND name = ?").get(tableName) ); @@ -46,16 +46,14 @@ function validateExistingRahSchema(db) { const requiredNodeColumns = [ 'title', 'description', 'source', 'link', 'event_date', 'metadata', - 'embedding', 'embedding_updated_at', 'embedding_text', 'chunk_status', 'context_id', + 'embedding', 'embedding_updated_at', 'embedding_text', 'chunk_status', 'created_at', 'updated_at' ]; - const requiredContextColumns = ['name', 'description', 'icon', 'created_at', 'updated_at']; const requiredEdgeColumns = ['from_node_id', 'to_node_id', 'source', 'created_at', 'context', 'explanation']; const requiredChunkColumns = ['node_id', 'chunk_idx', 'text', 'embedding_type', 'metadata', 'created_at']; const schemaChecks = [ ['nodes', requiredNodeColumns], - ['contexts', requiredContextColumns], ['edges', requiredEdgeColumns], ['chunks', requiredChunkColumns], ]; @@ -104,15 +102,6 @@ function initDatabase() { function ensureCoreSchema(db) { db.exec(` - CREATE TABLE IF NOT EXISTS contexts ( - id INTEGER PRIMARY KEY, - name TEXT NOT NULL, - description TEXT NOT NULL, - icon TEXT, - created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, - updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP - ); - CREATE TABLE IF NOT EXISTS nodes ( id INTEGER PRIMARY KEY, title TEXT, @@ -126,13 +115,8 @@ function ensureCoreSchema(db) { embedding BLOB, embedding_updated_at TEXT, embedding_text TEXT, - chunk_status TEXT DEFAULT 'not_chunked', - context_id INTEGER, - FOREIGN KEY (context_id) REFERENCES contexts(id) ON DELETE SET NULL + chunk_status TEXT DEFAULT 'not_chunked' ); - - CREATE UNIQUE INDEX IF NOT EXISTS idx_contexts_name_normalized - ON contexts(LOWER(TRIM(name))); `); ensureEdgesTableSchema(db); diff --git a/apps/mcp-server-standalone/skills/audit.md b/apps/mcp-server-standalone/skills/audit.md index d802871..c67598f 100644 --- a/apps/mcp-server-standalone/skills/audit.md +++ b/apps/mcp-server-standalone/skills/audit.md @@ -9,7 +9,7 @@ description: "Use for structured review, QA, cleanup, or governance checks acros 1. Node quality: duplicates, vague descriptions, missing dates, weak titles. 2. Edge quality: missing links, weak explanations, wrong directionality. -3. Context quality: drift, overlap, low-signal buckets, or contexts being overused where stronger node metadata should carry the meaning. +3. Graph structure quality: weak clustering, redundant organizational nodes, or cases where stronger titles, descriptions, metadata, and edges should carry the meaning. 4. Skill quality: trigger clarity, overlap, dead/unused skills. ## Output Format diff --git a/apps/mcp-server-standalone/skills/calibration.md b/apps/mcp-server-standalone/skills/calibration.md index 1691127..4592dfb 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 contexts, changed priorities, and explicit deltas." +success_criteria: "Graph reflects current reality: updated nodes, 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 contexts, anchor nodes, and active project nodes. +1. Review the strongest active nodes first. 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 b160a43..62c39ef 100644 --- a/apps/mcp-server-standalone/skills/db-operations.md +++ b/apps/mcp-server-standalone/skills/db-operations.md @@ -13,7 +13,7 @@ description: "Use for graph read, write, connect, classify, or traverse operatio 4. Search before create to avoid duplicates. 5. Every create/update must include a natural description that clearly says what the thing is, why it matters here, and its current workflow status. 6. Use event dates when known (when it happened, not when saved). -7. Leave context blank by default. Only apply context when the user explicitly wants it or when one obvious existing context is clearly useful. One node gets at most one primary context, and leaving context blank is valid. +7. Leave extra taxonomy out of normal writes. Good nodes and good edges should carry the structure. 8. Do not create edges autonomously. Surface likely edge candidates first, then create them only after the user explicitly confirms. 9. For user-authored ideas, notes, or dictated thoughts, preserve the user's wording in `source` as fully as possible with only minimal cleanup. @@ -24,9 +24,6 @@ description: "Use for graph read, write, connect, classify, or traverse operatio - `source`: full verbatim or canonical content of the node (transcript, article text, book passage, user's thoughts). The standalone MCP server stores this on the node. The RA-H app later chunks and embeds it for semantic search. - For idea capture from chat, the `source` should usually be the raw user thought, not a compressed assistant summary. - `link`: external source URL only. -- Normal writes should omit context entirely unless the user explicitly wants one. -- If context is intentionally provided, prefer `context_name`. -- Treat numeric `context_id` as an internal implementation detail, not a normal agent-facing field. - `metadata`: use the canonical node metadata contract when metadata is needed: - `type` - `state` (`processed` or `not_processed`) @@ -48,7 +45,7 @@ It must still make three things clear: 2. Why — why it is in the graph; what Brad is interested in; what it connects to 3. Status — where it sits in his workflow (queued, in progress, processed, unknown) -If the agent has graph context (context capsule, focused nodes, recent connected nodes, or an explicit active context), it should infer the why from that context and write it naturally. Do not let the service auto-generate a weak context-free description when you already have enough signal. +If the agent has graph context (focused nodes, recent connected nodes, or nearby graph structure), it should infer the why from that context and write it naturally. Do not let the service auto-generate a weak context-free description when you already have enough signal. If the why genuinely cannot be inferred, say that naturally. Do not use labels like `WHAT:`, `WHY:`, or `STATUS:` and do not substitute vague filler like `insightful for understanding` or `relevant to Brad's work`. diff --git a/apps/mcp-server-standalone/skills/node-context-enrichment.md b/apps/mcp-server-standalone/skills/node-context-enrichment.md index 10f28ad..02b161c 100644 --- a/apps/mcp-server-standalone/skills/node-context-enrichment.md +++ b/apps/mcp-server-standalone/skills/node-context-enrichment.md @@ -1,13 +1,13 @@ --- name: Node Context Enrichment -description: "Use to rewrite thin node descriptions into natural prose that still makes what, why, and status clear, with context review and edge suggestions." +description: "Use to rewrite thin node descriptions into natural prose that still makes what, why, and status clear, with graph review and edge suggestions." --- # Node Context Enrichment -Use this when a node already exists but its description is thin, generic, or missing personal context. +Use this when a node already exists but its description is thin, generic, or missing useful graph framing. -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. +This skill should not silently rewrite and move on when framing is inferred. If the enrichment depends on interpretation, update the node and then explicitly invite the user to correct or refine that framing. ## Goal @@ -17,24 +17,19 @@ Replace weak descriptions with a single clean natural description that captures: 2. Why it is in Brad's graph 3. Status in Brad's workflow -Also review whether the node needs context cleanup or obvious edge suggestions. +Also review whether the node needs obvious edge suggestions. ## Workflow -1. Load the node and inspect title, description, source, link, metadata, context, and nearby edges. -2. Search for adjacent context before rewriting: - - the node's primary context and its anchor node when present +1. Load the node and inspect title, description, source, link, metadata, and nearby edges. +2. Search for adjacent graph context before rewriting: - recently connected project or belief nodes - - related nodes with overlapping titles, creators, or neighboring context -3. Infer the best available "why" from that context. + - related nodes with overlapping titles, creators, or neighboring structure +3. Infer the best available "why" from that graph 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 context fit: - - keep the current context when it is clearly the primary scope - - suggest clearing context when it is weak or misleading - - suggest a different context only when the primary scope is explicit -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: +5. Suggest 1-3 high-signal edges when obvious. +6. Update the node once the description is strong enough to be useful. +7. 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 @@ -52,7 +47,7 @@ Every rewritten description must naturally cover: 2. Why - why Brad saved it - what project, belief, question, or theme it connects to - - if genuinely unknown, say that naturally without inventing context + - if genuinely unknown, say that naturally without inventing graph framing 3. Status - queued, in progress, processed, not yet reviewed, saved for later, etc. - if unknown, say naturally that it has not been reviewed yet @@ -71,14 +66,13 @@ Use batch enrichment when cleaning up many nodes with the same failure mode. 3. Enrich each node individually. Do not reuse boilerplate "why" text across unrelated nodes. 4. Return a compact summary of: - nodes updated - - context assignments to review - - edge suggestions not yet created +- edge suggestions not yet created ## Quality Bar - No filler phrases like `insightful for understanding`, `relevant to`, or `important for`. - No generic summaries that only restate the topic. -- No invented certainty. If context is weak, say so explicitly. +- No invented certainty. If graph evidence is weak, say so explicitly. - Prefer one compact 3-sentence description over bloated prose. ## Output Pattern @@ -86,6 +80,6 @@ Use batch enrichment when cleaning up many nodes with the same failure mode. For each node: - New description -- Context change: keep / change / clear +- Framing note: what graph context influenced the rewrite, if any - Edge suggestions: source -> target with explicit explanation -- One short invitation for user feedback when contextual framing was inferred +- One short invitation for user feedback when framing was inferred diff --git a/apps/mcp-server-standalone/skills/onboarding.md b/apps/mcp-server-standalone/skills/onboarding.md index f9340dc..9350973 100644 --- a/apps/mcp-server-standalone/skills/onboarding.md +++ b/apps/mcp-server-standalone/skills/onboarding.md @@ -7,7 +7,7 @@ description: "Use for new-user setup, empty or near-empty graphs, or major reset ## Your Job -Three things: help the user understand the basic structure of the system, help them start building useful graph data in it, and bootstrap the context capsule when durable cross-session facts become clear. +Three things: help the user understand the basic structure of the system, help them start building useful graph data in it, and get them to a useful starter graph quickly. Adapt to the user. @@ -30,28 +30,6 @@ Only bring up setup details if the user actually needs them: - **Anthropic** — mainly relevant for compatible runtime paths and local/dev setups. 4. If they are not ready to configure anything yet, keep onboarding. They can still learn the structure and add manual content. -## Capsule Bootstrap - -As part of onboarding, also bootstrap the context capsule on the user's behalf. - -When the user gives durable identity, preference, worldview, project, or agent-behavior information: - -1. Call `readSkill('context-capsule')` -2. Call `readCapsule` -3. When you have enough confidence, call `writeCapsule` - -Use the capsule for: -- preferred name -- agent name or greeting preference -- interaction style -- major projects -- major interests -- explicit worldview or belief signals -- what the user is using RA-H for - -Do not write the capsule for tentative, one-off, or low-confidence statements. -Keep it compressed and canonical. Replace stale instructions instead of preserving contradictory history. - ## Explain the System First Before asking anything, orient the user. Be direct, not salesy: @@ -60,7 +38,6 @@ Before asking anything, orient the user. Be direct, not salesy: Explain the structure in simple terms: -- **Contexts** — an optional soft organization layer. These are broad areas like health, job, life, or research. A node can belong to one context when it is explicit and useful, but context is not required. - **Nodes** — individual things. A project, idea, person, source, belief, decision, or topic. Each node must have a clear description of what it is and why it matters. - **Edges** — explicit connections between things. Each edge must clearly explain the relationship. - **Metadata and edges** — secondary structure that makes nodes more useful once the core artifact is clear. @@ -71,7 +48,7 @@ Then say: Also explain one practical thing early: -> "You do not need to perfectly design this up front. We want a few concrete nodes, clear contexts when they matter, and a few clean edges so the graph becomes useful quickly." +> "You do not need to perfectly design this up front. We want a few concrete nodes and a few clean edges so the graph becomes useful quickly." ## Interview Flow @@ -101,7 +78,6 @@ Keep it conversational. Use these buckets and adapt based on what the user gives Work these in naturally when they are relevant: - **First node creation** — explain that a node is one concrete thing worth keeping: a project, source, person, belief, decision, or idea. -- **Contexts** — explain that contexts are optional helpers, not required setup. If the user has none, that is fine. - **MCP connection** — if the user mentions Claude Code or external agents, offer a quick setup path and point them to the MCP docs/skill flow rather than reciting a giant config block immediately. - **What to do after setup** — once the graph has a few solid nodes, the next useful move is usually one of: - connect related nodes with explicit edges @@ -113,7 +89,6 @@ Work these in naturally when they are relevant: Do your best to build the graph as useful context emerges. - Add nodes when the user mentions concrete things worth keeping. -- Assign a context only when it is an obvious match to one of the user's existing contexts. Prefer leaving context empty over low-confidence guessing. - Add edges when relationships are clear enough to explain well. - Explain what you're adding in plain language so the user understands the structure as it develops. @@ -125,22 +100,16 @@ Before writing anything, call `readSkill('db-operations')` for full quality stan - Search before creating — avoid duplicates from day one - Every description must be concrete: what it IS and why it matters to them, not what it "explores" or "discusses" -- Contexts are optional and should only be used for an obvious existing match; otherwise leave them empty - Every edge needs an explicit explanation sentence ## Propose Before Writing When there is enough context, summarize the proposed structure before touching the database: -> "Here's what I'm planning to create: [list contexts with one-line rationale], [list starter nodes], [list key edges]. Does this look right? Anything to adjust?" +> "Here's what I'm planning to create: [list starter nodes], [list key edges]. Does this look right? Anything to adjust?" Write only after confirmation. -If onboarding also surfaced durable cross-session facts, include the capsule update in the proposal: - -> "I also plan to bootstrap your context capsule with your name, interaction preferences, and current priorities so future chats start grounded. Sound right?" -> "I also plan to bootstrap your context capsule with your name and interaction preferences so future chats start grounded. Sound right?" - For very early setup, include the first actionable next step too: > "After this starter pass, the best next move will be [add a source / connect these nodes / capture another active project]." diff --git a/apps/mcp-server/server.js b/apps/mcp-server/server.js index f04b576..e35073e 100644 --- a/apps/mcp-server/server.js +++ b/apps/mcp-server/server.js @@ -44,15 +44,13 @@ let logger = (message) => console.log(`[mcp] ${message}`); const instructions = [ 'RA-H is a personal knowledge graph — local-first, vendor-neutral.', - 'Core concepts: contexts (optional soft scopes, max 10), nodes (knowledge units), and edges (connections with explanations).', + 'Core concepts: nodes (knowledge units) and edges (connections with explanations).', 'If the user is trying to find a specific existing node, use rah_search_nodes first.', 'If graph context would help with a broader task, use rah_retrieve_query_context.', 'Use rah_get_context only when high-level graph orientation would actually help.', 'Do not keep re-running retrieval if you already have enough relevant graph context in play.', - 'Use contexts only when one obvious existing context is explicitly helpful. If unsure or if none exist, leave context empty. Do not assume the server will infer a best-fit context.', 'Search before creating: use rah_search_nodes to check if content already exists.', - 'Only suggest saving context when it is unusually durable and valuable. Keep the ask brief, for example: Add "X" as a node?', - 'Never write via rah_write_context unless the user has explicitly confirmed yes.', + 'Only suggest saving durable knowledge when it is unusually valuable. Keep the ask brief, for example: Add "X" as a node?', 'Do not create edges autonomously. Surface likely edge candidates briefly, then call edge-write tools only after the user explicitly confirms.', 'Every edge needs an explanation: why does this connection exist?', 'All data stays local on this device; nothing leaves 127.0.0.1.', @@ -79,7 +77,6 @@ const addNodeInputSchema = { source: z.string().max(50000).optional(), link: z.string().url().optional(), description: z.string().max(500).optional().describe('Description of the node. Write it as natural prose, not labels or a checklist. It must still make clear what the artifact is, why it is in the graph (infer from conversation context; ask the user if needed), and its current workflow status. Max 500 characters. If the reason is unclear, say that naturally instead of inventing it. Never use filler phrases like "insightful for understanding" or "relevant to the user\'s work".'), - context_name: z.string().optional().describe('Optional primary context name. Use only when the user explicitly wants this node assigned to a known context.'), metadata: z.record(z.any()).optional().describe('Optional metadata. Prefer canonical keys: type, state, captured_method, captured_by, source_metadata.'), chunk: z.string().max(50000).optional() }; @@ -93,7 +90,10 @@ const addNodeOutputSchema = { const searchNodesInputSchema = { query: z.string().min(1).max(400), limit: z.number().min(1).max(50).optional(), - context_name: z.string().optional() + createdAfter: z.string().optional(), + createdBefore: z.string().optional(), + eventAfter: z.string().optional(), + eventBefore: z.string().optional() }; const searchNodesOutputSchema = { @@ -113,7 +113,6 @@ const searchNodesOutputSchema = { const retrieveQueryContextInputSchema = { query: z.string().min(1).max(800), focused_node_id: z.number().int().positive().nullable().optional(), - active_context_id: z.number().int().positive().nullable().optional(), limit: z.number().min(1).max(12).optional() }; @@ -123,14 +122,13 @@ const retrieveQueryContextOutputSchema = { mode: z.enum(['skip', 'focused', 'query']), reason: z.string(), focused_node_id: z.number().nullable(), - active_context_id: z.number().nullable(), nodes: z.array(z.object({ id: z.number(), title: z.string(), description: z.string().nullable(), link: z.string().nullable(), updated_at: z.string(), - kind: z.enum(['focused', 'query_match', 'context_hint', 'neighbor']), + kind: z.enum(['focused', 'query_match', 'neighbor']), reason: z.string(), seed_node_id: z.number().optional() })), @@ -143,53 +141,6 @@ const retrieveQueryContextOutputSchema = { })) }; -const writeContextInputSchema = { - title: z.string().min(1).max(160), - description: z.string().min(1).max(500), - source: z.string().max(50000).optional(), - context_name: z.string().optional(), - metadata: z.record(z.any()).optional(), - confirmed_by_user: z.boolean() -}; - -const writeContextOutputSchema = { - success: z.boolean(), - nodeId: z.number(), - title: z.string(), - message: z.string() -}; - -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 queryContextsOutputSchema = { - count: z.number(), - contexts: z.array( - z.object({ - id: z.number(), - name: z.string(), - description: z.string().nullable(), - icon: z.string().nullable(), - count: z.number(), - nodes: z.array( - z.object({ - id: z.number(), - title: z.string(), - description: z.string().nullable(), - link: z.string().nullable(), - context_id: z.number().nullable().optional(), - updated_at: z.string() - }) - ).optional() - }) - ) -}; - // rah_update_node schemas const updateNodeInputSchema = { id: z.number().int().positive().describe('The ID of the node to update'), @@ -199,8 +150,6 @@ const updateNodeInputSchema = { content: z.string().optional().describe('Legacy alias for source. Mapped to source for backward compatibility.'), source: z.string().optional().describe('Canonical source text for embedding.'), link: z.string().optional().describe('New link'), - context_name: z.string().optional().describe('Optional primary context name. Use only when the user explicitly wants to assign this node to a known context.'), - clear_context: z.boolean().optional().describe('Set true only when the user explicitly wants to remove the node context.'), metadata: z.record(z.any()).optional().describe('Metadata patch. This now merges with existing metadata. Prefer canonical keys: type, state, captured_method, captured_by, source_metadata.') }).describe('Fields to update') }; @@ -378,17 +327,16 @@ mcpServer.registerTool( 'rah_add_node', { title: 'Add RA-H node', - description: 'Create a new node in the local RA-H knowledge base after you have already decided a net-new write is correct. If the user explicitly asked to save or import something and the target artifact is clear, write after duplicate/update checks. If you are only suggesting a save, propose the node first and wait for confirmation. Leave context blank by default. If the user explicitly wants context, use `context_name` rather than a numeric ID.', + description: 'Create a new node in the local RA-H knowledge base after you have already decided a net-new write is correct. If the user explicitly asked to save or import something and the target artifact is clear, write after duplicate/update checks. If you are only suggesting a save, propose the node first and wait for confirmation.', inputSchema: addNodeInputSchema, outputSchema: addNodeOutputSchema }, - async ({ title, content, source, link, description, context_name, metadata, chunk }) => { + async ({ title, content, source, link, description, metadata, chunk }) => { const payload = { title: title.trim(), source: source?.trim() || content?.trim() || chunk?.trim() || undefined, link: link?.trim() || undefined, description: description?.trim() || undefined, - context_name: context_name?.trim() || undefined, metadata: metadata || {} }; @@ -415,17 +363,20 @@ mcpServer.registerTool( 'rah_search_nodes', { title: 'Search RA-H nodes', - description: 'Find existing RA-H entries that mention a topic before adding new ones. Leave context blank by default. If the user explicitly wants a context-specific lookup, use `context_name` rather than a numeric ID. For full current-turn grounding of a substantive request, prefer rah_retrieve_query_context.', + description: 'Find existing RA-H entries that mention a topic before adding new ones. Use this first when the user is trying to locate a specific node they already created. For full current-turn grounding of a substantive request, prefer rah_retrieve_query_context.', inputSchema: searchNodesInputSchema, outputSchema: searchNodesOutputSchema }, - async ({ query, limit = 10, context_name }) => { + async ({ query, limit = 10, createdAfter, createdBefore, eventAfter, eventBefore }) => { const result = await callRaHApi('/api/nodes/direct-search', { method: 'POST', body: JSON.stringify({ query: query.trim(), limit: Math.min(Math.max(limit, 1), 50), - context_name: typeof context_name === 'string' ? context_name.trim() : undefined, + createdAfter, + createdBefore, + eventAfter, + eventBefore, }) }); @@ -459,13 +410,12 @@ mcpServer.registerTool( inputSchema: retrieveQueryContextInputSchema, outputSchema: retrieveQueryContextOutputSchema }, - async ({ query, focused_node_id, active_context_id, limit = 6 }) => { + async ({ query, focused_node_id, limit = 6 }) => { const result = await callRaHApi('/api/retrieval/query-context', { method: 'POST', body: JSON.stringify({ query, focused_node_id: focused_node_id ?? null, - active_context_id: active_context_id ?? null, limit }) }); @@ -477,94 +427,11 @@ mcpServer.registerTool( } ); -mcpServer.registerTool( - 'rah_query_contexts', - { - title: 'Query RA-H contexts', - description: 'List or inspect optional contexts. Use this only when a context is already obviously relevant or the user asks for it.', - inputSchema: queryContextsInputSchema, - outputSchema: queryContextsOutputSchema - }, - async ({ contextId, name, search, limit = 50, includeNodes = false }) => { - const normalizedName = typeof name === 'string' ? name.trim() : ''; - const normalizedSearch = typeof search === 'string' ? search.trim().toLowerCase() : ''; - - let contexts = []; - - if (contextId) { - const result = await callRaHApi(`/api/contexts/${contextId}`, { method: 'GET' }); - contexts = result.data ? [result.data] : []; - } else { - const result = await callRaHApi('/api/contexts', { method: 'GET' }); - contexts = Array.isArray(result.data) ? result.data : []; - } - - if (normalizedName) { - contexts = contexts.filter((context) => context.name.toLowerCase() === normalizedName.toLowerCase()); - } - - 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 includeContextNodes = includeNodes && contexts.length === 1 && (contextId || normalizedName); - const structuredContexts = await Promise.all( - contexts.map(async (context) => { - if (!includeContextNodes) { - return { - id: context.id, - name: context.name, - description: context.description ?? null, - icon: context.icon ?? null, - count: context.count ?? 0 - }; - } - - const nodesResult = await callRaHApi(`/api/contexts/${context.id}/nodes`, { method: 'GET' }); - const nodes = Array.isArray(nodesResult.data) ? nodesResult.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) => ({ - 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 - })) - }; - }) - ); - - const summary = structuredContexts.length === 0 - ? 'No contexts found.' - : `Found ${structuredContexts.length} context(s).`; - - return { - content: [{ type: 'text', text: summary }], - structuredContent: { - count: structuredContexts.length, - contexts: structuredContexts - } - }; - } -); - mcpServer.registerTool( 'rah_update_node', { title: 'Update RA-H node', - description: 'Update an existing node when it is clearly the same artifact and a net-new node would be redundant. Explicit user-directed updates can proceed once the target node is clear. Context is preserved by default. If the user explicitly wants to change context, use `context_name`. Use `clear_context` only when the user explicitly wants to remove the node context.', + description: 'Update an existing node when it is clearly the same artifact and a net-new node would be redundant. Explicit user-directed updates can proceed once the target node is clear.', inputSchema: updateNodeInputSchema, outputSchema: updateNodeOutputSchema }, @@ -584,10 +451,6 @@ mcpServer.registerTool( } delete mappedUpdates.chunk; - if (mappedUpdates.context_name && mappedUpdates.clear_context) { - throw new McpError(ErrorCode.InvalidParams, 'context_name cannot be combined with clear_context: true.'); - } - const result = await callRaHApi(`/api/nodes/${id}`, { method: 'PUT', body: JSON.stringify(mappedUpdates) @@ -871,80 +734,37 @@ mcpServer.registerTool( 'rah_get_context', { title: 'Get RA-H context', - description: 'Get orientation context: high-level graph state, optional contexts, hub nodes, stats, and available guides. Use this for orientation only, not as the default retrieval path for substantive requests.', + description: 'Get orientation context: high-level graph state, hub nodes, stats, and available guides. Use this for orientation only, not as the default retrieval path for substantive requests.', inputSchema: {}, outputSchema: { - stats: z.object({ nodeCount: z.number(), edgeCount: z.number(), contextCount: z.number().optional() }), + stats: z.object({ nodeCount: z.number(), edgeCount: z.number() }), 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(), guides: z.array(z.string()) } }, async () => { - const hubResult = await callRaHApi('/api/nodes?sortBy=edges&limit=5', { method: 'GET' }); + const hubResult = await callRaHApi('/api/nodes?sortBy=edges&limit=10', { method: 'GET' }); const hubNodes = Array.isArray(hubResult.data) ? hubResult.data.map(n => ({ id: n.id, title: n.title, description: n.description ?? null, edgeCount: n.edge_count ?? 0 })) : []; - 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, contextCount: contexts.length }; + const stats = { nodeCount: 0, edgeCount: 0 }; try { const countResult = await callRaHApi('/api/nodes?limit=1', { method: 'GET' }); if (countResult.total !== undefined) stats.nodeCount = countResult.total; } catch { /* use defaults */ } + try { + const edgeResult = await callRaHApi('/api/edges', { method: 'GET' }); + if (typeof edgeResult.count === 'number') stats.edgeCount = edgeResult.count; + } catch { /* use defaults */ } + return { - content: [{ type: 'text', text: `Knowledge graph: ${stats.contextCount} contexts, ${hubNodes.length} hub nodes for graph grounding, ${guides.length} guides available.` }], - structuredContent: { stats, hubNodes, contexts, guides } - }; - } -); - -mcpServer.registerTool( - 'rah_write_context', - { - title: 'Write RA-H context node', - description: 'Write one atomic durable context node to the graph only after the user has explicitly approved the save. Use this for agent-suggested capture after you already proposed the node briefly and got a clear yes. Prefer ordinary create/update flows for explicit user-directed capture.', - inputSchema: writeContextInputSchema, - outputSchema: writeContextOutputSchema - }, - async ({ title, description, source, context_name, metadata, confirmed_by_user }) => { - if (!confirmed_by_user) { - throw new Error('rah_write_context requires explicit user confirmation before writing to the graph.'); - } - - const result = await callRaHApi('/api/nodes', { - method: 'POST', - body: JSON.stringify({ - title: title.trim(), - description: description.trim(), - source: source?.trim() || undefined, - context_name: context_name?.trim() || undefined, - metadata: { - captured_by: 'human', - captured_method: 'write_context', - ...(metadata || {}) - } - }) - }); - - const node = result.data; - const message = result.message || `Saved context as node #${node.id}: ${node.title}`; - return { - content: [{ type: 'text', text: message }], - structuredContent: { - success: true, - nodeId: node.id, - title: node.title, - message - } + content: [{ type: 'text', text: `Knowledge graph: ${stats.nodeCount} nodes, ${stats.edgeCount} edges, ${hubNodes.length} hub nodes, ${guides.length} guides available.` }], + structuredContent: { stats, hubNodes, guides } }; } ); diff --git a/apps/mcp-server/stdio-server.js b/apps/mcp-server/stdio-server.js index 9cc59ae..aa11309 100644 --- a/apps/mcp-server/stdio-server.js +++ b/apps/mcp-server/stdio-server.js @@ -11,15 +11,13 @@ const packageJson = require('../../package.json'); const instructions = [ 'RA-H is a personal knowledge graph — local-first, vendor-neutral.', - 'Core concepts: contexts (optional soft scopes, max 10), nodes (knowledge units), and edges (connections with explanations).', + 'Core concepts: nodes (knowledge units) and edges (connections with explanations).', 'If the user is trying to find a specific existing node, use rah_search_nodes first.', 'If graph context would help with a broader task, use rah_retrieve_query_context.', 'Use rah_get_context only when high-level graph orientation would actually help.', 'Do not keep re-running retrieval if you already have enough relevant graph context in play.', - 'Use contexts only when one obvious existing context is explicitly helpful. If unsure or if none exist, leave context empty.', 'Search before creating: use rah_search_nodes to check if content already exists.', - 'Only suggest saving context when it is unusually durable and valuable. Keep the ask brief, for example: Add "X" as a node?', - 'Never write via rah_write_context unless the user has explicitly confirmed yes.', + 'Only suggest saving durable knowledge when it is unusually valuable. Keep the ask brief, for example: Add "X" as a node?', 'Do not create edges autonomously. Surface likely edge candidates briefly, then call edge-write tools only after the user explicitly confirms.', 'Every edge needs an explanation: why does this connection exist?', 'All data stays local on this device; nothing leaves 127.0.0.1.', @@ -45,7 +43,6 @@ const addNodeInputSchema = { source: z.string().max(50000).optional(), link: z.string().url().optional(), description: z.string().max(500).optional().describe('Description of the node. Write it as natural prose, not labels or a checklist. It must still make clear what the artifact is, why it is in the graph (infer from conversation context; ask the user if needed), and its current workflow status. Max 500 characters. If the reason is unclear, say that naturally instead of inventing it. Never use filler phrases like "insightful for understanding" or "relevant to the user\'s work".'), - context_name: z.string().optional().describe('Optional primary context name. Use only when the user explicitly wants this node assigned to a known context.'), metadata: z.record(z.any()).optional().describe('Optional metadata. Prefer canonical keys: type, state, captured_method, captured_by, source_metadata.'), chunk: z.string().max(50000).optional() }; @@ -59,7 +56,10 @@ const addNodeOutputSchema = { const searchNodesInputSchema = { query: z.string().min(1).max(400), limit: z.number().min(1).max(50).optional(), - context_name: z.string().optional() + createdAfter: z.string().optional(), + createdBefore: z.string().optional(), + eventAfter: z.string().optional(), + eventBefore: z.string().optional() }; const searchNodesOutputSchema = { @@ -79,7 +79,6 @@ const searchNodesOutputSchema = { const retrieveQueryContextInputSchema = { query: z.string().min(1).max(800), focused_node_id: z.number().int().positive().nullable().optional(), - active_context_id: z.number().int().positive().nullable().optional(), limit: z.number().min(1).max(12).optional() }; @@ -89,14 +88,13 @@ const retrieveQueryContextOutputSchema = { mode: z.enum(['skip', 'focused', 'query']), reason: z.string(), focused_node_id: z.number().nullable(), - active_context_id: z.number().nullable(), nodes: z.array(z.object({ id: z.number(), title: z.string(), description: z.string().nullable(), link: z.string().nullable(), updated_at: z.string(), - kind: z.enum(['focused', 'query_match', 'context_hint', 'neighbor']), + kind: z.enum(['focused', 'query_match', 'neighbor']), reason: z.string(), seed_node_id: z.number().optional() })), @@ -109,53 +107,6 @@ const retrieveQueryContextOutputSchema = { })) }; -const writeContextInputSchema = { - title: z.string().min(1).max(160), - description: z.string().min(1).max(500), - source: z.string().max(50000).optional(), - context_name: z.string().optional(), - metadata: z.record(z.any()).optional(), - confirmed_by_user: z.boolean() -}; - -const writeContextOutputSchema = { - success: z.boolean(), - nodeId: z.number(), - title: z.string(), - message: z.string() -}; - -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 queryContextsOutputSchema = { - count: z.number(), - contexts: z.array( - z.object({ - id: z.number(), - name: z.string(), - description: z.string().nullable(), - icon: z.string().nullable(), - count: z.number(), - nodes: z.array( - z.object({ - id: z.number(), - title: z.string(), - description: z.string().nullable(), - link: z.string().nullable(), - context_id: z.number().nullable().optional(), - updated_at: z.string() - }) - ).optional() - }) - ) -}; - // rah_update_node schemas const updateNodeInputSchema = { id: z.number().int().positive().describe('The ID of the node to update'), @@ -165,8 +116,6 @@ const updateNodeInputSchema = { content: z.string().optional().describe('Legacy alias for source. Mapped to source for backward compatibility.'), source: z.string().optional().describe('Canonical source text for embedding.'), link: z.string().optional().describe('New link'), - context_name: z.string().optional().describe('Optional primary context name. Use only when the user explicitly wants to assign this node to a known context.'), - clear_context: z.boolean().optional().describe('Set true only when the user explicitly wants to remove the node context.'), metadata: z.record(z.any()).optional().describe('Metadata patch. This now merges with existing metadata. Prefer canonical keys: type, state, captured_method, captured_by, source_metadata.') }).describe('Fields to update') }; @@ -323,7 +272,7 @@ async function resolveBaseUrl() { if (!candidate) return false; const normalized = String(candidate).replace(/\/+$/, ''); try { - const response = await fetch(`${normalized}/api/contexts`, { + const response = await fetch(`${normalized}/api/nodes?limit=1`, { method: 'GET', signal: AbortSignal.timeout(1500) }); @@ -373,17 +322,16 @@ server.registerTool( 'rah_add_node', { title: 'Add RA-H node', - description: 'Create a new node in the local RA-H knowledge base after you have already decided a net-new write is correct. If the user explicitly asked to save or import something and the target artifact is clear, write after duplicate/update checks. If you are only suggesting a save, propose the node first and wait for confirmation. Leave context blank by default. If the user explicitly wants context, use `context_name` rather than a numeric ID.', + description: 'Create a new node in the local RA-H knowledge base after you have already decided a net-new write is correct. If the user explicitly asked to save or import something and the target artifact is clear, write after duplicate/update checks. If you are only suggesting a save, propose the node first and wait for confirmation.', inputSchema: addNodeInputSchema, outputSchema: addNodeOutputSchema }, - async ({ title, content, source, link, description, context_name, metadata, chunk }) => { + async ({ title, content, source, link, description, metadata, chunk }) => { const payload = { title: title.trim(), source: source?.trim() || content?.trim() || chunk?.trim() || undefined, link: link?.trim() || undefined, description: description?.trim() || undefined, - context_name: context_name?.trim() || undefined, metadata: metadata || {} }; @@ -410,17 +358,20 @@ server.registerTool( 'rah_search_nodes', { title: 'Search RA-H nodes', - description: 'Find existing RA-H entries that mention a topic before adding new ones. Leave context blank by default. If the user explicitly wants a context-specific lookup, use `context_name` rather than a numeric ID. For full current-turn grounding of a substantive request, prefer rah_retrieve_query_context.', + description: 'Find existing RA-H entries that mention a topic before adding new ones. Use this first when the user is trying to locate a specific node they already created. For full current-turn grounding of a substantive request, prefer rah_retrieve_query_context.', inputSchema: searchNodesInputSchema, outputSchema: searchNodesOutputSchema }, - async ({ query, limit = 10, context_name }) => { + async ({ query, limit = 10, createdAfter, createdBefore, eventAfter, eventBefore }) => { const result = await callRaHApi('/api/nodes/direct-search', { method: 'POST', body: JSON.stringify({ query: query.trim(), limit: Math.min(Math.max(limit, 1), 50), - context_name: typeof context_name === 'string' ? context_name.trim() : undefined, + createdAfter, + createdBefore, + eventAfter, + eventBefore, }) }); @@ -455,13 +406,12 @@ server.registerTool( inputSchema: retrieveQueryContextInputSchema, outputSchema: retrieveQueryContextOutputSchema }, - async ({ query, focused_node_id, active_context_id, limit = 6 }) => { + async ({ query, focused_node_id, limit = 6 }) => { const result = await callRaHApi('/api/retrieval/query-context', { method: 'POST', body: JSON.stringify({ query, focused_node_id: focused_node_id ?? null, - active_context_id: active_context_id ?? null, limit }) }); @@ -473,95 +423,11 @@ server.registerTool( } ); -server.registerTool( - 'rah_query_contexts', - { - title: 'Query RA-H contexts', - description: 'List or inspect optional contexts. Use this only when a context is already obviously relevant or the user asks for it.', - inputSchema: queryContextsInputSchema, - outputSchema: queryContextsOutputSchema - }, - async ({ contextId, name, search, limit = 50, includeNodes = false }) => { - const normalizedName = typeof name === 'string' ? name.trim() : ''; - const normalizedSearch = typeof search === 'string' ? search.trim().toLowerCase() : ''; - - let contexts = []; - - if (contextId) { - const result = await callRaHApi(`/api/contexts/${contextId}`, { method: 'GET' }); - contexts = result.data ? [result.data] : []; - } else { - const result = await callRaHApi('/api/contexts', { method: 'GET' }); - contexts = Array.isArray(result.data) ? result.data : []; - } - - if (normalizedName) { - contexts = contexts.filter((context) => context.name.toLowerCase() === normalizedName.toLowerCase()); - } - - 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 includeContextNodes = includeNodes && contexts.length === 1 && (contextId || normalizedName); - const structuredContexts = await Promise.all( - contexts.map(async (context) => { - if (!includeContextNodes) { - return { - id: context.id, - name: context.name, - description: context.description ?? null, - icon: context.icon ?? null, - count: context.count ?? 0 - }; - } - - const nodesResult = await callRaHApi(`/api/contexts/${context.id}/nodes`, { method: 'GET' }); - const nodes = Array.isArray(nodesResult.data) ? nodesResult.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) => ({ - 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 - })) - }; - }) - ); - - const summary = - structuredContexts.length === 0 - ? 'No contexts found.' - : `Found ${structuredContexts.length} context(s).`; - - return { - content: [{ type: 'text', text: summary }], - structuredContent: { - count: structuredContexts.length, - contexts: structuredContexts - } - }; - } -); - server.registerTool( 'rah_update_node', { title: 'Update RA-H node', - description: 'Update an existing node when it is clearly the same artifact and a net-new node would be redundant. Explicit user-directed updates can proceed once the target node is clear. Context is preserved by default. If the user explicitly wants to change context, use `context_name`. Use `clear_context` only when the user explicitly wants to remove the node context.', + description: 'Update an existing node when it is clearly the same artifact and a net-new node would be redundant. Explicit user-directed updates can proceed once the target node is clear.', inputSchema: updateNodeInputSchema, outputSchema: updateNodeOutputSchema }, @@ -581,10 +447,6 @@ server.registerTool( } delete mappedUpdates.chunk; - if (mappedUpdates.context_name && mappedUpdates.clear_context) { - throw new Error('context_name cannot be combined with clear_context: true.'); - } - const result = await callRaHApi(`/api/nodes/${id}`, { method: 'PUT', body: JSON.stringify(mappedUpdates) @@ -867,8 +729,7 @@ server.registerTool( const getContextOutputSchema = { stats: z.object({ nodeCount: z.number(), - edgeCount: z.number(), - contextCount: z.number().optional() + edgeCount: z.number() }), hubNodes: z.array(z.object({ id: z.number(), @@ -876,13 +737,6 @@ 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(), guides: z.array(z.string()) }; @@ -890,13 +744,12 @@ server.registerTool( 'rah_get_context', { title: 'Get RA-H context', - description: 'Get orientation context: high-level graph state, optional contexts, hub nodes, stats, and available guides. Use this for orientation only, not as the default retrieval path for substantive requests.', + description: 'Get orientation context: high-level graph state, hub nodes, stats, and available guides. Use this for orientation only, not as the default retrieval path for substantive requests.', inputSchema: {}, outputSchema: getContextOutputSchema }, async () => { - // Fetch hub nodes (top 5 most-connected) - const hubResult = await callRaHApi('/api/nodes?sortBy=edges&limit=5', { method: 'GET' }); + const hubResult = await callRaHApi('/api/nodes?sortBy=edges&limit=10', { method: 'GET' }); const hubNodes = Array.isArray(hubResult.data) ? hubResult.data.map(n => ({ id: n.id, title: n.title, @@ -904,28 +757,15 @@ server.registerTool( edgeCount: n.edge_count ?? 0 })) : []; - 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) : []; - // Get counts - const nodeCount = hubNodes.length > 0 ? undefined : 0; const stats = { - nodeCount: nodeCount ?? hubNodes.reduce((_, n) => 0, 0), - edgeCount: 0, - contextCount: contexts.length + nodeCount: 0, + edgeCount: 0 }; - // Try to get actual counts from a stats endpoint or compute try { const countResult = await callRaHApi('/api/nodes?limit=1', { method: 'GET' }); if (countResult.total !== undefined) { @@ -933,62 +773,26 @@ server.registerTool( } } catch { /* use defaults */ } - const summary = `Knowledge graph: ${stats.contextCount} contexts, ${hubNodes.length} hub nodes for graph grounding, ${guides.length} guides available.`; + try { + const edgeResult = await callRaHApi('/api/edges', { method: 'GET' }); + if (typeof edgeResult.count === 'number') { + stats.edgeCount = edgeResult.count; + } + } catch { /* use defaults */ } + + const summary = `Knowledge graph: ${stats.nodeCount} nodes, ${stats.edgeCount} edges, ${hubNodes.length} hub nodes, ${guides.length} guides available.`; return { content: [{ type: 'text', text: summary }], structuredContent: { stats, hubNodes, - contexts, guides } }; } ); -server.registerTool( - 'rah_write_context', - { - title: 'Write RA-H context node', - description: 'Write one atomic durable context node to the graph only after the user has explicitly approved the save. Use this for agent-suggested capture after you already proposed the node briefly and got a clear yes. Prefer ordinary create/update flows for explicit user-directed capture.', - inputSchema: writeContextInputSchema, - outputSchema: writeContextOutputSchema - }, - async ({ title, description, source, context_name, metadata, confirmed_by_user }) => { - if (!confirmed_by_user) { - throw new Error('rah_write_context requires explicit user confirmation before writing to the graph.'); - } - - const result = await callRaHApi('/api/nodes', { - method: 'POST', - body: JSON.stringify({ - title: title.trim(), - description: description.trim(), - source: source?.trim() || undefined, - context_name: context_name?.trim() || undefined, - metadata: { - captured_by: 'human', - captured_method: 'write_context', - ...(metadata || {}) - } - }) - }); - - const node = result.data; - const message = result.message || `Saved context as node #${node.id}: ${node.title}`; - return { - content: [{ type: 'text', text: message }], - structuredContent: { - success: true, - nodeId: node.id, - title: node.title, - message - } - }; - } -); - async function main() { const transport = new StdioServerTransport(); await server.connect(transport); diff --git a/docs/0_overview.md b/docs/0_overview.md index 0e8a6a3..2c3f30b 100644 --- a/docs/0_overview.md +++ b/docs/0_overview.md @@ -1,78 +1,71 @@ -# RA-H OS Overview +# RA-H Overview -## What is RA-OS? +## What is RA-H? -RA-H OS is the open-source local graph surface of RA-H. It gives you the graph, UI, and MCP path without the private Mac-app-only packaging and subscription surfaces. +RA-H is a local-first knowledge graph for durable thinking. It captures sources, ideas, people, decisions, and conversations into one graph on your machine, then lets you retrieve and extend that graph through the app and MCP. +**Website:** [ra-h.app](https://ra-h.app) **Open Source:** [github.com/bradwmorris/ra-h_os](https://github.com/bradwmorris/ra-h_os) +**New here?** Open chat and say `let's get started` to run the onboarding skill. + ## Design Philosophy -**Local-first** — Your knowledge network belongs to you. Everything runs locally in a SQLite database you control. +RA-H is built on the idea that capture quality matters more than taxonomy. -**External-agent friendly** — The open-source path is designed to work well with external MCP clients. The graph contract should not depend on prompt hacks or old taxonomy assumptions. +**Non-prescriptive** — RA-H does not require folders or dimensions. It pushes clearer nodes and clearer edges instead of category maintenance. -**Simple & focused** — The open-source surface keeps the graph, UI, and MCP contract. It does not try to mirror every private-app surface. +**Everything is connected** — Every piece of knowledge can potentially connect to any other. Connections aren't just links — they carry context, explanation, and meaning. + +**Local-first** — Your knowledge network belongs to you, not a platform. Your thinking, research, and connections all belong to you in a portable format you control. + +**Human + AI** — You guide, AI assists. Skills and graph quality shape behavior; the graph is not supposed to silently self-organize into truth without you. ## Tech Stack - **Frontend:** Next.js 15, TypeScript, Tailwind CSS - **Database:** SQLite + sqlite-vec (vector search) -- **Embeddings:** OpenAI (BYO API key) +- **AI Models:** Anthropic Claude + OpenAI GPT via Vercel AI SDK +- **Desktop:** Tauri (Mac app) - **MCP Server:** Local connector for Claude Code and external agents -## What's Included +## Current Status -- Multi-pane UI for feed, contexts, map, table, node focus, and skills -- Node/Edge CRUD with optional contexts -- Full-text and semantic search -- MCP server with graph and skill tools -- Skills system (shared instructions for internal + external agents) -- PDF extraction -- Graph visualization (Map view) -- BYO API keys +- **Version:** current internal product contract as of April 2026 +- **Platforms:** + - Mac app (download at [ra-h.app/download](https://ra-h.app/download)) + - Open source self-hosted (BYO API keys) +- **License:** MIT (open source version) -## What's NOT Included +## Two Ways to Use RA-H -- Private-app-only built-in assistant experience -- Voice features -- Auth/subscription system -- Desktop packaging +| Version | Best For | Get It | +|---------|----------|--------| +| **Mac App** | Most users. One-click install, auto-updates, optional subscription features | [ra-h.app/download](https://ra-h.app/download) | +| **Open Source** | Developers, self-hosters, contributors. BYO API keys, full control | [GitHub](https://github.com/bradwmorris/ra-h_os) | -## Current Doctrine +Both versions follow the same core graph contract. The Mac app adds packaging, auth, voice, and subscription surfaces. `ra-h_os` keeps the local graph, UI, and MCP path. -- no runtime `dimensions` -- optional `contexts` -- node quality driven by `title`, `description`, `source`, `metadata`, and `edges` -- direct lookup first, broader retrieval when useful -- app-owned chunking and embeddings from `nodes.source` +## Key Features -## MCP Integration - -RA-OS is designed to be the knowledge backend for your AI workflows: - -```json -{ - "mcpServers": { - "ra-h": { - "command": "npx", - "args": ["--yes", "ra-h-mcp-server@2.1.2"] - } - } -} -``` - -Add this to `~/.claude.json` and restart Claude. Run RA-H once first so the database exists. The standalone MCP server can write nodes without the app running, but the app owns chunking and embedding from node source. If you publish a newer MCP release and need clients to pick it up immediately, bump the pinned version here and restart the client. - -Core tools include: `queryNodes`, `retrieveQueryContext`, `createNode`, `writeContext`, `updateNode`, `getNodesById`, `createEdge`, `updateEdge`, `queryEdge`, `queryContexts`, `listSkills`, `readSkill` +- **Source-first graph:** node quality comes from title, description, source, metadata, and edges +- **Graph-first capture:** durable structure comes from nodes and edges, not category maintenance +- **Flexible pane system:** explicit `1 / 2 / 3` pane workspace with right-edge chat +- **Single assistant:** one built-in RA-H runtime grounded in your graph and skills +- **Retrieval split:** direct lookup for specific nodes, broader retrieval when the turn actually needs graph context +- **MCP server:** connect Claude Code and other external agents to the same graph +- **Skills:** markdown procedures that shape graph work and agent behavior +- **Extraction tools:** website, YouTube, and PDF ingestion paths ## Documentation | Doc | Description | |-----|-------------| +| [Architecture](./1_architecture.md) | Single-agent runtime, tools, and system design | | [Schema](./2_schema.md) | Database schema, node/edge structure | -| [Tools & Skills](./4_tools-and-guides.md) | Available MCP tools, skill system | +| [Context](./3_context.md) | How context flows through the system | +| [Tools & Skills](./4_tools-and-guides.md) | Available tools, skill system | | [UI](./6_ui.md) | Component structure, panels, views | +| [Voice](./7_voice.md) | Voice interface (STT/TTS) | | [MCP](./8_mcp.md) | External agent connector setup | -| [Full Local](./10_full-local.md) | Supported local path and community patterns | -| [Troubleshooting](./TROUBLESHOOTING.md) | Common issues and fixes | +| [Open Source](./9_open-source.md) | Contributor and sync guide | diff --git a/docs/2_schema.md b/docs/2_schema.md index f3f2bfe..eb8a0dc 100644 --- a/docs/2_schema.md +++ b/docs/2_schema.md @@ -11,15 +11,6 @@ - `metadata` - `chunk_status` - `event_date` -- `context_id` nullable FK to `contexts.id` -- `created_at` -- `updated_at` - -### `contexts` -- `id` -- `name` -- `description` -- `icon` - `created_at` - `updated_at` @@ -51,38 +42,35 @@ - `chunks_fts` indexes chunk text. - `vec_nodes` stores node-level vectors. - `vec_chunks` stores chunk-level vectors. - -Full-text search and vector search are separate surfaces: -- FTS uses `nodes_fts` / `chunks_fts` -- semantic/vector retrieval uses `vec_nodes` / `vec_chunks` -- retrieval may combine them, but docs should not blur them into one surface +- Full-text search and vector search are separate surfaces: + - FTS uses `nodes_fts` / `chunks_fts` + - semantic/vector retrieval uses `vec_nodes` / `vec_chunks` + - retrieval can combine them, but they should not be described as the same thing ## Embedding Lifecycle - `nodes.source` is the canonical long-form field for chunking and chunk embeddings. -- changing `nodes.source` should return the node to the app-owned chunk pipeline -- standalone MCP can write `nodes.source`, but it does not directly create chunks or vector rows -- node-level embeddings and chunk embeddings are separate runtime surfaces -- integrity/degraded-mode behavior matters enough that docs should describe these surfaces honestly +- Creating or changing `nodes.source` must put the node back through the app-owned chunk pipeline so the `chunks` rows, `chunks_fts`, and `vec_chunks` state reflect the latest source. +- Standalone MCP can write `nodes.source`, but it does not directly create `chunks` or vector rows. The app later processes those pending nodes. +- Deleting a node must remove dependent chunk rows and must not leave stale node/chunk search or vector state behind. +- Node-level embeddings are a separate surface from chunk embeddings. The contract for what feeds the node-level embedding must be explicit, and updates to those fields must trigger a fresh node-level embedding run. +- Integrity and degraded-mode checks must cover both search surfaces and embedding-related write surfaces, not just top-level node reads. + +## Edge Contract + +- `edges.explanation` is now a top-level field and should be treated as the human-readable reason the connection exists. +- `edges.context` still exists as structured JSON for inferred type, confidence, and creation metadata. +- docs should not describe edge context JSON as if it is the only user-facing explanation surface. ## Important Constraints - `dimensions` and `node_dimensions` are no longer canonical tables. - New installs should never create them. - Existing installs migrate by snapshotting old dimension data, then dropping the legacy tables. -- `contexts` are optional. `nodes.context_id` must allow `NULL`. +- FTS repair and integrity handling are now operational concerns. Do not describe automatic live rebuild behavior as normal product behavior. ## Common Queries -Nodes in a context: - -```sql -SELECT * -FROM nodes -WHERE context_id = ? -ORDER BY updated_at DESC; -``` - Most connected nodes: ```sql diff --git a/docs/4_tools-and-guides.md b/docs/4_tools-and-guides.md index 3be4c0d..6ae9bb4 100644 --- a/docs/4_tools-and-guides.md +++ b/docs/4_tools-and-guides.md @@ -1,56 +1,65 @@ # Tools & Skills -MCP tools are the graph contract. Skills are the reusable procedural layer that teaches agents how to use that contract well. +What actions agents can take, and how skills provide procedural guidance. -## Live MCP Tools +## Tool Groups + +| Group | Purpose | Examples | +|-------|---------|----------| +| Read | Find, inspect, and ground graph context | `queryNodes`, `retrieveQueryContext`, `getNodesById` | +| Write | Create or update graph structure | `createNode`, `updateNode`, `createEdge` | +| Extraction | Ingest external content into the graph | `websiteExtract`, `youtubeExtract`, `paperExtract` | +| Utility | Deep inspection or external support | `sqliteQuery`, `webSearch`, `think` | +| Skills | Procedural guidance | `listSkills`, `readSkill`, `writeSkill`, `deleteSkill` | + +## Live Tool Surface ### Read - -| Tool | Description | -|------|-------------| -| `getContext` | Graph overview for orientation | -| `queryNodes` | Direct node lookup by title, description, or source | -| `retrieveQueryContext` | Broader current-turn retrieval when graph grounding helps | -| `getNodesById` | Fetch full nodes by ID | -| `queryEdge` | Inspect existing edges | -| `queryContexts` | List/search contexts | -| `searchContentEmbeddings` | Search source chunks/transcripts | -| `sqliteQuery` | Read-only SQL (`SELECT`, `WITH`, `PRAGMA`) | +- `getContext` +- `queryNodes` +- `retrieveQueryContext` +- `getNodesById` +- `queryEdge` +- `searchContentEmbeddings` +- `sqliteQuery` +- `webSearch` +- `think` ### Write - -| Tool | Description | -|------|-------------| -| `createNode` | Create a node after duplicate/update checks | -| `updateNode` | Update a node while preserving context by default | -| `writeContext` | Save one confirmed durable context node | -| `createEdge` | Create a confirmed edge | -| `updateEdge` | Correct an edge after explicit confirmation | +- `createNode` +- `updateNode` +- `deleteNode` +- `createEdge` +- `updateEdge` ### Skills +- `listSkills` +- `readSkill` +- `writeSkill` +- `deleteSkill` -| Tool | Description | -|------|-------------| -| `listSkills` | List available skills | -| `readSkill` | Read one skill | -| `writeSkill` | Create or update a skill | -| `deleteSkill` | Delete a skill | +### Extraction +- `websiteExtract` +- `youtubeExtract` +- `paperExtract` -## Behavior Rules +## Important Behavior Rules -- search before creating -- use `queryNodes` first for specific-node intent -- use `retrieveQueryContext` only when broader grounding would help -- leave context blank by default -- if context is intentionally provided, prefer `context_name` -- `writeContext`, `createEdge`, and `updateEdge` are confirmation-gated -- judge graph quality by node quality and explicit edges, not taxonomy completeness +- Search before creating. +- Use `queryNodes` first when the user is clearly looking for a specific existing node. +- Use `retrieveQueryContext` when the current turn would benefit from broader graph grounding. +- `createEdge` and `updateEdge` are confirmation-gated. +- node creation quality should come from `title`, `description`, `source`, `metadata`, and explicit edges, not taxonomy. -## Skills +Metadata note for `createNode` / `updateNode`: +- prefer canonical keys: `type`, `state`, `captured_method`, `captured_by`, `source_metadata` +- `updateNode.metadata` merges into the existing object rather than replacing the whole blob -Skills are markdown instructions stored locally and shared across internal and external agents. +## Skills System -Default seeded skills: +Skills are markdown instruction documents shared by internal and external agents. + +Seeded defaults: - `db-operations` - `create-skill` - `audit` @@ -64,21 +73,21 @@ Storage: - live skills: `~/Library/Application Support/RA-H/skills/` - bundled defaults: `src/config/skills/` -## API Routes +## API Surfaces | Route | Method | Purpose | |-------|--------|---------| | `/api/skills` | GET | List skills | | `/api/skills/[name]` | GET/PUT/DELETE | Skill CRUD | -| `/api/guides` | GET | Compatibility alias to skills | -| `/api/guides/[name]` | GET/PUT/DELETE | Compatibility alias to skills | +| `/api/guides` | GET | Legacy compatibility alias to skills | +| `/api/guides/[name]` | GET/PUT/DELETE | Legacy compatibility alias to skills | ## Key Files | File | Purpose | |------|---------| -| `apps/mcp-server-standalone/` | Standalone MCP server | | `src/tools/infrastructure/registry.ts` | Live tool registry | -| `src/services/skills/skillService.ts` | Skills runtime service | +| `src/services/skills/skillService.ts` | App skill service | +| `apps/mcp-server-standalone/services/skillService.js` | Standalone MCP skill service | | `src/config/skills/*.md` | Bundled default skills | -| `src/components/panes/SkillsPane.tsx` | Skills pane UI | +| `apps/mcp-server-standalone/skills/*.md` | MCP bundled default skills | diff --git a/docs/6_ui.md b/docs/6_ui.md index 34d69c4..1379cec 100644 --- a/docs/6_ui.md +++ b/docs/6_ui.md @@ -11,7 +11,6 @@ RA-H OS follows the current pane model: ## Main Views - `Feed` for recent and sortable node browsing -- `Contexts` for optional context browsing - `Map` for graph structure - `Table` for dense inspection - `Skills` for editable agent instructions @@ -21,11 +20,11 @@ RA-H OS follows the current pane model: - The app no longer exposes a dimensions pane. - Feed and table filtering are not dimension-based. -- Persisted pane layout should only hydrate valid pane types: `views`, `node`, `contexts`, `map`, `table`, `skills`. -- Contexts are shown as a secondary organizational aid, not as a hard requirement for capture. +- Persisted pane layout should only hydrate valid pane types: `views`, `node`, `map`, `table`, `skills`. +- The app does not expose a separate organizing pane or category filter surface. ## Focus And Capture -- Capture must succeed when context is omitted. +- Capture must succeed without any category or context assignment. - Focus surfaces should emphasize title, description, source, metadata, and edges. -- Node cards may show context when present, but should not depend on it for meaning. +- Node cards and focus views should not depend on category labels for meaning. diff --git a/docs/8_mcp.md b/docs/8_mcp.md index 0450920..1501315 100644 --- a/docs/8_mcp.md +++ b/docs/8_mcp.md @@ -1,116 +1,41 @@ # MCP Surface -This is the full practical setup page for the standalone open-source MCP path. +RA-H exposes MCP tools for direct graph work against the local database or app API. -## 1. What MCP Gives You In RA-H OS +Important runtime distinction: -MCP lets an external agent: -- search your existing graph -- ground a broader task in relevant graph context -- create or update nodes -- propose and confirm edges -- read and write shared skills - -The graph contract is: -- no runtime `dimensions` -- optional `contexts` -- direct lookup first -- broader retrieval only when useful -- confirmation-gated durable writeback and edge changes - -## 2. Choose Your Assistant / Client - -Best-supported path: -- Claude Code -- Claude Desktop - -Also reasonable: -- Cursor and similar MCP-capable coding assistants - -Prefer a client that: -- supports local stdio MCP servers well -- reliably restarts after config changes -- lets you pin one package version in config - -## 3. Install The Standalone MCP Server - -Requirements: -- Node.js 18-22 LTS recommended -- existing RA-H DB created by running the app once -- pinned package version in client config - -Package: - -```bash -npx --yes ra-h-mcp-server@2.1.2 -``` - -If `better-sqlite3` fails to load: -- use Node 18-22 LTS -- rebuild native modules if you are developing locally - -## 4. Configure The Assistant With A Pinned Version - -Claude config example: - -```json -{ - "mcpServers": { - "ra-h": { - "command": "npx", - "args": ["--yes", "ra-h-mcp-server@2.1.2"] - } - } -} -``` - -Contributor local-path example: - -```json -{ - "mcpServers": { - "ra-h": { - "command": "node", - "args": ["/absolute/path/to/ra-h_os/apps/mcp-server-standalone/index.js"] - } - } -} -``` - -## 5. Restart And Verify The Tools Are Available - -After editing config: -- fully restart the client -- do not trust a soft window close -- ask the assistant whether RA-H tools are available - -Healthy verification usually means you can see tools like: -- `queryNodes` -- `retrieveQueryContext` -- `createNode` -- `readSkill` +- the app MCP surface talks to the running app/API +- the standalone MCP surface talks directly to an existing SQLite DB file +- standalone MCP can read and write nodes/edges without the app running, but it does not own chunking or embedding +- if standalone MCP writes `nodes.source` while the app is closed, the app later processes that node through startup recovery ## Core MCP Contract - `queryNodes` is the primary tool for direct node retrieval when the user is trying to find a specific existing node. - `retrieveQueryContext` is the primary retrieval entrypoint for substantive current-turn work when the agent needs graph context to support a broader answer. -- `getContext` returns graph orientation: stats, contexts, hubs, and skills. -- `createNode`, `updateNode`, and `queryNodes` leave context blank by default. -- If context is intentionally provided, prefer `context_name`. -- `context_id` is an internal implementation detail, not the normal agent-facing field. -- `writeContext` writes one confirmed durable context node and must never be called before explicit user approval. -- `createEdge` is a post-confirmation execution tool. -- `updateEdge` is also post-confirmation only. -- `queryNodes` searches title, description, and source, with optional context filters. +- `getContext` returns graph orientation: stats, hubs, and skills. +- `createEdge` is a post-confirmation execution tool. Agents should propose likely edges first and only write them after the user explicitly confirms. +- `queryNodes` searches title, description, and source. - `dimensions` are removed from the MCP contract. +## Behavior Split + +Internal app MCP surfaces: +- talk to the running app/API +- can participate in the live app runtime and UI refresh model + +Standalone MCP: +- talks directly to an existing SQLite DB +- can read and write nodes, edges, and skills without the app running +- does not own chunking, vector generation, or schema migration on a live existing DB +- relies on the app to process pending `nodes.source` work later + ## Main Tools Read: - `retrieveQueryContext` - `getContext` - `queryNodes` -- `queryContexts` - `getNodesById` - `queryEdge` - `listSkills` @@ -119,7 +44,6 @@ Read: - `sqliteQuery` Write: -- `writeContext` - `createNode` - `updateNode` - `createEdge` @@ -127,61 +51,26 @@ Write: - `writeSkill` - `deleteSkill` -## 6. How The Agent Should Behave With RA-H +## Tool Behavior -- always search before creating -- use `queryNodes` first for specific-node intent -- use `retrieveQueryContext` when broader graph grounding would help -- keep context optional by default -- use `context_name` only when context is intentionally provided -- do not assume the server will infer a best-fit context -- if the user explicitly asked to save or update something and the target artifact is clear, the agent can write after duplicate/update checks -- if the agent is only suggesting a save, it should propose the node first and wait for confirmation -- when obvious relationships appear, propose candidate edges briefly rather than writing them automatically -- keep writeback prompts terse and selective +- Always search before creating. +- If the user is trying to find a specific existing node, use `queryNodes` first. +- If the user is asking a broader question that would benefit from graph context, use `retrieveQueryContext`. +- Optional user memory reinforcement can help, but the MCP tools, instructions, skills, and docs should be enough for the core retrieval and writeback contract to work. +- If the user explicitly asked to save or update something and the target artifact is clear, the agent can write after duplicate/update checks. +- If the agent is only suggesting a save, it should propose the node first and wait for confirmation. +- When obvious relationships appear, propose candidate edges briefly rather than writing them automatically. +- Judge graph quality by node quality and edges, not taxonomy completeness. +- Keep writeback prompts terse and selective. The goal is not to ask constantly whether every useful sentence should be saved. +- Do not assume MCP node creation immediately produces chunks or embeddings. The canonical contract is: + - write node data first + - app-owned pipeline later creates chunks, FTS rows, and vectors -Do not assume MCP node creation immediately produces chunks or embeddings. The canonical contract is: -- write node data first -- app-owned pipeline later creates chunks, FTS rows, and vectors +## Memory-File Rule -## 7. Optional Memory-File Reinforcement +Optional assistant memory files can reinforce good behavior, but they are not supposed to be the hidden dependency that makes RA-H work. -Important distinction: -- `CLAUDE.md` is an assistant-native memory/instruction file for Claude Code -- `AGENTS.md` is a repo-local instruction file many teams already use -- other clients may have their own memory or instruction surfaces - -Rules: -- the MCP/tool/docs contract should work without user prompt surgery +Current rule: +- MCP tools, server instructions, skills, and docs should be enough for the base contract - optional reinforcement can still improve consistency -- do not create contradictory instruction files - -Short recommended reinforcement pattern: - -```md -Retrieve relevant RA-H context before substantive work, search before creating, and keep durable writeback prompts brief and confirmation-gated. -``` - -## 8. Troubleshooting And Common Failure Cases - -`Tools not found` -- fully restart the client -- verify the config path is the one your client actually uses -- run the pinned package manually once - -`Database not found` -- run the RA-H app once first so the DB exists -- confirm `RAH_DB_PATH` if using a custom path - -`Node writes land but embeddings/chunks are missing` -- this is usually expected when the app is closed -- standalone MCP writes node data first -- the app later processes pending `nodes.source` work - -`Native module load failure` -- use Node 18-22 LTS -- rebuild `better-sqlite3` if needed for local development - -`Version drift` -- pin the package version in client config -- bump the pinned version intentionally when testing a new release +- avoid contradictory instruction files across `CLAUDE.md`, `AGENTS.md`, and other client-specific memory surfaces diff --git a/docs/9_open-source.md b/docs/9_open-source.md index 41f9801..ed24221 100644 --- a/docs/9_open-source.md +++ b/docs/9_open-source.md @@ -1,47 +1,22 @@ # Open Source Surface -The open-source RA-H surface should match the main app contract where product behavior is shared: +`ra-h_os` should match the main app contract wherever the underlying product behavior is shared. + +That means the open-source docs should reflect: - no runtime `dimensions` model, -- optional soft `contexts`, -- no automatic context assignment on write, - node quality driven by title, description, source, metadata, and edges. -## What RA-H OS Includes - -- local SQLite graph -- local UI -- standalone MCP server -- shared skills system -- BYO API key path - -## What RA-H OS Does Not Promise - -- every private-app surface -- private subscription/auth behavior -- official support for every local model stack or vector backend someone can wire up -- a guarantee that every community setup is first-class supported - -## Support Boundary - -Supported core path: -- RA-H OS app -- local SQLite -- standard standalone MCP server -- documented repo install flow - -Reasonable community pattern: -- local model or alternate MCP-capable chat surface layered on top of the documented contract - -Experimental / user-owned: -- custom vector backends -- unsupported deployment targets -- aggressive local-model substitutions that degrade tool quality +It should also explain the open-source-specific reality clearly: +- no private-app-only promises +- a practical standalone MCP install path +- pinned package versions for external-agent setup +- clear verification and troubleshooting steps +- honest support boundaries for fully local or community setups ## Important App Routes - `app/api/nodes/` -- `app/api/contexts/` - `app/api/edges/` - `app/api/rah/chat/` - extraction routes @@ -52,3 +27,11 @@ Experimental / user-owned: Main `ra-h` ships first. `ra-h_os` is a required follow-up port of the same contract, not a place to preserve older taxonomy behavior. + +## Required Docs Surfaces In `ra-h_os` + +- `README.md` for terse install + MCP quickstart +- `docs/README.md` for the start-here path +- `docs/8_mcp.md` for the full MCP setup, verification, memory-file guidance, and troubleshooting path +- `docs/10_full-local.md` for clearly caveated local/community patterns +- `apps/mcp-server-standalone/README.md` for package-level install details that match the repo docs diff --git a/scripts/README.md b/scripts/README.md index dcb3279..baaa7d4 100644 --- a/scripts/README.md +++ b/scripts/README.md @@ -24,7 +24,7 @@ npm run backup ``` - Creates timestamped backup in `/backups/` folder - Example: `rah_backup_20250902_102846.sql` -- Includes all nodes, chunks, edges, contexts, and migration snapshots +- Includes all nodes, chunks, edges, logs, chats, voice usage, and migration snapshots ### Restore from Backup ```bash @@ -59,7 +59,7 @@ node scripts/helpers/delete-helper.js "helper-id" - All nodes (42,000+ knowledge items) - Content chunks and embeddings - Node connections/edges -- Contexts, metadata, and migration snapshots +- Metadata, logs, chats, voice usage, and migration snapshots - Database schema and indexes ## Notes diff --git a/scripts/database/sqlite-ensure-app-schema.sh b/scripts/database/sqlite-ensure-app-schema.sh index 26d54c6..0346658 100755 --- a/scripts/database/sqlite-ensure-app-schema.sh +++ b/scripts/database/sqlite-ensure-app-schema.sh @@ -110,7 +110,7 @@ VALUES ( SQL fi -echo "Ensuring core tables exist (nodes, chunks, edges, chats, contexts)..." +echo "Ensuring core tables exist (nodes, chunks, edges, chats)..." if ! has_table nodes; then "$SQLITE_BIN" "$DB_PATH" <<'SQL' @@ -132,20 +132,6 @@ CREATE TABLE nodes ( SQL fi -if ! has_table contexts; then - "$SQLITE_BIN" "$DB_PATH" <<'SQL' -CREATE TABLE contexts ( - id INTEGER PRIMARY KEY, - name TEXT NOT NULL, - description TEXT NOT NULL, - icon TEXT, - created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, - updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP -); -CREATE UNIQUE INDEX idx_contexts_name_normalized ON contexts(LOWER(TRIM(name))); -SQL -fi - if ! has_table chunks; then "$SQLITE_BIN" "$DB_PATH" <<'SQL' CREATE TABLE chunks ( @@ -172,7 +158,6 @@ CREATE TABLE edges ( source TEXT, created_at TEXT, context TEXT, - explanation TEXT, FOREIGN KEY (from_node_id) REFERENCES nodes(id) ON DELETE CASCADE, FOREIGN KEY (to_node_id) REFERENCES nodes(id) ON DELETE CASCADE ); @@ -181,186 +166,14 @@ CREATE INDEX idx_edges_to ON edges(to_node_id); SQL fi -if has_table edges; then - NEEDS_EDGE_REWRITE=0 - if ! has_col edges from_node_id || ! has_col edges to_node_id || ! has_col edges source || ! has_col edges created_at || ! has_col edges context; then - NEEDS_EDGE_REWRITE=1 - fi - if has_col edges from_id || has_col edges to_id || has_col edges description || has_col edges updated_at; then - NEEDS_EDGE_REWRITE=1 - fi - - if [ "$NEEDS_EDGE_REWRITE" = "1" ]; then - echo "Migrating legacy edges table to canonical schema" - - FROM_EXPR="NULL" - if has_col edges from_node_id; then - FROM_EXPR="from_node_id" - elif has_col edges from_id; then - FROM_EXPR="from_id" - fi - - TO_EXPR="NULL" - if has_col edges to_node_id; then - TO_EXPR="to_node_id" - elif has_col edges to_id; then - TO_EXPR="to_id" - fi - - SOURCE_EXPR="'legacy'" - if has_col edges source; then - SOURCE_EXPR="source" - fi - - CREATED_AT_EXPR="CURRENT_TIMESTAMP" - if has_col edges created_at; then - CREATED_AT_EXPR="created_at" - fi - - CONTEXT_EXPR="NULL" - if has_col edges context; then - CONTEXT_EXPR="context" - fi - - EXPLANATION_EXPR="NULL" - if has_col edges explanation; then - EXPLANATION_EXPR="explanation" - elif has_col edges description; then - EXPLANATION_EXPR="description" - elif has_col edges context; then - EXPLANATION_EXPR="CASE WHEN json_valid(context) THEN json_extract(context, '\$.explanation') ELSE NULL END" - fi - - "$SQLITE_BIN" "$DB_PATH" < logs if needed diff --git a/scripts/database/sqlite-repair-fts.sh b/scripts/database/sqlite-repair-fts.sh index 109c731..20db436 100755 --- a/scripts/database/sqlite-repair-fts.sh +++ b/scripts/database/sqlite-repair-fts.sh @@ -113,15 +113,6 @@ CREATE TABLE agents ( prompts TEXT ); -CREATE TABLE contexts ( - id INTEGER PRIMARY KEY, - name TEXT NOT NULL, - description TEXT NOT NULL, - icon TEXT, - created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, - updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP -); - CREATE TABLE nodes ( id INTEGER PRIMARY KEY, title TEXT, @@ -135,9 +126,7 @@ CREATE TABLE nodes ( embedding BLOB, embedding_updated_at TEXT, embedding_text TEXT, - chunk_status TEXT DEFAULT 'not_chunked', - context_id INTEGER, - FOREIGN KEY (context_id) REFERENCES contexts(id) ON DELETE SET NULL + chunk_status TEXT DEFAULT 'not_chunked' ); CREATE TABLE edges ( @@ -157,8 +146,7 @@ CREATE TABLE chunks ( node_id INTEGER NOT NULL, chunk_idx INTEGER, text TEXT NOT NULL, - embedding BLOB, - embedding_type TEXT DEFAULT 'openai', + embedding_type TEXT DEFAULT 'text-embedding-3-small', metadata TEXT, created_at TEXT DEFAULT CURRENT_TIMESTAMP, FOREIGN KEY (node_id) REFERENCES nodes(id) ON DELETE CASCADE @@ -217,20 +205,17 @@ CREATE TABLE dimension_migration_snapshots ( INSERT INTO schema_version SELECT version, applied_at, description FROM source.schema_version; INSERT INTO agents SELECT id, key, display_name, role, system_prompt, available_tools, model, description, enabled, created_at, updated_at, memory, prompts FROM source.agents; -INSERT INTO contexts SELECT id, name, description, icon, created_at, updated_at FROM source.contexts; -INSERT INTO nodes SELECT id, title, description, source, link, event_date, created_at, updated_at, metadata, embedding, embedding_updated_at, embedding_text, chunk_status, context_id FROM source.nodes; +INSERT INTO nodes SELECT id, title, description, source, link, event_date, created_at, updated_at, metadata, embedding, embedding_updated_at, embedding_text, chunk_status FROM source.nodes; INSERT INTO edges SELECT id, from_node_id, to_node_id, source, created_at, context, explanation FROM source.edges; -INSERT INTO chunks SELECT id, node_id, chunk_idx, text, embedding, embedding_type, metadata, created_at FROM source.chunks; +INSERT INTO chunks SELECT id, node_id, chunk_idx, text, embedding_type, metadata, created_at FROM source.chunks; INSERT INTO chats SELECT id, chat_type, helper_name, agent_type, delegation_id, user_message, assistant_message, thread_id, focused_node_id, created_at, metadata FROM source.chats; INSERT INTO logs SELECT id, ts, table_name, action, row_id, summary, snapshot_json, enriched_summary FROM source.logs; INSERT INTO voice_usage SELECT id, chat_id, session_id, helper_name, request_id, message_id, voice, model, chars, cost_usd, duration_ms, text_preview, created_at FROM source.voice_usage; INSERT INTO dimension_migration_snapshots SELECT id, migrated_at, dimension_count, assignment_count, payload FROM source.dimension_migration_snapshots; -CREATE UNIQUE INDEX idx_contexts_name_normalized ON contexts(LOWER(TRIM(name))); CREATE INDEX idx_edges_from ON edges(from_node_id); CREATE INDEX idx_edges_to ON edges(to_node_id); CREATE INDEX idx_nodes_updated_at ON nodes(updated_at DESC); -CREATE INDEX idx_nodes_context_id ON nodes(context_id); CREATE INDEX idx_chunks_node_id ON chunks(node_id); CREATE INDEX idx_chunks_by_node ON chunks(node_id); CREATE INDEX idx_chunks_by_node_idx ON chunks(node_id, chunk_idx); diff --git a/scripts/dev/bootstrap-local.mjs b/scripts/dev/bootstrap-local.mjs index bb24d2e..7e337b2 100644 --- a/scripts/dev/bootstrap-local.mjs +++ b/scripts/dev/bootstrap-local.mjs @@ -153,16 +153,6 @@ function ensureCoreSchema(db) { FOREIGN KEY (focused_node_id) REFERENCES nodes(id) ON DELETE SET NULL ); - CREATE TABLE IF NOT EXISTS contexts ( - id INTEGER PRIMARY KEY, - 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 - ); - CREATE UNIQUE INDEX IF NOT EXISTS idx_contexts_name_normalized ON contexts(LOWER(TRIM(name))); - CREATE TABLE IF NOT EXISTS logs ( id INTEGER PRIMARY KEY, ts TEXT NOT NULL DEFAULT (CURRENT_TIMESTAMP), @@ -408,31 +398,6 @@ function ensureCoreSchema(db) { if (!hasNodeCol('chunk_status')) { db.exec("ALTER TABLE nodes ADD COLUMN chunk_status TEXT DEFAULT 'not_chunked';"); } - if (!hasNodeCol('context_id')) { - db.exec('ALTER TABLE nodes ADD COLUMN context_id INTEGER REFERENCES contexts(id) ON DELETE SET NULL;'); - } - - const contextCols = db.prepare('PRAGMA table_info(contexts)').all().map(col => col.name); - const hasContextCol = (name) => contextCols.includes(name); - if (!hasContextCol('description')) { - db.exec("ALTER TABLE contexts ADD COLUMN description TEXT NOT NULL DEFAULT '';"); - } - if (!hasContextCol('icon')) { - db.exec('ALTER TABLE contexts ADD COLUMN icon TEXT;'); - } - if (!hasContextCol('created_at')) { - db.exec("ALTER TABLE contexts ADD COLUMN created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP;"); - } - if (!hasContextCol('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 INDEX IF NOT EXISTS idx_nodes_context_id ON nodes(context_id); - `); if (hasNodeCol('content')) { db.exec(` diff --git a/src/components/focus/FocusPanel.tsx b/src/components/focus/FocusPanel.tsx index b46a07d..cd85d94 100644 --- a/src/components/focus/FocusPanel.tsx +++ b/src/components/focus/FocusPanel.tsx @@ -12,7 +12,6 @@ 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[]; @@ -68,9 +67,6 @@ 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); @@ -79,7 +75,6 @@ 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); @@ -105,34 +100,6 @@ 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) => { if (!nodesData[tabId] && !loadingNodes.has(tabId)) { @@ -165,7 +132,6 @@ export default function FocusPanel({ setDescEditValue(''); setSourceEditMode(false); setSourceEditValue(''); - setContextMenuOpen(false); setEdgeSearchOpen(false); setEdgeEditingId(null); setEdgeEditingValue(''); @@ -389,20 +355,6 @@ 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 { @@ -1192,60 +1144,6 @@ export default function FocusPanel({ -
-
context
-
-
-
- - {currentNode.context?.name || 'No context'} - - -
- - {contextMenuOpen ? ( -
- - {availableContexts.map((context) => ( - - ))} -
- ) : null} -
-
-
- {/* Connections */}
edge
diff --git a/src/components/layout/ChatPanel.tsx b/src/components/layout/ChatPanel.tsx new file mode 100644 index 0000000..fe7b688 --- /dev/null +++ b/src/components/layout/ChatPanel.tsx @@ -0,0 +1,106 @@ +"use client"; + +import { MessageSquare, X } from 'lucide-react'; +import type { ChatPanelProps } from '../panes/types'; + +export default function ChatPanel({ + isOpen, + onClose, + onOpen, +}: ChatPanelProps) { + if (!isOpen) { + return ( +
+ +
+ ); + } + + return ( +
+
+ + Chat + + +
+ +
+ Chat UI is not included in this open-source parity slice. The graph, MCP, retrieval, and skill surfaces remain available. +
+
+ ); +} diff --git a/src/components/layout/LeftToolbar.tsx b/src/components/layout/LeftToolbar.tsx index f01a81f..de8b62d 100644 --- a/src/components/layout/LeftToolbar.tsx +++ b/src/components/layout/LeftToolbar.tsx @@ -7,7 +7,6 @@ import { RefreshCw, LayoutList, Map, - Folder, ChevronDown, ChevronRight, Table2, @@ -21,7 +20,6 @@ import { import type { PaneType } from '../panes/types'; import type { FocusedSkill } from '@/types/skills'; import type { Theme } from '@/hooks/useTheme'; -import type { ContextSummary } from '@/types/database'; interface LeftToolbarProps { onSearchClick: () => void; @@ -38,8 +36,6 @@ interface LeftToolbarProps { focusedSkill?: FocusedSkill | null; theme: Theme; onThemeToggle: () => void; - contexts?: ContextSummary[]; - onContextQuickSelect?: (contextId: number, contextName: string) => void; } const NAV_WIDTH_COLLAPSED = 50; @@ -124,10 +120,7 @@ export default function LeftToolbar({ focusedSkill, theme, onThemeToggle, - contexts = [], - onContextQuickSelect, }: LeftToolbarProps) { - const [contextsExpanded, setContextsExpanded] = useState(false); const [paneSelectorOpen, setPaneSelectorOpen] = useState(false); const renderPaneGlyph = (count: 1 | 2 | 3, active: boolean) => ( @@ -302,77 +295,6 @@ export default function LeftToolbar({ activeTone="green" /> ))} - -
-
-
- onPaneTypeClick('contexts')} - activeTone="green" - /> -
- {isExpanded ? ( - - ) : null} -
- - {isExpanded && contextsExpanded && contexts.length > 0 ? ( -
- {contexts.map((context) => ( - - ))} -
- ) : null} -
diff --git a/src/components/layout/ThreePanelLayout.tsx b/src/components/layout/ThreePanelLayout.tsx index 2e5067e..8eeb508 100644 --- a/src/components/layout/ThreePanelLayout.tsx +++ b/src/components/layout/ThreePanelLayout.tsx @@ -1,20 +1,25 @@ "use client"; import { useState, useCallback, useEffect, useRef, useMemo } from 'react'; +import { GripVertical, X } from 'lucide-react'; import SettingsModal, { SettingsTab } from '../settings/SettingsModal'; import SearchModal from '../nodes/SearchModal'; -import type { ContextSummary, Node } from '@/types/database'; +import { Node } from '@/types/database'; import { DatabaseEvent } from '@/services/events'; import { usePersistentState } from '@/hooks/usePersistentState'; import { useTheme } from '@/hooks/useTheme'; +// Layout components import LeftToolbar from './LeftToolbar'; import SplitHandle from './SplitHandle'; +import ChatPanel from './ChatPanel'; -import { NodePane, ContextsPane, MapPane, ViewsPane, TablePane, SkillsPane, SlotTabBar } from '../panes'; +// Pane components +import { NodePane, MapPane, ViewsPane, TablePane, SkillsPane, SlotTabBar } from '../panes'; import QuickAddInput from '../agents/QuickAddInput'; import type { PaneType, SlotState, SlotTab, PaneAction, SlotId } from '../panes/types'; import { createTabId, getActiveTab } from '../panes/types'; +import type { FocusedSkill } from '@/types/skills'; export interface PendingNode { id: string; @@ -25,9 +30,124 @@ export interface PendingNode { error?: string; } -const SLOT_A_KEY = 'ui.slotA.v7'; -const SLOT_B_KEY = 'ui.slotB.v7'; -const SLOT_C_KEY = 'ui.slotC.v7'; +// --- localStorage migration --- +function migrateSlotState(raw: unknown): SlotState | null { + if (raw === null || raw === undefined) return null; + const obj = raw as Record; + const validPaneTypes = new Set(['views', 'node', 'map', 'table', 'skills']); + + // Already v4+ format (has tabs array) — filter out removed pane types + if (Array.isArray(obj.tabs)) { + const state = raw as SlotState; + const mappedTabs = state.tabs.map(t => (t.type as string) === 'guides' + ? { ...t, id: 'skills', type: 'skills' as PaneType } + : t); + const filtered = mappedTabs.filter(t => validPaneTypes.has(t.type)); + if (filtered.length === 0) return null; + const activeStillExists = filtered.some(t => t.id === state.activeTabId); + const activeTabId = state.activeTabId === 'guides' ? 'skills' : state.activeTabId; + return { tabs: filtered, activeTabId: activeStillExists ? activeTabId : filtered[0].id }; + } + + // v3 format (has type field) — migrate + if (typeof obj.type === 'string') { + const oldType = obj.type as string; + + // Removed pane types should not hydrate into current panel state + if (!validPaneTypes.has(oldType as PaneType) && oldType !== 'guides') return null; + if (oldType === 'guides') { + return { tabs: [{ id: 'skills', type: 'skills' }], activeTabId: 'skills' }; + } + + const paneType = oldType as PaneType; + const tabs: SlotTab[] = []; + + // Add the main type tab + if (paneType === 'node') { + // Migrate node tabs + const nodeTabs = (obj.nodeTabs as number[]) || []; + for (const nodeId of nodeTabs) { + tabs.push({ id: createTabId('node', nodeId), type: 'node', nodeId }); + } + if (tabs.length === 0) { + // Empty node pane — just return null + return null; + } + const activeNodeTab = obj.activeNodeTab as number | null | undefined; + const activeTabId = activeNodeTab != null + ? createTabId('node', activeNodeTab) + : tabs[0].id; + return { tabs, activeTabId }; + } + + // Singleton pane type + tabs.push({ id: createTabId(paneType), type: paneType }); + return { tabs, activeTabId: tabs[0].id }; + } + + return null; +} + +function stripNodeTabsForPersistence(state: SlotState | null): SlotState | null { + if (!state) return null; + + const tabs = state.tabs.filter((tab) => tab.type !== 'node'); + if (tabs.length === 0) { + return null; + } + + const activeTabId = tabs.some((tab) => tab.id === state.activeTabId) + ? state.activeTabId + : tabs[0].id; + + return { tabs, activeTabId }; +} + +function readPersistedSlotState(key: string, fallback: SlotState | null): SlotState | null { + if (typeof window === 'undefined') { + return fallback; + } + + try { + const raw = window.localStorage.getItem(key); + if (raw === null) { + return fallback; + } + + return stripNodeTabsForPersistence(migrateSlotState(JSON.parse(raw))) ?? fallback; + } catch (error) { + console.error(`Error loading ${key} from localStorage:`, error); + return fallback; + } +} + +function persistSlotState(key: string, state: SlotState | null): void { + if (typeof window === 'undefined') { + return; + } + + try { + const sanitized = stripNodeTabsForPersistence(state); + if (sanitized) { + window.localStorage.setItem(key, JSON.stringify(sanitized)); + } else { + window.localStorage.removeItem(key); + } + } catch (error) { + console.error(`Error saving ${key} to localStorage:`, error); + } +} + +const DEFAULT_SLOT_A: SlotState = { + tabs: [{ id: 'views', type: 'views' }], + activeTabId: 'views', +}; + +const SLOT_A_KEY = 'ui.slotA.v6'; +const SLOT_B_KEY = 'ui.slotB.v6'; +const SLOT_C_KEY = 'ui.slotC.v6'; +const CHAT_PANEL_OPEN_KEY = 'ui.chatPanel.open'; +const CHAT_SLOT_KEY = 'ui.chatPanel.slot.v1'; const VISIBLE_PANE_COUNT_KEY = 'ui.visiblePaneCount.v1'; const PANEL_A_EXPANDED_KEY = 'ui.panelA.expanded.v1'; const PANEL_B_EXPANDED_KEY = 'ui.panelB.expanded.v1'; @@ -36,182 +156,350 @@ const PANEL_A_WEIGHT_KEY = 'ui.panelA.weight.v1'; const PANEL_B_WEIGHT_KEY = 'ui.panelB.weight.v1'; const PANEL_C_WEIGHT_KEY = 'ui.panelC.weight.v1'; const LEFT_NAV_EXPANDED_KEY = 'ui.leftNavExpanded'; -const ACTIVE_CONTEXT_KEY = 'ui.focus.activeContextId'; - -const VALID_PANE_TYPES = new Set(['node', 'contexts', 'map', 'views', 'table', 'skills']); - -const DEFAULT_SLOT_A: SlotState = { - tabs: [{ id: 'views', type: 'views' }], - activeTabId: 'views', -}; - -const EMPTY_SEARCH_FILTERS: Array<{ type: 'context' | 'title' | 'tag'; value: string }> = []; +const ONBOARDING_SURFACE_SEEN_KEY = 'ui.onboarding.firstRun.seen.v1'; +const ONBOARDING_HINT_DISMISSED_KEY = 'ui.onboarding.firstRun.dismissed.v1'; +const ONBOARDING_BOOTSTRAP_CHECKED_KEY = 'ui.onboarding.firstRun.checked.v1'; +const ONBOARDING_HINT_TEXT = 'Just tell your agent you want to get setup, and it will help you.'; const SLOT_ORDER: SlotId[] = ['A', 'B', 'C']; +const MIN_PANE_WIDTH = 280; +const CHAT_FEATURE_ENABLED = false; -function createSingletonState(type: Exclude): SlotState { - return { - tabs: [{ id: createTabId(type), type }], - activeTabId: createTabId(type), - }; -} - -function migrateSlotState(raw: unknown): SlotState | null { - if (!raw || typeof raw !== 'object') { - return null; +function deriveInitialPaneCount(): number { + if (typeof window === 'undefined') { + return 2; } - const value = raw as Record; - - if (Array.isArray(value.tabs)) { - const tabs: SlotTab[] = []; - for (const tab of value.tabs) { - if (!tab || typeof tab !== 'object') continue; - const tabValue = tab as Record; - const rawType = tabValue.type === 'guides' ? 'skills' : tabValue.type; - if (typeof rawType !== 'string' || !VALID_PANE_TYPES.has(rawType as PaneType)) continue; - - tabs.push({ - id: typeof tabValue.id === 'string' - ? tabValue.id - : createTabId(rawType as PaneType, typeof tabValue.nodeId === 'number' ? tabValue.nodeId : undefined), - type: rawType as PaneType, - ...(typeof tabValue.nodeId === 'number' ? { nodeId: tabValue.nodeId } : {}), - }); + const stored = window.localStorage.getItem(VISIBLE_PANE_COUNT_KEY); + if (stored) { + const parsed = Number(stored); + if (parsed >= 1 && parsed <= 3) { + return parsed; } - - if (tabs.length === 0) return null; - - const activeTabId = typeof value.activeTabId === 'string' && tabs.some((tab) => tab.id === value.activeTabId) - ? value.activeTabId - : tabs[0].id; - - return { tabs, activeTabId }; } - if (typeof value.type === 'string') { - const rawType = value.type === 'guides' ? 'skills' : value.type; - if (rawType === 'dimensions') return null; - if (!VALID_PANE_TYPES.has(rawType as PaneType)) return null; - - if (rawType === 'node') { - const nodeTabs = Array.isArray(value.nodeTabs) - ? value.nodeTabs.filter((nodeId): nodeId is number => typeof nodeId === 'number') - : []; - if (nodeTabs.length === 0) return null; - const tabs = nodeTabs.map((nodeId) => ({ id: createTabId('node', nodeId), type: 'node' as const, nodeId })); - const preferredNodeId = typeof value.activeNodeTab === 'number' ? value.activeNodeTab : nodeTabs[0]; - return { - tabs, - activeTabId: createTabId('node', preferredNodeId), - }; - } - - return createSingletonState(rawType as Exclude); + try { + const panelCExpanded = window.localStorage.getItem(PANEL_C_EXPANDED_KEY) === 'true'; + const panelBExpanded = window.localStorage.getItem(PANEL_B_EXPANDED_KEY) === 'true'; + return panelCExpanded ? 3 : panelBExpanded ? 2 : 1; + } catch { + return 2; } - - return null; } -function sanitizeSlotState(state: SlotState | null, fallback: SlotState | null = null): SlotState | null { - const migrated = migrateSlotState(state); - return migrated ?? fallback; -} - -function areSlotStatesEqual(a: SlotState | null, b: SlotState | null): boolean { - if (a === b) return true; - if (!a || !b) return a === b; - if (a.activeTabId !== b.activeTabId) return false; - if (a.tabs.length !== b.tabs.length) return false; - - return a.tabs.every((tab, index) => { - const other = b.tabs[index]; - return ( - tab.id === other?.id && - tab.type === other?.type && - tab.nodeId === other?.nodeId - ); - }); -} - -function getSlotTabsByType(state: SlotState | null, type: PaneType): SlotTab[] { - return state?.tabs.filter((tab) => tab.type === type) ?? []; +function isTauriRuntime(): boolean { + if (typeof window === 'undefined') return false; + return Boolean((window as unknown as { __TAURI__?: unknown }).__TAURI__); } export default function ThreePanelLayout() { const containerRef = useRef(null); const panelRefs = useRef>({ A: null, B: null, C: null }); + const hasHydratedPaneCountRef = useRef(false); const [theme, toggleTheme] = useTheme(); - const [slotA, setSlotA] = usePersistentState(SLOT_A_KEY, DEFAULT_SLOT_A); - const [slotB, setSlotB] = usePersistentState(SLOT_B_KEY, null); - const [slotC, setSlotC] = usePersistentState(SLOT_C_KEY, null); + const [slotA, setSlotA] = useState(() => readPersistedSlotState(SLOT_A_KEY, DEFAULT_SLOT_A)); + const [slotB, setSlotB] = useState(() => readPersistedSlotState(SLOT_B_KEY, null)); + const [slotC, setSlotC] = useState(() => readPersistedSlotState(SLOT_C_KEY, null)); const [visiblePaneCount, setVisiblePaneCount] = usePersistentState(VISIBLE_PANE_COUNT_KEY, 2); - const [panelAExpanded, setPanelAExpanded] = usePersistentState(PANEL_A_EXPANDED_KEY, true); const [panelBExpanded, setPanelBExpanded] = usePersistentState(PANEL_B_EXPANDED_KEY, false); const [panelCExpanded, setPanelCExpanded] = usePersistentState(PANEL_C_EXPANDED_KEY, false); const [panelAWeight, setPanelAWeight] = usePersistentState(PANEL_A_WEIGHT_KEY, 1); const [panelBWeight, setPanelBWeight] = usePersistentState(PANEL_B_WEIGHT_KEY, 1); const [panelCWeight, setPanelCWeight] = usePersistentState(PANEL_C_WEIGHT_KEY, 1); + const [chatPanelOpen, setChatPanelOpen] = usePersistentState(CHAT_PANEL_OPEN_KEY, false); + const [chatSlot, setChatSlot] = usePersistentState(CHAT_SLOT_KEY, null); const [leftNavExpanded, setLeftNavExpanded] = usePersistentState(LEFT_NAV_EXPANDED_KEY, false); - const [activeContextId, setActiveContextId] = usePersistentState(ACTIVE_CONTEXT_KEY, null); + // Track which pane is active const [activePane, setActivePane] = useState('A'); + + // Settings modal state const [showSettings, setShowSettings] = useState(false); const [settingsInitialTab, setSettingsInitialTab] = useState(); - const [showSearchModal, setShowSearchModal] = useState(false); - const [showAddStuff, setShowAddStuff] = useState(false); - const [nodesPanelRefresh, setNodesPanelRefresh] = useState(0); - const [focusPanelRefresh, setFocusPanelRefresh] = useState(0); - const [availableContexts, setAvailableContexts] = useState([]); - const [browseContextFilters, setBrowseContextFilters] = useState>({ - A: null, - B: null, - C: null, - }); - const [highlightedPassage, setHighlightedPassage] = useState<{ - nodeId: number; - nodeTitle: string; - selectedText: string; - } | null>(null); - const [pendingNodes, setPendingNodes] = useState([]); - const [dragOverSlot, setDragOverSlot] = useState(null); - const openNodeIdsRef = useRef([]); - const handleNodeDeletedRef = useRef<(nodeId: number) => void>(() => {}); - const handleCloseSettings = useCallback(() => { setShowSettings(false); setSettingsInitialTab(undefined); }, []); - useEffect(() => { - setSlotA((prev) => { - const next = sanitizeSlotState(prev, DEFAULT_SLOT_A); - return areSlotStatesEqual(prev, next) ? prev : next; - }); - setSlotB((prev) => { - const next = sanitizeSlotState(prev); - return areSlotStatesEqual(prev, next) ? prev : next; - }); - setSlotC((prev) => { - const next = sanitizeSlotState(prev); - return areSlotStatesEqual(prev, next) ? prev : next; - }); - }, [setSlotA, setSlotB, setSlotC]); + // Search modal state + const [showSearchModal, setShowSearchModal] = useState(false); + + // Add Stuff modal state + const [showAddStuff, setShowAddStuff] = useState(false); + + // Pending quick-add nodes (loading placeholders) + const [pendingNodes, setPendingNodes] = useState([]); + + // Track selected nodes (for context) + const [selectedNodes, setSelectedNodes] = useState>(new Set()); + const [focusedNodeId, setFocusedNodeId] = useState(null); + const [isMapFocusSuppressed, setIsMapFocusSuppressed] = useState(false); + + // Open tabs data (full node objects for context) + const [openTabsData, setOpenTabsData] = useState([]); + + // Event handlers for SSE events + const [nodesPanelRefresh, setNodesPanelRefresh] = useState(0); + const [focusPanelRefresh, setFocusPanelRefresh] = useState(0); + const [folderViewRefresh, setFolderViewRefresh] = useState(0); + + const [focusedSkill, setFocusedSkill] = useState(null); + const [autoOpenSkillName, setAutoOpenSkillName] = useState(null); + const [showOnboardingHint, setShowOnboardingHint] = useState(false); + + // Chat state (lifted to persist across pane type changes) + const [chatMessages, setChatMessages] = useState([]); + + // Source awareness - highlighted passage context for agent + const [highlightedPassage, setHighlightedPassage] = useState<{ + nodeId: number; + nodeTitle: string; + selectedText: string; + } | null>(null); + + // Ref to get current openTabs value in SSE handler + const openTabsRef = useRef([]); + + const dismissOnboardingHint = useCallback(() => { + if (typeof window !== 'undefined') { + window.localStorage.setItem(ONBOARDING_HINT_DISMISSED_KEY, 'true'); + } + setShowOnboardingHint(false); + }, []); useEffect(() => { + persistSlotState(SLOT_A_KEY, slotA); + }, [slotA]); + + useEffect(() => { + persistSlotState(SLOT_B_KEY, slotB); + }, [slotB]); + + useEffect(() => { + persistSlotState(SLOT_C_KEY, slotC); + }, [slotC]); + + useEffect(() => { + let cancelled = false; + + if (typeof window === 'undefined' || !isTauriRuntime()) { + return; + } + + if ( + window.localStorage.getItem(ONBOARDING_SURFACE_SEEN_KEY) + || window.localStorage.getItem(ONBOARDING_HINT_DISMISSED_KEY) + || window.localStorage.getItem(ONBOARDING_BOOTSTRAP_CHECKED_KEY) + ) { + return; + } + void (async () => { try { - const response = await fetch('/api/contexts'); - const payload = await response.json(); - if (response.ok && payload.success) { - setAvailableContexts(payload.data || []); + const response = await fetch('/api/nodes?limit=1'); + if (!response.ok) return; + + const data = await response.json(); + const total = typeof data.total === 'number' + ? data.total + : Number(data.total ?? data.count ?? 0); + + if (cancelled || Number.isNaN(total)) { + return; + } + + window.localStorage.setItem(ONBOARDING_BOOTSTRAP_CHECKED_KEY, 'true'); + + if (total >= 3) { + return; + } + + window.localStorage.setItem(ONBOARDING_SURFACE_SEEN_KEY, 'true'); + setSlotA({ tabs: [{ id: 'skills', type: 'skills' }], activeTabId: 'skills' }); + setSlotB(null); + setSlotC(null); + setVisiblePaneCount(1); + setActivePane('A'); + setAutoOpenSkillName('Onboarding'); + if (CHAT_FEATURE_ENABLED) { + setChatPanelOpen(true); + setChatSlot('A'); + setShowOnboardingHint(true); } } catch (error) { - console.error('Failed to fetch contexts:', error); + console.error('[ThreePanelLayout] Failed to bootstrap onboarding surface:', error); } })(); - }, [nodesPanelRefresh]); + + return () => { + cancelled = true; + }; + }, [setChatPanelOpen, setChatSlot, setSlotA, setSlotB, setSlotC, setVisiblePaneCount]); + + // --- Collect node tabs from both slots for chat context --- + const { openTabs, activeTab } = useMemo(() => { + const collectNodeTabs = (state: SlotState | null): number[] => { + if (!state) return []; + return state.tabs + .filter(t => t.type === 'node' && t.nodeId != null) + .map(t => t.nodeId!); + }; + + const slotStates: Record = { A: slotA, B: slotB, C: slotC }; + const activeSlotState = slotStates[activePane]; + const otherNodes = (['A', 'B', 'C'] as SlotId[]) + .filter((slot) => slot !== activePane) + .flatMap((slot) => collectNodeTabs(slotStates[slot])); + + const activeNodes = collectNodeTabs(activeSlotState); + const allNodes = [...new Set([...activeNodes, ...otherNodes])]; + + // Active tab: prefer the active slot's node tab, then another active node. + let active: number | null = null; + if (activeSlotState) { + const activeT = getActiveTab(activeSlotState); + if (activeT?.type === 'node' && activeT.nodeId != null) { + active = activeT.nodeId; + } + } + if (active == null) { + for (const slot of ['A', 'B', 'C'] as SlotId[]) { + if (slot === activePane) continue; + const otherT = getActiveTab(slotStates[slot] ?? { tabs: [], activeTabId: '' }); + if (otherT?.type === 'node' && otherT.nodeId != null) { + active = otherT.nodeId; + break; + } + } + } + if (active == null && allNodes.length > 0) { + active = allNodes[0]; + } + + return { openTabs: allNodes, activeTab: active }; + }, [slotA, slotB, slotC, activePane]); + + const deriveFallbackFocusedNode = useCallback((): number | null => { + const slotStates: Record = { A: slotA, B: slotB, C: slotC }; + const orderedSlots: SlotId[] = [activePane, ...(['A', 'B', 'C'] as SlotId[]).filter((slot) => slot !== activePane)]; + + for (const slot of orderedSlots) { + const activeTab = getActiveTab(slotStates[slot] ?? { tabs: [], activeTabId: '' }); + if (activeTab?.type === 'node' && activeTab.nodeId != null) { + return activeTab.nodeId; + } + } + + for (const slot of orderedSlots) { + const fallbackTab = slotStates[slot]?.tabs.find((tab) => tab.type === 'node' && tab.nodeId != null); + if (fallbackTab?.nodeId != null) { + return fallbackTab.nodeId; + } + } + + return null; + }, [activePane, slotA, slotB, slotC]); + + // Fetch full node data for open tabs + const fetchOpenTabsData = async (tabIds: number[]) => { + if (tabIds.length === 0) { + setOpenTabsData([]); + return; + } + + try { + const nodePromises = tabIds.map(async (id) => { + const response = await fetch(`/api/nodes/${id}`); + if (response.ok) { + const data = await response.json(); + return data.node as Node; + } + return null; + }); + + const nodes = await Promise.all(nodePromises); + const validNodes = nodes.filter((node): node is Node => Boolean(node)).map(node => ({ + id: node.id, + title: node.title, + description: node.description, + link: node.link, + source: node.source, + created_at: node.created_at, + updated_at: node.updated_at, + chunk_status: node.chunk_status, + metadata: node.metadata, + })); + setOpenTabsData(validNodes); + } catch (error) { + console.error('Failed to fetch tab data:', error); + setOpenTabsData([]); + } + }; + + // Update tab data whenever openTabs changes or a node is updated + const openTabsKey = openTabs.join(','); + useEffect(() => { + openTabsRef.current = openTabs; + fetchOpenTabsData(openTabs); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [openTabsKey, focusPanelRefresh]); + + useEffect(() => { + if (focusedNodeId == null) { + return; + } + + const isStillOpen = [slotA, slotB, slotC].some((state) => + state?.tabs.some((tab) => tab.type === 'node' && tab.nodeId === focusedNodeId), + ); + + if (!isStillOpen) { + setFocusedNodeId(deriveFallbackFocusedNode()); + } + }, [deriveFallbackFocusedNode, focusedNodeId, slotA, slotB, slotC]); + + useEffect(() => { + if (focusedNodeId != null || isMapFocusSuppressed) { + return; + } + + const initialFocusedNode = deriveFallbackFocusedNode(); + if (initialFocusedNode != null) { + setFocusedNodeId(initialFocusedNode); + } + }, [deriveFallbackFocusedNode, focusedNodeId, isMapFocusSuppressed]); + + // Timeout cleanup for stuck pending nodes (90s) + useEffect(() => { + if (pendingNodes.length === 0) return; + const interval = setInterval(() => { + const now = Date.now(); + setPendingNodes(prev => prev.filter(p => { + // Keep error nodes for 30s so user can see them, then auto-dismiss + if (p.status === 'error') return now - p.submittedAt < 120_000; + // Auto-dismiss processing nodes after 90s + return now - p.submittedAt < 90_000; + })); + }, 5_000); + return () => clearInterval(interval); + }, [pendingNodes.length]); + + const handleRefreshAll = useCallback(() => { + setNodesPanelRefresh(prev => prev + 1); + setFolderViewRefresh(prev => prev + 1); + setFocusPanelRefresh(prev => prev + 1); + }, []); + + const getSuggestedPaneType = useCallback((): Exclude => { + const openTypes = new Set(); + slotA?.tabs.forEach((tab) => openTypes.add(tab.type)); + slotB?.tabs.forEach((tab) => openTypes.add(tab.type)); + slotC?.tabs.forEach((tab) => openTypes.add(tab.type)); + + const order: Exclude[] = ['map', 'table', 'skills', 'views']; + return order.find((paneType) => !openTypes.has(paneType)) || 'map'; + }, [slotA, slotB, slotC]); const getSlotState = useCallback((slot: SlotId): SlotState | null => { switch (slot) { @@ -239,8 +527,7 @@ export default function ThreePanelLayout() { () => SLOT_ORDER.slice(0, Math.max(1, Math.min(3, visiblePaneCount))), [visiblePaneCount] ); - const [focusedNodeId, setFocusedNodeId] = useState(null); - const [isMapFocusSuppressed, setIsMapFocusSuppressed] = useState(false); + const searchModalFilters = useMemo(() => [], []); const isPanelExpanded = useCallback((slot: SlotId) => { return visibleSlots.includes(slot); @@ -281,21 +568,31 @@ export default function ThreePanelLayout() { } }, [setPanelAWeight, setPanelBWeight, setPanelCWeight]); - const slotStates = useMemo>(() => ({ A: slotA, B: slotB, C: slotC }), [slotA, slotB, slotC]); + const isSlotEmpty = useCallback((slot: SlotId) => { + const state = getSlotState(slot); + return !state || state.tabs.length === 0; + }, [getSlotState]); - const allOpenNodeIds = useMemo(() => { - const ids = new Set(); - (Object.values(slotStates) as Array).forEach((state) => { - getSlotTabsByType(state, 'node').forEach((tab) => { - if (tab.nodeId != null) ids.add(tab.nodeId); - }); - }); - return [...ids]; - }, [slotStates]); + const getAvailableVisibleContentSlots = useCallback(() => { + if (!CHAT_FEATURE_ENABLED) { + return visibleSlots; + } + return visibleSlots.filter((slot) => slot !== chatSlot); + }, [chatSlot, visibleSlots]); useEffect(() => { - openNodeIdsRef.current = allOpenNodeIds; - }, [allOpenNodeIds]); + if (hasHydratedPaneCountRef.current || typeof window === 'undefined') { + return; + } + + hasHydratedPaneCountRef.current = true; + + if (window.localStorage.getItem(VISIBLE_PANE_COUNT_KEY) !== null) { + return; + } + + setVisiblePaneCount(deriveInitialPaneCount()); + }, [setVisiblePaneCount]); useEffect(() => { setPanelAExpanded(true); @@ -304,217 +601,346 @@ export default function ThreePanelLayout() { }, [setPanelAExpanded, setPanelBExpanded, setPanelCExpanded, visiblePaneCount]); useEffect(() => { - if (!visibleSlots.includes(activePane)) { - setActivePane(visibleSlots[0] ?? 'A'); + if (!visibleSlots.includes(activePane) || (CHAT_FEATURE_ENABLED && activePane === chatSlot)) { + const fallback = getAvailableVisibleContentSlots()[0] ?? visibleSlots[0] ?? 'A'; + setActivePane(fallback); } - }, [activePane, visibleSlots]); - - const deriveFallbackFocusedNode = useCallback((): number | null => { - const orderedSlots: SlotId[] = [activePane, ...(['A', 'B', 'C'] as SlotId[]).filter((slot) => slot !== activePane)]; - - for (const slot of orderedSlots) { - const state = slotStates[slot]; - const activeTab = state ? getActiveTab(state) : undefined; - if (activeTab?.type === 'node' && activeTab.nodeId != null) { - return activeTab.nodeId; - } - } - - for (const slot of orderedSlots) { - const fallbackTab = slotStates[slot]?.tabs.find((tab) => tab.type === 'node' && tab.nodeId != null); - if (fallbackTab?.nodeId != null) { - return fallbackTab.nodeId; - } - } - - return null; - }, [activePane, slotStates]); + }, [activePane, chatSlot, getAvailableVisibleContentSlots, visibleSlots]); useEffect(() => { - if (focusedNodeId == null) { + if (!CHAT_FEATURE_ENABLED) { + if (chatPanelOpen) { + setChatPanelOpen(false); + } + if (chatSlot !== null) { + setChatSlot(null); + } return; } - if (!allOpenNodeIds.includes(focusedNodeId)) { - setFocusedNodeId(deriveFallbackFocusedNode()); - } - }, [allOpenNodeIds, deriveFallbackFocusedNode, focusedNodeId]); - - useEffect(() => { - if (focusedNodeId != null || isMapFocusSuppressed) { + if (!chatPanelOpen) { + if (chatSlot !== null) { + setChatSlot(null); + } return; } - const initialFocusedNode = deriveFallbackFocusedNode(); - if (initialFocusedNode != null) { - setFocusedNodeId(initialFocusedNode); + // Keep chat pinned to its current visible slot. Reassign only when chat has + // no slot yet or its slot is no longer visible (for example after reducing + // pane count). Otherwise an empty sibling pane can cause A<->B flip-flopping. + if (chatSlot !== null && visibleSlots.includes(chatSlot)) { + return; } - }, [deriveFallbackFocusedNode, focusedNodeId, isMapFocusSuppressed]); - const handleRefreshAll = useCallback(() => { - setNodesPanelRefresh((prev) => prev + 1); - setFocusPanelRefresh((prev) => prev + 1); - }, []); + const spareVisibleSlots = visibleSlots.filter((slot) => slot !== chatSlot && isSlotEmpty(slot)); + const nextChatSlot = spareVisibleSlots[spareVisibleSlots.length - 1] + ?? visibleSlots[visibleSlots.length - 1] + ?? 'A'; - useEffect(() => { - const handleKeyDown = (event: KeyboardEvent) => { - if ((event.metaKey || event.ctrlKey) && event.key === 'k') { - event.preventDefault(); - setShowSearchModal(true); - } - - if ((event.metaKey || event.ctrlKey) && event.key === 'n') { - if (document.activeElement?.closest('[data-rah-app]')) { - event.preventDefault(); - setShowAddStuff(true); - } - } - - if ((event.metaKey || event.ctrlKey) && event.shiftKey && event.key.toLowerCase() === 'r') { - event.preventDefault(); - handleRefreshAll(); - } - }; - - window.addEventListener('keydown', handleKeyDown); - return () => window.removeEventListener('keydown', handleKeyDown); - }, [handleRefreshAll]); + if (chatSlot !== nextChatSlot) { + setChatSlot(nextChatSlot); + } + }, [chatPanelOpen, chatSlot, isSlotEmpty, setChatPanelOpen, setChatSlot, visibleSlots]); + // --- SSE connection for real-time updates --- useEffect(() => { let eventSource: EventSource | null = null; try { eventSource = new EventSource('/api/events'); + + eventSource.onopen = () => { + console.log('SSE connected for real-time updates'); + }; + eventSource.onmessage = (event) => { try { const data: DatabaseEvent = JSON.parse(event.data); switch (data.type) { case 'NODE_CREATED': - setNodesPanelRefresh((prev) => prev + 1); + setNodesPanelRefresh(prev => prev + 1); break; - case 'NODE_UPDATED': - setNodesPanelRefresh((prev) => prev + 1); - if (openNodeIdsRef.current.includes(Number(data.data.nodeId))) { - setFocusPanelRefresh((prev) => prev + 1); + + case 'NODE_UPDATED': { + const currentOpenTabs = openTabsRef.current; + const updatedNodeId = Number(data.data.nodeId); + if (currentOpenTabs.includes(updatedNodeId)) { + setFocusPanelRefresh(prev => prev + 1); + } + setNodesPanelRefresh(prev => prev + 1); + break; + } + + case 'NODE_DELETED': + handleNodeDeleted(data.data.nodeId); + setNodesPanelRefresh(prev => prev + 1); + break; + + case 'EDGE_CREATED': + case 'EDGE_DELETED': { + const currentOpenTabsForEdge = openTabsRef.current; + if (currentOpenTabsForEdge.includes(data.data.fromNodeId) || + currentOpenTabsForEdge.includes(data.data.toNodeId)) { + setFocusPanelRefresh(prev => prev + 1); } break; - case 'NODE_DELETED': - handleNodeDeletedRef.current(Number(data.data.nodeId)); - setNodesPanelRefresh((prev) => prev + 1); + } + + case 'DIMENSION_UPDATED': + setNodesPanelRefresh(prev => prev + 1); + setFolderViewRefresh(prev => prev + 1); break; - case 'EDGE_CREATED': - case 'EDGE_DELETED': - setFocusPanelRefresh((prev) => prev + 1); + + case 'HELPER_UPDATED': + case 'AGENT_UPDATED': + if (typeof window !== 'undefined') { + window.dispatchEvent(new CustomEvent('agents:updated', { detail: data.data })); + } break; + + case 'SKILL_UPDATED': + if (typeof window !== 'undefined') { + window.dispatchEvent(new CustomEvent('skills:updated')); + window.dispatchEvent(new CustomEvent('guides:updated')); + } + break; + case 'GUIDE_UPDATED': if (typeof window !== 'undefined') { - window.dispatchEvent(new CustomEvent('skills:updated', { detail: data.data })); + window.dispatchEvent(new CustomEvent('skills:updated')); + window.dispatchEvent(new CustomEvent('guides:updated')); } break; - case 'QUICK_ADD_COMPLETED': - if (data.data?.quickAddId) { - setPendingNodes((prev) => prev.filter((item) => item.id !== data.data.quickAddId)); - setNodesPanelRefresh((prev) => prev + 1); + + case 'QUICK_ADD_COMPLETED': { + const completedId = data.data?.quickAddId; + if (completedId) { + setPendingNodes(prev => prev.filter(p => p.id !== completedId)); } break; - case 'QUICK_ADD_FAILED': - if (data.data?.quickAddId) { - setPendingNodes((prev) => prev.map((item) => ( - item.id === data.data.quickAddId - ? { ...item, status: 'error', error: data.data.error || 'Unknown error' } - : item - ))); + } + + case 'QUICK_ADD_FAILED': { + const failedId = data.data?.quickAddId; + const errorMsg = data.data?.error || 'Processing failed'; + if (failedId) { + setPendingNodes(prev => prev.map(p => + p.id === failedId ? { ...p, status: 'error' as const, error: errorMsg } : p + )); } break; + } + + case 'CONNECTION_ESTABLISHED': + console.log('SSE connection established'); + break; + + default: + console.log('Unknown SSE event:', data.type); } } catch (error) { console.error('Failed to parse SSE event:', error); } }; + + eventSource.onerror = (error) => { + console.error('SSE connection error:', error); + }; } catch (error) { console.error('Failed to establish SSE connection:', error); } return () => { - eventSource?.close(); + if (eventSource) { + eventSource.close(); + } }; }, []); - useEffect(() => { - if (pendingNodes.length === 0) return; - const timer = setInterval(() => { - const now = Date.now(); - setPendingNodes((prev) => prev.filter((item) => { - const age = now - item.submittedAt; - if (item.status === 'processing' && age > 90_000) return false; - if (item.status === 'error' && age > 120_000) return false; - return true; - })); - }, 5000); + // --- Tab helpers --- - return () => clearInterval(timer); - }, [pendingNodes.length]); - - const upsertSingletonTab = useCallback((slot: SlotId, paneType: Exclude) => { - const tabId = createTabId(paneType); - const setter = getSlotSetter(slot); - - setter({ tabs: [{ id: tabId, type: paneType }], activeTabId: tabId }); - - setPanelExpanded(slot, true); - setActivePane(slot); - }, [getSlotSetter, setPanelExpanded]); - - const addNodeTabToSlot = useCallback((slot: SlotId, nodeId: number) => { - const nodeTabId = createTabId('node', nodeId); - const setter = getSlotSetter(slot); + const setSingletonPaneInSlot = useCallback(( + setter: React.Dispatch>, + paneType: Exclude, + ) => { + const tab: SlotTab = { id: createTabId(paneType), type: paneType }; setter((prev) => { - const current = sanitizeSlotState(prev); - const nodeTabs = (current?.tabs ?? []).filter((tab) => tab.type === 'node'); - if (nodeTabs.some((tab) => tab.id === nodeTabId)) { - return { tabs: nodeTabs, activeTabId: nodeTabId }; + if (!prev || prev.tabs.length === 0) { + return { tabs: [tab], activeTabId: tab.id }; } - return { - tabs: [...nodeTabs, { id: nodeTabId, type: 'node', nodeId }], - activeTabId: nodeTabId, - }; - }); - setPanelExpanded(slot, true); - setActivePane(slot); + const existingIndex = prev.tabs.findIndex((t) => t.type === paneType); + if (existingIndex >= 0) { + return { ...prev, activeTabId: prev.tabs[existingIndex].id }; + } + + const tabs = [...prev.tabs]; + const nonNodeIndex = tabs.findIndex((t) => t.type !== 'node'); + + if (nonNodeIndex >= 0) { + tabs[nonNodeIndex] = tab; + } else { + tabs.push(tab); + } + + return { tabs, activeTabId: tab.id }; + }); + }, []); + + const focusPaneIfOpen = useCallback((paneType: Exclude): SlotId | null => { + const tabId = createTabId(paneType); + + for (const slot of visibleSlots) { + if (slot === chatSlot) { + continue; + } + + const state = getSlotState(slot); + if (state?.tabs.some((tab) => tab.id === tabId)) { + getSlotSetter(slot)((prev) => prev ? { ...prev, activeTabId: tabId } : prev); + setActivePane(slot); + return slot; + } + } + + return null; + }, [chatSlot, getSlotSetter, getSlotState, visibleSlots]); + + const openPaneSingleton = useCallback((paneType: Exclude, preferredSlot?: SlotId) => { + const existing = focusPaneIfOpen(paneType); + if (existing) { + return existing; + } + + const orderedVisibleSlots = preferredSlot && visibleSlots.includes(preferredSlot) + ? [preferredSlot, ...visibleSlots.filter((slot) => slot !== preferredSlot)] + : visibleSlots; + + const contentSlots = orderedVisibleSlots.filter((slot) => slot !== chatSlot); + const visibleEmpty = contentSlots.find((slot) => isSlotEmpty(slot)); + if (visibleEmpty) { + setSingletonPaneInSlot(getSlotSetter(visibleEmpty), paneType); + setActivePane(visibleEmpty); + return visibleEmpty; + } + + const replacementTarget = contentSlots.includes(activePane) + ? activePane + : contentSlots[contentSlots.length - 1] + ?? orderedVisibleSlots[orderedVisibleSlots.length - 1] + ?? 'A'; + + setSingletonPaneInSlot(getSlotSetter(replacementTarget), paneType); + setActivePane(replacementTarget); + return replacementTarget; + }, [activePane, chatSlot, focusPaneIfOpen, getSlotSetter, isSlotEmpty, setSingletonPaneInSlot, visibleSlots]); + + // Add or focus a tab in a slot + const addOrFocusTab = useCallback(( + setter: (value: SlotState | null | ((prev: SlotState | null) => SlotState | null)) => void, + currentState: SlotState | null, + tab: SlotTab, + ) => { + if (tab.type !== 'node') { + setter({ tabs: [tab], activeTabId: tab.id }); + return; + } + + const nodeTabs = currentState?.tabs.filter((t) => t.type === 'node') ?? []; + const existing = nodeTabs.find((t) => t.id === tab.id); + + if (existing) { + setter({ tabs: nodeTabs, activeTabId: tab.id }); + } else { + setter({ + tabs: [...nodeTabs, tab], + activeTabId: tab.id, + }); + } + }, []); + + // Remove a tab from a slot + const removeTabFromSlot = useCallback(( + setter: (value: SlotState | null | ((prev: SlotState | null) => SlotState | null)) => void, + currentState: SlotState | null, + tabId: string, + ) => { + if (!currentState) return; + const newTabs = currentState.tabs.filter(t => t.id !== tabId); + if (newTabs.length === 0) { + setter(null); + return; + } + let newActiveTabId = currentState.activeTabId; + if (currentState.activeTabId === tabId) { + const oldIndex = currentState.tabs.findIndex(t => t.id === tabId); + const newIndex = Math.min(oldIndex, newTabs.length - 1); + newActiveTabId = newTabs[newIndex].id; + } + setter(prev => prev ? { ...prev, tabs: newTabs, activeTabId: newActiveTabId } : null); + }, []); + + useEffect(() => { + const handleKeyDown = (e: KeyboardEvent) => { + if ((e.metaKey || e.ctrlKey) && e.key === 'k') { + e.preventDefault(); + setShowSearchModal(true); + } + + if ((e.metaKey || e.ctrlKey) && e.key === '\\') { + e.preventDefault(); + setVisiblePaneCount((current) => Math.min(3, current + 1)); + } + + if (CHAT_FEATURE_ENABLED && (e.metaKey || e.ctrlKey) && e.key === 'j') { + e.preventDefault(); + setChatPanelOpen((prev: boolean) => !prev); + } + + if ((e.metaKey || e.ctrlKey) && e.key === 'n') { + if (document.activeElement?.closest('[data-rah-app]')) { + e.preventDefault(); + setShowAddStuff(true); + } + } + + if ((e.metaKey || e.ctrlKey) && e.shiftKey && e.key === 'r') { + e.preventDefault(); + handleRefreshAll(); + } + }; + + window.addEventListener('keydown', handleKeyDown); + return () => window.removeEventListener('keydown', handleKeyDown); + }, [handleRefreshAll, setChatPanelOpen, setVisiblePaneCount]); + + // --- Node tab management --- + const addNodeTabToSlot = useCallback(( + slot: SlotId, + nodeId: number, + ) => { + const tab: SlotTab = { id: createTabId('node', nodeId), type: 'node', nodeId }; + const setter = getSlotSetter(slot); + const state = getSlotState(slot); + addOrFocusTab(setter, state, tab); setIsMapFocusSuppressed(false); setFocusedNodeId(nodeId); - }, [getSlotSetter, setPanelExpanded]); - - const closeTabInSlot = useCallback((slot: SlotId, tabId: string) => { - const setter = getSlotSetter(slot); - - setter((prev) => { - const current = sanitizeSlotState(prev); - if (!current) return null; - - const tabs = current.tabs.filter((tab) => tab.id !== tabId); - if (tabs.length === 0) { - return null; - } - - const activeTabId = current.activeTabId === tabId - ? tabs[Math.max(0, tabs.length - 1)].id - : current.activeTabId; - - return { tabs, activeTabId }; - }); - }, [getSlotSetter]); + }, [getSlotSetter, getSlotState, addOrFocusTab]); const openNodeFromSlot = useCallback((nodeId: number, fromSlot?: SlotId) => { const existingTabId = createTabId('node', nodeId); + const contentSlots = visibleSlots.filter((slot) => slot !== chatSlot); for (const slot of visibleSlots) { + if (slot === chatSlot) { + continue; + } const state = getSlotState(slot); if (state?.tabs.some((tab) => tab.id === existingTabId)) { - getSlotSetter(slot)({ tabs: state.tabs, activeTabId: existingTabId }); + getSlotSetter(slot)((prev) => prev ? { ...prev, activeTabId: existingTabId } : prev); + setSelectedNodes(new Set([nodeId])); setIsMapFocusSuppressed(false); setFocusedNodeId(nodeId); setActivePane(slot); @@ -522,7 +948,7 @@ export default function ThreePanelLayout() { } } - const visibleNodeSlots = visibleSlots.filter((slot) => { + const visibleNodeSlots = contentSlots.filter((slot) => { const state = getSlotState(slot); return state?.tabs.some((tab) => tab.type === 'node'); }); @@ -535,132 +961,194 @@ export default function ThreePanelLayout() { if (preferredNodeTarget) { addNodeTabToSlot(preferredNodeTarget, nodeId); + setSelectedNodes(new Set([nodeId])); setIsMapFocusSuppressed(false); setFocusedNodeId(nodeId); setActivePane(preferredNodeTarget); return; } - const emptyTarget = visibleSlots.find((slot) => { - const state = getSlotState(slot); - return !state || state.tabs.length === 0; - }); - const target = emptyTarget - ?? (visibleSlots.includes(activePane) ? activePane : null) - ?? visibleSlots[visibleSlots.length - 1] - ?? 'A'; + const orderedTargets = fromSlot + ? [...contentSlots.filter((slot) => slot !== fromSlot), fromSlot] + : contentSlots; + const emptyTarget = orderedTargets.find((slot) => isSlotEmpty(slot)); + const preferredActiveTarget = orderedTargets.includes(activePane) ? activePane : null; + const target = emptyTarget ?? preferredActiveTarget ?? orderedTargets[orderedTargets.length - 1] ?? 'A'; addNodeTabToSlot(target, nodeId); + setSelectedNodes(new Set([nodeId])); setIsMapFocusSuppressed(false); setFocusedNodeId(nodeId); - }, [activePane, addNodeTabToSlot, getSlotSetter, getSlotState, visibleSlots]); + setActivePane(target); + }, [activePane, addNodeTabToSlot, chatSlot, getSlotSetter, getSlotState, isSlotEmpty, visibleSlots]); - const openPaneSingleton = useCallback((paneType: Exclude, preferredSlot?: SlotId) => { - for (const slot of visibleSlots) { - const state = getSlotState(slot); - if (state?.tabs.some((tab) => tab.type === paneType)) { - getSlotSetter(slot)({ tabs: state.tabs, activeTabId: createTabId(paneType) }); - setPanelExpanded(slot, true); - setActivePane(slot); - return slot; - } - } + const handleNodeSelect = useCallback((nodeId: number, _multiSelect: boolean) => { + openNodeFromSlot(nodeId); + }, [openNodeFromSlot]); - const orderedSlots = preferredSlot && visibleSlots.includes(preferredSlot) - ? [preferredSlot, ...visibleSlots.filter((slot) => slot !== preferredSlot)] - : visibleSlots; - const emptyTarget = orderedSlots.find((slot) => { - const state = getSlotState(slot); - return !state || state.tabs.length === 0; - }); - const target = emptyTarget - ?? (orderedSlots.includes(activePane) ? activePane : null) - ?? orderedSlots[orderedSlots.length - 1] - ?? 'A'; - - upsertSingletonTab(target, paneType); - return target; - }, [activePane, getSlotSetter, getSlotState, setPanelExpanded, upsertSingletonTab, visibleSlots]); - - const handleTabSelect = useCallback((slot: SlotId, tabId: string) => { - const state = getSlotState(slot); - if (!state) return; - getSlotSetter(slot)({ tabs: state.tabs, activeTabId: tabId }); - const tab = state.tabs.find((current) => current.id === tabId); - if (tab?.type === 'node' && tab.nodeId != null) { + const handleTabSelectA = useCallback((tabId: string) => { + setSlotA(prev => prev ? { ...prev, activeTabId: tabId } : null); + // If it's a node tab, update selection + const tab = slotA?.tabs.find(t => t.id === tabId); + if (tab?.type === 'node' && tab.nodeId) { + setSelectedNodes(new Set([tab.nodeId])); setIsMapFocusSuppressed(false); setFocusedNodeId(tab.nodeId); } - setActivePane(slot); - }, [getSlotSetter, getSlotState]); + setActivePane('A'); + }, [slotA, setSlotA]); + + const handleTabSelectB = useCallback((tabId: string) => { + setSlotB(prev => prev ? { ...prev, activeTabId: tabId } : null); + const tab = slotB?.tabs.find(t => t.id === tabId); + if (tab?.type === 'node' && tab.nodeId) { + setSelectedNodes(new Set([tab.nodeId])); + setIsMapFocusSuppressed(false); + setFocusedNodeId(tab.nodeId); + } + setActivePane('B'); + }, [slotB, setSlotB]); + + const handleTabSelectC = useCallback((tabId: string) => { + setSlotC(prev => prev ? { ...prev, activeTabId: tabId } : null); + const tab = slotC?.tabs.find(t => t.id === tabId); + if (tab?.type === 'node' && tab.nodeId) { + setSelectedNodes(new Set([tab.nodeId])); + setIsMapFocusSuppressed(false); + setFocusedNodeId(tab.nodeId); + } + setActivePane('C'); + }, [slotC, setSlotC]); const clearMapFocus = useCallback(() => { setIsMapFocusSuppressed(true); setFocusedNodeId(null); }, []); + const handleCloseTabA = useCallback((tabId: string) => { + removeTabFromSlot(setSlotA, slotA, tabId); + // Remove from selection if it's a node + const tab = slotA?.tabs.find(t => t.id === tabId); + if (tab?.type === 'node' && tab.nodeId) { + setSelectedNodes(prev => { + const next = new Set(prev); + next.delete(tab.nodeId!); + return next; + }); + } + }, [slotA, setSlotA, removeTabFromSlot]); + + const handleCloseTabB = useCallback((tabId: string) => { + removeTabFromSlot(setSlotB, slotB, tabId); + }, [slotB, setSlotB, removeTabFromSlot]); + + const handleCloseTabC = useCallback((tabId: string) => { + removeTabFromSlot(setSlotC, slotC, tabId); + }, [slotC, setSlotC, removeTabFromSlot]); + + // Get node-specific props from a slot for rendering NodePane + const getNodeTabsFromSlot = useCallback((state: SlotState | null): { openTabs: number[]; activeTab: number | null } => { + if (!state) return { openTabs: [], activeTab: null }; + const nodeTabs = state.tabs.filter(t => t.type === 'node' && t.nodeId != null); + const nodeIds = nodeTabs.map(t => t.nodeId!); + const activeT = getActiveTab(state); + const activeNodeId = activeT?.type === 'node' && activeT.nodeId != null ? activeT.nodeId : null; + return { openTabs: nodeIds, activeTab: activeNodeId }; + }, []); + + const handleNodeCreated = useCallback((newNode: Node) => { + openNodeFromSlot(newNode.id); + }, [openNodeFromSlot]); + const handleNodeDeleted = useCallback((nodeId: number) => { const tabId = createTabId('node', nodeId); - (['A', 'B', 'C'] as SlotId[]).forEach((slot) => closeTabInSlot(slot, tabId)); - }, [closeTabInSlot]); + removeTabFromSlot(setSlotA, slotA, tabId); + removeTabFromSlot(setSlotB, slotB, tabId); + removeTabFromSlot(setSlotC, slotC, tabId); + }, [slotA, slotB, slotC, setSlotA, setSlotB, setSlotC, removeTabFromSlot]); - useEffect(() => { - handleNodeDeletedRef.current = handleNodeDeleted; - }, [handleNodeDeleted]); + const handleReorderTabsA = useCallback((fromIndex: number, toIndex: number) => { + if (fromIndex === toIndex || !slotA) return; + if (fromIndex < 0 || toIndex < 0 || fromIndex >= slotA.tabs.length || toIndex >= slotA.tabs.length) return; + const updated = [...slotA.tabs]; + const [moved] = updated.splice(fromIndex, 1); + updated.splice(toIndex, 0, moved); + setSlotA(prev => prev ? { ...prev, tabs: updated } : null); + }, [slotA, setSlotA]); - const handleReorderTabs = useCallback((slot: SlotId, fromIndex: number, toIndex: number) => { - const state = getSlotState(slot); - if (!state || fromIndex === toIndex) return; - if (fromIndex < 0 || toIndex < 0 || fromIndex >= state.tabs.length || toIndex >= state.tabs.length) return; + const handleReorderTabsB = useCallback((fromIndex: number, toIndex: number) => { + if (fromIndex === toIndex || !slotB) return; + if (fromIndex < 0 || toIndex < 0 || fromIndex >= slotB.tabs.length || toIndex >= slotB.tabs.length) return; + const updated = [...slotB.tabs]; + const [moved] = updated.splice(fromIndex, 1); + updated.splice(toIndex, 0, moved); + setSlotB(prev => prev ? { ...prev, tabs: updated } : null); + }, [slotB, setSlotB]); - const tabs = [...state.tabs]; - const [moved] = tabs.splice(fromIndex, 1); - tabs.splice(toIndex, 0, moved); - getSlotSetter(slot)({ tabs, activeTabId: state.activeTabId }); - }, [getSlotSetter, getSlotState]); + const handleReorderTabsC = useCallback((fromIndex: number, toIndex: number) => { + if (fromIndex === toIndex || !slotC) return; + if (fromIndex < 0 || toIndex < 0 || fromIndex >= slotC.tabs.length || toIndex >= slotC.tabs.length) return; + const updated = [...slotC.tabs]; + const [moved] = updated.splice(fromIndex, 1); + updated.splice(toIndex, 0, moved); + setSlotC(prev => prev ? { ...prev, tabs: updated } : null); + }, [slotC, setSlotC]); - const handleCrossSlotDrop = useCallback((targetSlot: SlotId, tab: SlotTab, fromSlot: SlotId) => { - closeTabInSlot(fromSlot, tab.id); - if (tab.type === 'node' && tab.nodeId != null) { - addNodeTabToSlot(targetSlot, tab.nodeId); - return; + const handleFolderViewDataChanged = useCallback(() => { + setFolderViewRefresh(prev => prev + 1); + setNodesPanelRefresh(prev => prev + 1); + }, []); + + const handleNodeOpenFromDimensions = useCallback((nodeId: number) => { + openNodeFromSlot(nodeId, 'A'); + }, [openNodeFromSlot]); + + // Handle pane type selection from toolbar — add/focus tab in active slot + const handlePaneTypeClick = useCallback((paneType: PaneType) => { + if (paneType !== 'node') { + openPaneSingleton(paneType); } - if (tab.type !== 'node') { - upsertSingletonTab(targetSlot, tab.type); - } - }, [addNodeTabToSlot, closeTabInSlot, upsertSingletonTab]); + }, [openPaneSingleton]); - const handleContextSelect = useCallback((slot: SlotId, contextId: number | null, _contextName?: string | null) => { - setBrowseContextFilters((prev) => ({ ...prev, [slot]: contextId })); - setActiveContextId(contextId); - if (contextId != null) { - openPaneSingleton('views', slot); + // Auto-open Feed pane if not already visible + const ensureFeedOpen = useCallback(() => { + const visibleFeedSlot = visibleSlots.find((slot) => { + if (slot === chatSlot) return false; + return getSlotState(slot)?.tabs.some((tab) => tab.type === 'views'); + }); + + if (visibleFeedSlot) { + const state = getSlotState(visibleFeedSlot); + const viewsTab = state?.tabs.find((tab) => tab.type === 'views'); + if (viewsTab) { + getSlotSetter(visibleFeedSlot)((prev) => prev ? { ...prev, activeTabId: viewsTab.id } : prev); + setActivePane(visibleFeedSlot); + return; + } } - }, [openPaneSingleton, setActiveContextId]); + + openPaneSingleton('views'); + }, [chatSlot, getSlotSetter, getSlotState, openPaneSingleton, visibleSlots]); const handleQuickAddSubmit = useCallback(async ({ input, mode, description }: { input: string; mode: 'link' | 'text'; description?: string }) => { try { const response = await fetch('/api/quick-add', { method: 'POST', headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ - input, - mode, - description, - contextId: activeContextId, - }), + body: JSON.stringify({ input, mode, description }) }); if (!response.ok) { - const payload = await response.json().catch(() => null); - throw new Error(payload?.error || 'Failed to submit Quick Add'); + const data = await response.json(); + throw new Error(data.error || 'Failed to submit Quick Add'); } - const payload = await response.json(); - const result = payload.result as { id?: string; inputType?: string } | undefined; + const data = await response.json(); + const result = data.result as { id: string; inputType: string } | undefined; + if (result?.id) { - setPendingNodes((prev) => [{ - id: result.id!, + setPendingNodes(prev => [{ + id: result.id, input: input.trim(), inputType: result.inputType || 'note', submittedAt: Date.now(), @@ -668,89 +1156,210 @@ export default function ThreePanelLayout() { }, ...prev]); } - openPaneSingleton('views', 'A'); + ensureFeedOpen(); setShowAddStuff(false); } catch (error) { - console.error('[ThreePanelLayout] Quick Add failed:', error); + console.error('[ThreePanelLayout] Quick Add error:', error); } - }, [activeContextId, openPaneSingleton]); + }, [ensureFeedOpen]); - const handleSlotAction = useCallback((slot: SlotId, action: PaneAction) => { + // Handle closing a pane + const handleCloseSlotA = useCallback(() => { + setSlotA(null); + setActivePane('A'); + }, [setSlotA]); + + const handleCloseSlotB = useCallback(() => { + setSlotB(null); + setActivePane('A'); + }, [setSlotB]); + + const handleCloseSlotC = useCallback(() => { + setSlotC(null); + setActivePane(visibleSlots[0] ?? 'A'); + }, [setSlotC, visibleSlots]); + + // Handle pane actions + const handleSlotAAction = useCallback((action: PaneAction) => { switch (action.type) { - case 'switch-pane-type': + case 'switch-pane-type': { if (action.paneType !== 'node') { - upsertSingletonTab(slot, action.paneType); + setSingletonPaneInSlot(setSlotA, action.paneType); + setActivePane('A'); } break; - case 'open-context': - handleContextSelect(action.targetSlot ?? slot, action.contextId, action.contextName); - break; + } case 'open-node': - openNodeFromSlot(action.nodeId, action.targetSlot ?? slot); - break; - case 'close-pane': - closeActiveSlot(slot); + openNodeFromSlot(action.nodeId, 'A'); break; } - }, [handleContextSelect, openNodeFromSlot, upsertSingletonTab]); + }, [openNodeFromSlot, setSingletonPaneInSlot]); - const closeActiveSlot = useCallback((slot: SlotId) => { - getSlotSetter(slot)(null); - if (activePane === slot) { - const fallback = visibleSlots.find((candidate) => candidate !== slot) ?? 'A'; - setActivePane(fallback); + const handleSlotBAction = useCallback((action: PaneAction) => { + switch (action.type) { + case 'switch-pane-type': { + if (action.paneType !== 'node') { + setSingletonPaneInSlot(setSlotB, action.paneType); + setActivePane('B'); + } + break; + } + case 'open-node': + openNodeFromSlot(action.nodeId, 'B'); + break; } - }, [activePane, getSlotSetter, visibleSlots]); + }, [openNodeFromSlot, setSingletonPaneInSlot]); + + const handleSlotCAction = useCallback((action: PaneAction) => { + switch (action.type) { + case 'switch-pane-type': { + if (action.paneType !== 'node') { + setSingletonPaneInSlot(setSlotC, action.paneType); + setActivePane('C'); + } + break; + } + case 'open-node': + openNodeFromSlot(action.nodeId, 'C'); + break; + } + }, [openNodeFromSlot, setSingletonPaneInSlot]); + + // Handle search result selection + const handleSearchNodeSelect = useCallback((nodeId: number) => { + handleNodeSelect(nodeId, false); + setShowSearchModal(false); + }, [handleNodeSelect]); const handleSwapPanes = useCallback((source: SlotId, target: SlotId) => { if (source === target) return; const sourceState = getSlotState(source); const targetState = getSlotState(target); - const sourceExpanded = isPanelExpanded(source); - const targetExpanded = isPanelExpanded(target); - const sourceWeight = getPanelWeight(source); - const targetWeight = getPanelWeight(target); getSlotSetter(source)(targetState); getSlotSetter(target)(sourceState); - setPanelExpanded(source, targetExpanded); - setPanelExpanded(target, sourceExpanded); - setPanelWeight(source, targetWeight); - setPanelWeight(target, sourceWeight); - if (activePane === source) setActivePane(target); - else if (activePane === target) setActivePane(source); - }, [activePane, getPanelWeight, getSlotSetter, getSlotState, isPanelExpanded, setPanelExpanded, setPanelWeight]); + if (activePane === source) { + setActivePane(target); + } else if (activePane === target) { + setActivePane(source); + } - const handleSlotDragOver = useCallback((event: React.DragEvent, slot: SlotId) => { - if (event.dataTransfer.types.includes('application/x-rah-pane') || event.dataTransfer.types.includes('application/x-rah-tab')) { - event.preventDefault(); - event.dataTransfer.dropEffect = 'move'; + if (chatSlot === source) { + setChatSlot(target); + } else if (chatSlot === target) { + setChatSlot(source); + } + }, [activePane, chatSlot, getSlotSetter, getSlotState, setChatSlot]); + + // --- Drag state for cross-slot tab dragging --- + const [dragOverSlot, setDragOverSlot] = useState(null); + + const handleSlotDragOver = useCallback((e: React.DragEvent, slot: SlotId) => { + if (e.dataTransfer.types.includes('application/x-rah-pane') || + e.dataTransfer.types.includes('application/x-rah-tab') || + e.dataTransfer.types.includes('application/node-info')) { + e.preventDefault(); + e.dataTransfer.dropEffect = e.dataTransfer.types.includes('application/x-rah-pane') ? 'move' : 'copy'; setDragOverSlot(slot); } }, []); - const handleSlotDrop = useCallback((event: React.DragEvent, targetSlot: SlotId) => { - event.preventDefault(); + const handleSlotDragLeave = useCallback(() => { + setDragOverSlot(null); + }, []); + + const handleSlotDrop = useCallback((e: React.DragEvent, targetSlot: SlotId) => { setDragOverSlot(null); - const paneData = event.dataTransfer.getData('application/x-rah-pane'); + const paneData = e.dataTransfer.getData('application/x-rah-pane'); if (paneData) { - handleSwapPanes(paneData as SlotId, targetSlot); + const sourceSlot = paneData as SlotId; + if (sourceSlot !== targetSlot) { + handleSwapPanes(sourceSlot, targetSlot); + } return; } - const tabData = event.dataTransfer.getData('application/x-rah-tab'); + let tabData = e.dataTransfer.getData('application/x-rah-tab'); + if (!tabData) { + tabData = e.dataTransfer.getData('application/node-info'); + } if (!tabData) return; try { - const parsed = JSON.parse(tabData) as { sourceSlot: SlotId; tab: SlotTab }; - handleCrossSlotDrop(targetSlot, parsed.tab, parsed.sourceSlot); - } catch (error) { - console.error('Failed to parse dropped tab payload:', error); + const parsed = JSON.parse(tabData); + const sourceSlot = parsed.sourceSlot as SlotId | undefined; + + // If it has tabId/tabType, it's a full tab drag + if (parsed.tabId && parsed.tabType) { + const tab: SlotTab = { + id: parsed.tabId, + type: parsed.tabType, + ...(parsed.nodeId != null ? { nodeId: parsed.nodeId } : {}), + }; + + // Same slot — just select + if (sourceSlot === targetSlot) { + if (targetSlot === 'A') { + setSlotA(prev => prev ? { ...prev, activeTabId: tab.id } : null); + } else if (targetSlot === 'B') { + setSlotB(prev => prev ? { ...prev, activeTabId: tab.id } : null); + } else { + setSlotC(prev => prev ? { ...prev, activeTabId: tab.id } : null); + } + return; + } + + // Cross-slot: remove from source, add to target + if (sourceSlot === 'A') removeTabFromSlot(setSlotA, slotA, tab.id); + if (sourceSlot === 'B') removeTabFromSlot(setSlotB, slotB, tab.id); + if (sourceSlot === 'C') removeTabFromSlot(setSlotC, slotC, tab.id); + + const targetSetter = getSlotSetter(targetSlot); + const targetState = getSlotState(targetSlot); + addOrFocusTab(targetSetter, targetState, tab); + setActivePane(targetSlot); + return; + } + + // Legacy: node-info drop (from sidebar etc.) + const nodeId = parsed.id; + if (typeof nodeId !== 'number') return; + + const tab: SlotTab = { id: createTabId('node', nodeId), type: 'node', nodeId }; + const targetSetter = getSlotSetter(targetSlot); + const targetState = getSlotState(targetSlot); + + addOrFocusTab(targetSetter, targetState, tab); + setActivePane(targetSlot); + } catch (err) { + console.error('Failed to parse dropped tab data:', err); } - }, [handleCrossSlotDrop, handleSwapPanes]); + }, [getSlotSetter, getSlotState, handleSwapPanes, slotA, slotB, slotC, addOrFocusTab, removeTabFromSlot, setSlotC]); + + // Cross-slot drop handler for SlotTabBar + const handleCrossSlotDropA = useCallback((tab: SlotTab, fromSlot: SlotId) => { + if (fromSlot === 'B') removeTabFromSlot(setSlotB, slotB, tab.id); + if (fromSlot === 'C') removeTabFromSlot(setSlotC, slotC, tab.id); + addOrFocusTab(setSlotA, slotA, tab); + setActivePane('A'); + }, [slotA, slotB, slotC, setSlotA, setSlotB, setSlotC, addOrFocusTab, removeTabFromSlot]); + + const handleCrossSlotDropB = useCallback((tab: SlotTab, fromSlot: SlotId) => { + if (fromSlot === 'A') removeTabFromSlot(setSlotA, slotA, tab.id); + if (fromSlot === 'C') removeTabFromSlot(setSlotC, slotC, tab.id); + addOrFocusTab(setSlotB, slotB, tab); + setActivePane('B'); + }, [slotA, slotB, slotC, setSlotA, setSlotB, setSlotC, addOrFocusTab, removeTabFromSlot]); + + const handleCrossSlotDropC = useCallback((tab: SlotTab, fromSlot: SlotId) => { + if (fromSlot === 'A') removeTabFromSlot(setSlotA, slotA, tab.id); + if (fromSlot === 'B') removeTabFromSlot(setSlotB, slotB, tab.id); + addOrFocusTab(setSlotC, slotC, tab); + setActivePane('C'); + }, [slotA, slotB, slotC, setSlotA, setSlotB, setSlotC, addOrFocusTab, removeTabFromSlot]); const handleResizePanels = useCallback((left: SlotId, right: SlotId, clientX: number) => { const leftEl = panelRefs.current[left]; @@ -762,9 +1371,8 @@ export default function ThreePanelLayout() { const combinedWidth = leftRect.width + rightRect.width; if (combinedWidth <= 0) return; - const minWidth = 220; const rawLeftWidth = clientX - leftRect.left; - const nextLeftWidth = Math.max(minWidth, Math.min(combinedWidth - minWidth, rawLeftWidth)); + const nextLeftWidth = Math.max(MIN_PANE_WIDTH, Math.min(combinedWidth - MIN_PANE_WIDTH, rawLeftWidth)); const nextRightWidth = combinedWidth - nextLeftWidth; const totalWeight = getPanelWeight(left) + getPanelWeight(right); @@ -772,116 +1380,196 @@ export default function ThreePanelLayout() { setPanelWeight(right, (nextRightWidth / combinedWidth) * totalWeight); }, [getPanelWeight, setPanelWeight]); - const renderSlot = (slot: SlotId, state: SlotState) => { - const activeTab = getActiveTab(state); - if (!activeTab) return null; + // --- Compute toolbar indicators --- + const { openTabTypes, activeTabType } = useMemo(() => { + const types = new Set(); + if (slotA) { + for (const tab of slotA.tabs) types.add(tab.type); + } + if (slotB) { + for (const tab of slotB.tabs) types.add(tab.type); + } + if (slotC) { + for (const tab of slotC.tabs) types.add(tab.type); + } - const tabBar = ( + const activeSlot = getSlotState(activePane); + const activeT = activeSlot ? getActiveTab(activeSlot) : null; + + return { + openTabTypes: types, + activeTabType: activeT?.type ?? null, + }; + }, [slotA, slotB, slotC, activePane, getSlotState]); + + // --- Render a slot based on the active tab --- + const renderSlotContent = (slot: SlotId, state: SlotState) => { + const isActive = activePane === slot; + const onCollapse = slot === 'A' + ? handleCloseSlotA + : slot === 'B' + ? handleCloseSlotB + : handleCloseSlotC; + const activeT = getActiveTab(state); + if (!activeT) return null; + + const renderTabBar = () => ( handleTabSelect(slot, tabId)} - onTabClose={(tabId) => closeTabInSlot(slot, tabId)} - onReorderTabs={(fromIndex, toIndex) => handleReorderTabs(slot, fromIndex, toIndex)} - onCrossSlotDrop={(tab, fromSlot) => handleCrossSlotDrop(slot, tab, fromSlot)} + onTabSelect={slot === 'A' ? handleTabSelectA : slot === 'B' ? handleTabSelectB : handleTabSelectC} + onTabClose={slot === 'A' ? handleCloseTabA : slot === 'B' ? handleCloseTabB : handleCloseTabC} + onReorderTabs={slot === 'A' ? handleReorderTabsA : slot === 'B' ? handleReorderTabsB : handleReorderTabsC} + onCrossSlotDrop={slot === 'A' ? handleCrossSlotDropA : slot === 'B' ? handleCrossSlotDropB : handleCrossSlotDropC} /> ); - const commonProps = { - slot, - isActive: activePane === slot, - onPaneAction: (action: PaneAction) => handleSlotAction(slot, action), - onCollapse: () => closeActiveSlot(slot), - onSwapPanes: handleSwapPanes, - tabBar, - }; + const tabBarElement = activeT.type === 'node' ? renderTabBar() : undefined; - switch (activeTab.type) { + switch (activeT.type) { case 'node': { - const nodeTabs = getSlotTabsByType(state, 'node').map((tab) => tab.nodeId!).filter((id): id is number => typeof id === 'number'); + const { openTabs: nodeTabs, activeTab: activeNodeTab } = getNodeTabsFromSlot(state); return ( handleTabSelect(slot, createTabId('node', nodeId))} - onTabClose={(nodeId) => closeTabInSlot(slot, createTabId('node', nodeId))} - onNodeClick={(nodeId) => addNodeTabToSlot(slot, nodeId)} - onReorderTabs={(fromIndex, toIndex) => handleReorderTabs(slot, fromIndex, toIndex)} + activeTab={activeNodeTab} + onTabSelect={(nodeId) => { + const tabId = createTabId('node', nodeId); + if (slot === 'A') handleTabSelectA(tabId); + else if (slot === 'B') handleTabSelectB(tabId); + else handleTabSelectC(tabId); + }} + onTabClose={(nodeId) => { + const tabId = createTabId('node', nodeId); + if (slot === 'A') handleCloseTabA(tabId); + else if (slot === 'B') handleCloseTabB(tabId); + else handleCloseTabC(tabId); + }} + onNodeClick={(nodeId) => { + addNodeTabToSlot(slot, nodeId); + setSelectedNodes(new Set([nodeId])); + setActivePane(slot); + }} + onReorderTabs={slot === 'A' ? (fromIndex: number, toIndex: number) => { + const nodeTabIndices = state.tabs.reduce((acc, t, i) => { + if (t.type === 'node') acc.push(i); + return acc; + }, []); + if (fromIndex < nodeTabIndices.length && toIndex < nodeTabIndices.length) { + handleReorderTabsA(nodeTabIndices[fromIndex], nodeTabIndices[toIndex]); + } + } : slot === 'B' ? (fromIndex: number, toIndex: number) => { + const nodeTabIndices = state.tabs.reduce((acc, t, i) => { + if (t.type === 'node') acc.push(i); + return acc; + }, []); + if (fromIndex < nodeTabIndices.length && toIndex < nodeTabIndices.length) { + handleReorderTabsB(nodeTabIndices[fromIndex], nodeTabIndices[toIndex]); + } + } : (fromIndex: number, toIndex: number) => { + const nodeTabIndices = state.tabs.reduce((acc, t, i) => { + if (t.type === 'node') acc.push(i); + return acc; + }, []); + if (fromIndex < nodeTabIndices.length && toIndex < nodeTabIndices.length) { + handleReorderTabsC(nodeTabIndices[fromIndex], nodeTabIndices[toIndex]); + } + }} refreshTrigger={focusPanelRefresh} onOpenInOtherSlot={(nodeId) => openNodeFromSlot(nodeId, slot)} - onTextSelect={(nodeId, nodeTitle, text) => setHighlightedPassage({ nodeId, nodeTitle, selectedText: text })} + onTextSelect={(nodeId, nodeTitle, text) => { + setHighlightedPassage({ nodeId, nodeTitle, selectedText: text }); + }} highlightedPassage={highlightedPassage} /> ); } - case 'contexts': - return ( - openNodeFromSlot(nodeId, slot)} - onContextSelect={(contextId, contextName) => handleContextSelect(slot, contextId, contextName)} - /> - ); + case 'map': return ( openNodeFromSlot(nodeId, slot)} focusedNodeId={focusedNodeId} onClearFocus={clearMapFocus} /> ); + case 'views': return ( openNodeFromSlot(nodeId, slot)} onNodeOpenInOtherPane={(nodeId) => openNodeFromSlot(nodeId, slot)} refreshToken={nodesPanelRefresh} pendingNodes={pendingNodes} - onDismissPending={(id) => setPendingNodes((prev) => prev.filter((item) => item.id !== id))} - externalContextFilterId={browseContextFilters[slot]} - onContextFilterSelect={(contextId) => { - setBrowseContextFilters((prev) => ({ ...prev, [slot]: contextId })); - }} - onClearExternalContextFilter={() => { - setBrowseContextFilters((prev) => ({ ...prev, [slot]: null })); - }} + onDismissPending={(id) => setPendingNodes(prev => prev.filter(p => p.id !== id))} /> ); + case 'table': return ( openNodeFromSlot(nodeId, slot)} refreshToken={nodesPanelRefresh} /> ); + case 'skills': - return ; + return ( + { + setFocusedSkill(skill); + if (CHAT_FEATURE_ENABLED && skill) { + setChatPanelOpen(true); + } + }} + autoOpenSkillName={autoOpenSkillName} + onAutoOpenHandled={() => setAutoOpenSkillName(null)} + /> + ); + default: return null; } }; - const openTabTypes = useMemo(() => { - const types = new Set(); - (Object.values(slotStates) as Array).forEach((state) => { - state?.tabs.forEach((tab) => types.add(tab.type)); - }); - return types; - }, [slotStates]); - - const activeTabType = useMemo(() => { - const state = getSlotState(activePane); - return state ? getActiveTab(state)?.type ?? null : null; - }, [activePane, getSlotState]); - + const slotStates: Record = { A: slotA, B: slotB, C: slotC }; const getSlotContainerStyle = (slot: SlotId) => { const state = slotStates[slot]; + const hasContent = Boolean(state && state.tabs.length > 0); const weight = getPanelWeight(slot); + const showsChat = CHAT_FEATURE_ENABLED && chatPanelOpen && chatSlot === slot; return { flex: `${weight} ${weight} 0`, @@ -891,7 +1579,7 @@ export default function ThreePanelLayout() { flexDirection: 'column' as const, background: 'var(--rah-bg-surface)', borderRadius: '10px', - border: state ? '1px solid transparent' : '1px dashed var(--rah-border)', + border: hasContent || showsChat ? '1px solid transparent' : '1px dashed var(--rah-border)', outline: dragOverSlot === slot ? '2px dashed var(--rah-accent-green)' : 'none', outlineOffset: '-4px', transition: 'outline 0.15s ease, background 0.15s ease', @@ -900,7 +1588,66 @@ export default function ThreePanelLayout() { const renderExpandedEmptyPanel = (slot: SlotId) => (
-
+
{ + e.dataTransfer.setData('application/x-rah-pane', slot); + e.dataTransfer.effectAllowed = 'move'; + }} + onDragOver={(e) => { + if (!e.dataTransfer.types.includes('application/x-rah-pane')) return; + e.preventDefault(); + }} + onDrop={(e) => { + e.preventDefault(); + const source = e.dataTransfer.getData('application/x-rah-pane') as SlotId; + if (source && source !== slot) { + handleSwapPanes(source, slot); + } + }} + style={{ + minHeight: '48px', + display: 'flex', + alignItems: 'center', + justifyContent: 'space-between', + padding: '8px 12px', + cursor: 'grab', + }} + > + + +
+
Select a pane from the nav
@@ -914,9 +1661,10 @@ export default function ThreePanelLayout() { height: '100vh', width: '100vw', background: 'var(--rah-bg-base)', - overflow: 'hidden', + overflow: 'hidden' }} > + {/* Left Toolbar */} setShowSearchModal(true)} onAddStuffClick={() => setShowAddStuff(true)} @@ -927,28 +1675,24 @@ export default function ThreePanelLayout() { setSettingsInitialTab(undefined); setShowSettings(true); }} - onPaneTypeClick={(paneType) => { - if (paneType !== 'node') { - openPaneSingleton(paneType, 'A'); - } - }} + onPaneTypeClick={handlePaneTypeClick} isExpanded={leftNavExpanded} onToggleExpanded={() => setLeftNavExpanded((prev) => !prev)} openTabTypes={openTabTypes} activeTabType={activeTabType} + focusedSkill={focusedSkill} theme={theme} onThemeToggle={toggleTheme} - contexts={availableContexts} - onContextQuickSelect={(contextId) => { - const target = openPaneSingleton('views', 'A'); - setBrowseContextFilters((prev) => ({ ...prev, [target]: contextId })); - setActiveContextId(contextId); - }} /> -
+
{visibleSlots.flatMap((slot, index) => { const state = slotStates[slot]; + const showsChat = CHAT_FEATURE_ENABLED && chatPanelOpen && chatSlot === slot; + const nextSlot = visibleSlots[index + 1]; const items: React.ReactNode[] = []; items.push( @@ -957,17 +1701,44 @@ export default function ThreePanelLayout() { ref={(node) => { panelRefs.current[slot] = node; }} - onClick={() => setActivePane(slot)} - onDragOver={(event) => handleSlotDragOver(event, slot)} - onDragLeave={() => setDragOverSlot(null)} - onDrop={(event) => handleSlotDrop(event, slot)} + onClick={() => { + if (!showsChat) { + setActivePane(slot); + } + }} + onDragOver={(e) => handleSlotDragOver(e, slot)} + onDragLeave={handleSlotDragLeave} + onDrop={(e) => handleSlotDrop(e, slot)} style={getSlotContainerStyle(slot)} > - {state ? renderSlot(slot, state) : renderExpandedEmptyPanel(slot)} + {showsChat ? ( + setChatPanelOpen(false)} + onOpen={() => setChatPanelOpen(true)} + onSwapPanes={handleSwapPanes} + openTabsData={openTabsData} + activeTabId={activeTab} + focusedSkill={focusedSkill} + onClearFocusedSkill={() => setFocusedSkill(null)} + onNodeClick={(nodeId) => { + openNodeFromSlot(nodeId); + }} + chatMessages={chatMessages as unknown[]} + setChatMessages={setChatMessages as React.Dispatch>} + highlightedPassage={highlightedPassage} + onClearPassage={() => setHighlightedPassage(null)} + onboardingHint={showOnboardingHint ? ONBOARDING_HINT_TEXT : null} + onDismissOnboardingHint={showOnboardingHint ? dismissOnboardingHint : undefined} + /> + ) : state + ? renderSlotContent(slot, state) + : renderExpandedEmptyPanel(slot)}
); - const nextSlot = visibleSlots[index + 1]; if (nextSlot) { items.push( + {CHAT_FEATURE_ENABLED && !chatPanelOpen && ( + setChatPanelOpen(false)} + onOpen={() => setChatPanelOpen(true)} + openTabsData={openTabsData} + activeTabId={activeTab} + focusedSkill={focusedSkill} + onClearFocusedSkill={() => setFocusedSkill(null)} + onNodeClick={(nodeId) => { + openNodeFromSlot(nodeId, 'C'); + }} + chatMessages={chatMessages as unknown[]} + setChatMessages={setChatMessages as React.Dispatch>} + highlightedPassage={highlightedPassage} + onClearPassage={() => setHighlightedPassage(null)} + onboardingHint={showOnboardingHint ? ONBOARDING_HINT_TEXT : null} + onDismissOnboardingHint={showOnboardingHint ? dismissOnboardingHint : undefined} + /> + )} + + {/* Search Modal */} setShowSearchModal(false)} - onNodeSelect={(nodeId) => { - openNodeFromSlot(nodeId); - setShowSearchModal(false); - }} - existingFilters={EMPTY_SEARCH_FILTERS} + onNodeSelect={handleSearchNodeSelect} + existingFilters={searchModalFilters} /> + {/* Settings Modal */} + {/* Add Stuff Modal */} setShowAddStuff(false)} diff --git a/src/components/panes/ContextsPane.tsx b/src/components/panes/ContextsPane.tsx deleted file mode 100644 index 6a6311f..0000000 --- a/src/components/panes/ContextsPane.tsx +++ /dev/null @@ -1,124 +0,0 @@ -"use client"; - -import { useEffect, useState } from 'react'; -import { Folder } from 'lucide-react'; -import type { ContextSummary } from '@/types/database'; -import PaneHeader from './PaneHeader'; -import type { ContextsPaneProps } from './types'; - -export default function ContextsPane({ - slot, - onCollapse, - onSwapPanes, - tabBar, - onContextSelect, -}: ContextsPaneProps) { - const [contexts, setContexts] = useState([]); - const [loading, setLoading] = useState(true); - const [toolbarHost, setToolbarHost] = useState(null); - - useEffect(() => { - void (async () => { - setLoading(true); - try { - const response = await fetch('/api/contexts'); - const payload = await response.json(); - if (response.ok && payload.success) { - setContexts(payload.data || []); - } - } finally { - setLoading(false); - } - })(); - }, []); - - return ( -
- - -
-
- {loading ? ( -
Loading contexts...
- ) : contexts.length === 0 ? ( -
No contexts yet. That is optional.
- ) : ( - contexts.map((context) => ( - - )) - )} -
-
-
- ); -} - -const rowStyle: React.CSSProperties = { - width: '100%', - display: 'flex', - alignItems: 'center', - gap: '12px', - padding: '12px 14px', - border: '1px solid var(--rah-border)', - borderRadius: '12px', - background: 'var(--rah-bg-card)', - cursor: 'pointer', -}; - -const iconWrapStyle: React.CSSProperties = { - width: '32px', - height: '32px', - display: 'inline-flex', - alignItems: 'center', - justifyContent: 'center', - borderRadius: '10px', - border: '1px solid var(--rah-border)', - background: 'var(--rah-bg-panel)', - color: 'var(--rah-text-muted)', - flexShrink: 0, -}; - -const countStyle: React.CSSProperties = { - fontSize: '10px', - lineHeight: 1.2, - padding: '3px 7px', - borderRadius: '999px', - border: '1px solid var(--rah-border)', - background: 'var(--rah-bg-panel)', - color: 'var(--rah-text-muted)', -}; - -const emptyStateStyle: React.CSSProperties = { - border: '1px dashed var(--rah-border-strong)', - borderRadius: '12px', - padding: '16px', - color: 'var(--rah-text-muted)', - fontSize: '13px', - background: 'var(--rah-bg-card)', -}; diff --git a/src/components/panes/SkillsPane.tsx b/src/components/panes/SkillsPane.tsx index ac171e3..9bcfa15 100644 --- a/src/components/panes/SkillsPane.tsx +++ b/src/components/panes/SkillsPane.tsx @@ -6,6 +6,7 @@ import PaneHeader from './PaneHeader'; import type { BasePaneProps } from './types'; import SkillCard from '@/components/skills/SkillCard'; import SkillMarkdown from '@/components/skills/SkillMarkdown'; +import type { FocusedSkill } from '@/types/skills'; interface SkillMeta { name: string; @@ -17,12 +18,19 @@ interface Skill extends SkillMeta { content: string; } +interface SkillsPaneProps extends BasePaneProps { + focusedSkill?: FocusedSkill | null; + onFocusSkill?: (skill: FocusedSkill | null) => void; + autoOpenSkillName?: string | null; + onAutoOpenHandled?: () => void; +} + export default function SkillsPane({ slot, onCollapse, onSwapPanes, tabBar, -}: BasePaneProps) { +}: SkillsPaneProps) { const [skills, setSkills] = useState([]); const [selectedSkill, setSelectedSkill] = useState(null); const [loading, setLoading] = useState(true); diff --git a/src/components/panes/SlotTabBar.tsx b/src/components/panes/SlotTabBar.tsx index 2ad20bc..e444af9 100644 --- a/src/components/panes/SlotTabBar.tsx +++ b/src/components/panes/SlotTabBar.tsx @@ -1,12 +1,11 @@ "use client"; import { useEffect, useRef, useState, useCallback } from 'react'; -import { X, LayoutList, PanelsTopLeft, Map, FileText, Table2, BookOpen } from 'lucide-react'; +import { X, LayoutList, Map, FileText, Table2, BookOpen } from 'lucide-react'; import type { SlotTab, PaneType, SlotId } from './types'; const TAB_TYPE_ICONS: Record = { views: LayoutList, - contexts: PanelsTopLeft, map: Map, node: FileText, table: Table2, @@ -15,7 +14,6 @@ const TAB_TYPE_ICONS: Record = { const TAB_TYPE_LABELS: Record = { views: 'Feed', - contexts: 'Contexts', map: 'Map', node: 'Node', table: 'Table', diff --git a/src/components/panes/ViewsPane.tsx b/src/components/panes/ViewsPane.tsx index 2fe923b..5eae56d 100644 --- a/src/components/panes/ViewsPane.tsx +++ b/src/components/panes/ViewsPane.tsx @@ -12,9 +12,6 @@ export interface ViewsPaneProps extends BasePaneProps { refreshToken?: number; pendingNodes?: PendingNode[]; onDismissPending?: (id: string) => void; - externalContextFilterId?: number | null; - onContextFilterSelect?: (contextId: number | null, contextName?: string | null) => void; - onClearExternalContextFilter?: () => void; } export default function ViewsPane({ @@ -29,9 +26,6 @@ export default function ViewsPane({ refreshToken, pendingNodes, onDismissPending, - externalContextFilterId, - onContextFilterSelect, - onClearExternalContextFilter, }: ViewsPaneProps) { const [toolbarHost, setToolbarHost] = useState(null); @@ -61,9 +55,6 @@ export default function ViewsPane({ refreshToken={refreshToken} pendingNodes={pendingNodes} onDismissPending={onDismissPending} - externalContextFilterId={externalContextFilterId} - onContextFilterSelect={onContextFilterSelect} - onClearExternalContextFilter={onClearExternalContextFilter} toolbarHost={toolbarHost} />
diff --git a/src/components/panes/index.ts b/src/components/panes/index.ts index 38d6181..d33507e 100644 --- a/src/components/panes/index.ts +++ b/src/components/panes/index.ts @@ -1,5 +1,4 @@ export { default as NodePane } from './NodePane'; -export { default as ContextsPane } from './ContextsPane'; export { default as MapPane } from './MapPane'; export { default as ViewsPane } from './ViewsPane'; export { default as TablePane } from './TablePane'; diff --git a/src/components/panes/types.ts b/src/components/panes/types.ts index 7904004..228e82e 100644 --- a/src/components/panes/types.ts +++ b/src/components/panes/types.ts @@ -4,7 +4,7 @@ import type { FocusedSkill } from '@/types/skills'; export type SlotId = 'A' | 'B' | 'C'; // The pane types -export type PaneType = 'node' | 'contexts' | 'map' | 'views' | 'table' | 'skills'; +export type PaneType = 'node' | 'map' | 'views' | 'table' | 'skills'; // A single tab within a slot export interface SlotTab { @@ -32,7 +32,6 @@ export function getActiveTab(state: SlotState): SlotTab | undefined { // Actions panes can emit to the layout export type PaneAction = | { type: 'open-node'; nodeId: number; targetSlot?: SlotId } - | { type: 'open-context'; contextId: number | null; contextName?: string | null; targetSlot?: SlotId } | { type: 'switch-pane-type'; paneType: PaneType } | { type: 'close-pane' }; @@ -69,13 +68,13 @@ export interface HighlightedPassage { export interface ChatPanelProps { isOpen: boolean; + isSoloPane?: boolean; + slot?: SlotId; onClose: () => void; onOpen: () => void; + onSwapPanes?: (source: SlotId, target: SlotId) => void; openTabsData: Node[]; activeTabId: number | null; - activeContextId?: number | null; - activeContextName?: string | null; - onClearContext?: () => void; focusedSkill?: FocusedSkill | null; onClearFocusedSkill?: () => void; onNodeClick?: (nodeId: number) => void; @@ -99,14 +98,6 @@ export interface ViewsPaneProps extends BasePaneProps { onNodeClick: (nodeId: number) => void; onNodeOpenInOtherPane?: (nodeId: number) => void; refreshToken?: number; - externalContextFilterId?: number | null; - onContextFilterSelect?: (contextId: number | null, contextName?: string | null) => void; - onClearExternalContextFilter?: () => void; -} - -export interface ContextsPaneProps extends BasePaneProps { - onNodeOpen: (nodeId: number) => void; - onContextSelect?: (contextId: number | null, contextName?: string | null) => void; } // TablePane specific props @@ -128,7 +119,6 @@ export interface PaneHeaderProps { // Labels for pane types export const PANE_LABELS: Record = { node: 'Nodes', - contexts: 'Contexts', map: 'Map', views: 'Feed', table: 'Table', diff --git a/src/components/settings/ContextViewer.tsx b/src/components/settings/ContextViewer.tsx deleted file mode 100644 index 2d785fc..0000000 --- a/src/components/settings/ContextViewer.tsx +++ /dev/null @@ -1,198 +0,0 @@ -"use client"; - -import { useEffect, useState, type CSSProperties } from 'react'; -import type { Node } from '@/types/database'; - -interface NodeWithMetrics extends Node { - edge_count?: number; -} - -interface CapsuleData { - userProfile: string; - agentProfile: string; - lastUpdatedAt: string; -} - -export default function ContextViewer() { - const [nodes, setNodes] = useState([]); - const [capsule, setCapsule] = useState(null); - const [loadingNodes, setLoadingNodes] = useState(true); - const [loadingCapsule, setLoadingCapsule] = useState(true); - const [resetting, setResetting] = useState(false); - - const loadNodes = async () => { - try { - const res = await fetch('/api/nodes?sortBy=edges&limit=5'); - const payload = await res.json(); - setNodes(payload.data || []); - } catch (error) { - console.error(error); - setNodes([]); - } finally { - setLoadingNodes(false); - } - }; - - const loadCapsule = async () => { - try { - const res = await fetch('/api/rah/memory'); - const payload = await res.json(); - setCapsule(payload.data?.capsule ?? null); - } catch (error) { - console.error(error); - setCapsule(null); - } finally { - setLoadingCapsule(false); - } - }; - - useEffect(() => { - void loadNodes(); - void loadCapsule(); - }, []); - - const handleReset = async () => { - if (!confirm('Reset the context capsule to neutral defaults?')) return; - try { - setResetting(true); - await fetch('/api/rah/memory', { method: 'DELETE' }); - await loadCapsule(); - } finally { - setResetting(false); - } - }; - - return ( -
-

- RA-H now carries one compact context capsule into every conversation. It stays neutral by default, - updates only on meaningful changes, and sits alongside hub nodes rather than replacing them. -

- -
-
-
Context Capsule
-
Always injected into the system prompt. Max 200 words total.
-
- -
- - {loadingCapsule ? ( -
Loading capsule...
- ) : capsule ? ( -
- - -
- ) : ( -
Capsule unavailable.
- )} - -
Hub Nodes
-
- Your 5 most-connected nodes remain the raw graph grounding and are included in every conversation. -
- - {loadingNodes ? ( -
Loading hub nodes...
- ) : nodes.length === 0 ? ( -
No connected nodes yet.
- ) : ( -
- {nodes.map((node) => ( -
-
- {node.title || 'Untitled'} - {node.edge_count ?? 0} -
- {node.description && ( -
{node.description}
- )} - {node.context?.name && ( -
- {node.context.name} -
- )} -
- ))} -
- )} -
- ); -} - -function CapsuleSection({ - title, - value, - footer, -}: { - title: string; - value: string; - footer?: string; -}) { - return ( -
-
{title}
-
{value}
- {footer &&
{footer}
} -
- ); -} - -const containerStyle: CSSProperties = { padding: 24, height: '100%', overflow: 'auto' }; -const descStyle: CSSProperties = { fontSize: 13, color: 'var(--settings-muted)', marginBottom: 20, lineHeight: 1.5, maxWidth: 780 }; -const capsuleHeaderStyle: CSSProperties = { display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start', gap: 16, marginBottom: 12 }; -const capsuleGridStyle: CSSProperties = { display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(220px, 1fr))', gap: 12 }; -const cardStyle: CSSProperties = { - background: 'var(--settings-card-bg)', - border: '1px solid var(--settings-border)', - borderRadius: 8, - padding: 16, - minHeight: 140, -}; -const labelStyle: CSSProperties = { fontSize: 13, fontWeight: 500, color: 'var(--settings-text)', marginBottom: 8 }; -const subLabelStyle: CSSProperties = { fontSize: 12, color: 'var(--settings-muted)' }; -const mutedStyle: CSSProperties = { fontSize: 13, color: 'var(--settings-muted)', marginTop: 10 }; -const capsuleBodyStyle: CSSProperties = { fontSize: 13, lineHeight: 1.6, color: 'var(--settings-subtext)', whiteSpace: 'pre-wrap' }; -const capsuleFooterStyle: CSSProperties = { fontSize: 11, color: 'var(--settings-muted)', marginTop: 12 }; -const resetButtonStyle: CSSProperties = { - padding: '8px 14px', - background: 'transparent', - border: '1px solid var(--settings-border-strong)', - borderRadius: '6px', - color: 'var(--settings-text)', - fontSize: '12px', - fontWeight: 500, - cursor: 'pointer', - transition: 'all 0.15s ease', - whiteSpace: 'nowrap', -}; -const nodeCardStyle: CSSProperties = { - padding: '12px 14px', - background: 'var(--settings-card-bg)', - border: '1px solid var(--settings-border)', - borderRadius: 6, -}; -const nodeTitleStyle: CSSProperties = { fontSize: 13, fontWeight: 500, color: 'var(--settings-text)' }; -const nodeDescriptionStyle: CSSProperties = { fontSize: 12, lineHeight: 1.5, color: 'var(--settings-subtext)', marginTop: 8 }; -const edgeCountStyle: CSSProperties = { fontSize: 12, color: 'var(--settings-muted)' }; -const contextTagStyle: CSSProperties = { - padding: '2px 8px', - borderRadius: 4, - fontSize: 11, - background: 'var(--settings-chip-bg)', - color: 'var(--settings-subtext)', - border: '1px solid var(--settings-border)', -}; diff --git a/src/components/settings/DatabaseViewer.tsx b/src/components/settings/DatabaseViewer.tsx index c40b61a..65177d8 100644 --- a/src/components/settings/DatabaseViewer.tsx +++ b/src/components/settings/DatabaseViewer.tsx @@ -325,24 +325,7 @@ export default function DatabaseViewer() {
- {node.context?.name ? ( - - {node.context.name} - - ) : ( - Unscoped - )} +
{node.edge_count ?? 0}
diff --git a/src/components/settings/SettingsModal.tsx b/src/components/settings/SettingsModal.tsx index c1801c2..dce6003 100644 --- a/src/components/settings/SettingsModal.tsx +++ b/src/components/settings/SettingsModal.tsx @@ -4,9 +4,8 @@ import { useEffect, useState, type CSSProperties } from 'react'; import { createPortal } from 'react-dom'; import ApiKeysViewer from './ApiKeysViewer'; import ExternalAgentsPanel from './ExternalAgentsPanel'; -import ContextViewer from './ContextViewer'; -export type SettingsTab = 'apikeys' | 'context' | 'agents'; +export type SettingsTab = 'apikeys' | 'agents'; interface SettingsModalProps { isOpen: boolean; @@ -15,10 +14,10 @@ interface SettingsModalProps { } const DEFAULT_TAB: SettingsTab = 'apikeys'; -const TAB_ORDER: SettingsTab[] = ['apikeys', 'context', 'agents']; +const TAB_ORDER: SettingsTab[] = ['apikeys', 'agents']; + const TAB_LABELS: Record = { apikeys: 'API Keys', - context: 'Context', agents: 'Agents', }; @@ -31,11 +30,9 @@ export default function SettingsModal({ isOpen, onClose, initialTab }: SettingsM useEffect(() => { if (!isOpen) return; - const handleEscape = (e: KeyboardEvent) => { if (e.key === 'Escape') onClose(); }; - window.addEventListener('keydown', handleEscape); return () => window.removeEventListener('keydown', handleEscape); }, [isOpen, onClose]); @@ -50,6 +47,7 @@ export default function SettingsModal({ isOpen, onClose, initialTab }: SettingsM return createPortal(
e.stopPropagation()}> + {/* Sidebar */}
Settings
@@ -70,36 +68,27 @@ export default function SettingsModal({ isOpen, onClose, initialTab }: SettingsM ))} -
-
Local Mode
-

- This build runs entirely on your machine. Add your API key here to unlock descriptions, - embeddings, and agent-assisted workflows. -

+
+
Local Mode
+
Bring your own API keys for descriptions, embeddings, and agent workflows.
+ {/* Content */}

{TAB_LABELS[activeTab]}

-
{activeTab === 'apikeys' && } - {activeTab === 'context' && } {activeTab === 'agents' && }
@@ -109,6 +98,7 @@ export default function SettingsModal({ isOpen, onClose, initialTab }: SettingsM ); } +// Styles const backdropStyle: CSSProperties = { position: 'fixed', inset: 0, @@ -133,7 +123,7 @@ const modalStyle: CSSProperties = { }; const sidebarStyle: CSSProperties = { - width: '220px', + width: '200px', background: 'var(--settings-sidebar-bg)', borderRight: '1px solid var(--settings-border)', display: 'flex', @@ -146,6 +136,7 @@ const logoStyle: CSSProperties = { fontSize: '15px', fontWeight: 600, color: 'var(--settings-text)', + letterSpacing: '-0.01em', }; const navStyle: CSSProperties = { @@ -164,8 +155,7 @@ const navItemStyle: CSSProperties = { borderRadius: '8px', }; -const footerStyle: CSSProperties = { - marginTop: 'auto', +const userSectionStyle: CSSProperties = { padding: '16px 20px', borderTop: '1px solid var(--settings-border)', display: 'flex', @@ -173,19 +163,31 @@ const footerStyle: CSSProperties = { gap: '8px', }; -const footerLabelStyle: CSSProperties = { +const userLabelStyle: CSSProperties = { fontSize: '10px', - fontWeight: 600, - letterSpacing: '0.06em', + fontWeight: 500, textTransform: 'uppercase', + letterSpacing: '0.05em', color: 'var(--settings-muted)', }; -const footerTextStyle: CSSProperties = { +const userEmailStyle: CSSProperties = { fontSize: '12px', color: 'var(--settings-subtext)', - margin: 0, - lineHeight: 1.5, + wordBreak: 'break-all', +}; + +const signOutBtnStyle: CSSProperties = { + marginTop: '4px', + padding: '8px 12px', + background: 'transparent', + color: 'var(--settings-text)', + border: '1px solid var(--settings-border-strong)', + borderRadius: '6px', + fontSize: '12px', + fontWeight: 500, + cursor: 'pointer', + transition: 'background 0.15s ease, border-color 0.15s ease', }; const contentStyle: CSSProperties = { @@ -211,13 +213,13 @@ const titleStyle: CSSProperties = { }; const closeBtnStyle: CSSProperties = { - background: 'transparent', + background: 'none', border: 'none', color: 'var(--settings-muted)', + fontSize: '20px', cursor: 'pointer', - fontSize: '24px', - lineHeight: 1, padding: '4px 8px', + lineHeight: 1, transition: 'color 0.15s ease', }; diff --git a/src/components/views/DatabaseTableView.tsx b/src/components/views/DatabaseTableView.tsx index 833390d..d340858 100644 --- a/src/components/views/DatabaseTableView.tsx +++ b/src/components/views/DatabaseTableView.tsx @@ -322,30 +322,7 @@ export default function DatabaseTableView({ onNodeClick, refreshToken = 0, toolb {node.id} - - {node.context?.name ? ( - - {node.context.name} - - ) : ( - - )} - + {node.edge_count ?? 0} diff --git a/src/components/views/GridView.tsx b/src/components/views/GridView.tsx index 289cbf6..f12ee06 100644 --- a/src/components/views/GridView.tsx +++ b/src/components/views/GridView.tsx @@ -133,27 +133,6 @@ export default function GridView({ nodes, onNodeClick }: GridViewProps) {
)} - {/* Footer with Context */} - {node.context?.name && ( -
- - {node.context.name} - -
- )} ); })} diff --git a/src/components/views/ListView.tsx b/src/components/views/ListView.tsx index b64f89f..a900878 100644 --- a/src/components/views/ListView.tsx +++ b/src/components/views/ListView.tsx @@ -146,21 +146,6 @@ export default function ListView({ nodes, onNodeClick }: ListViewProps) { }}> {processedState} - {node.context?.name && ( - - {node.context.name} - - )} - {/* Date */} void; - externalContextFilterId?: number | null; - onContextFilterSelect?: (contextId: number | null, contextName?: string | null) => void; - onClearExternalContextFilter?: () => void; toolbarHost?: HTMLDivElement | null; } @@ -216,15 +213,9 @@ export default function ViewsOverlay({ refreshToken = 0, pendingNodes, onDismissPending, - externalContextFilterId = null, - onContextFilterSelect, - onClearExternalContextFilter, toolbarHost, }: ViewsOverlayProps) { - const [contexts, setContexts] = useState([]); - const [contextsLoading, setContextsLoading] = useState(true); - // Sort order (persisted) const [sortOrder, setSortOrder] = usePersistentState('ui.feedSortOrder', 'updated'); @@ -245,22 +236,6 @@ export default function ViewsOverlay({ ? 'not_processed' : 'all'; - const fetchContexts = useCallback(async () => { - setContextsLoading(true); - try { - const response = await fetch('/api/contexts'); - const data = await response.json(); - if (!response.ok || !data.success) { - throw new Error(data.error || 'Failed to fetch contexts'); - } - setContexts(data.data || []); - } catch (error) { - console.error('Error fetching contexts:', error); - } finally { - setContextsLoading(false); - } - }, []); - const applyProcessedFilter = useCallback((nodes: Node[]) => { if (processedFilter === 'all') return nodes; return nodes.filter((node) => getNodeProcessedState(node.metadata) === processedFilter); @@ -273,7 +248,7 @@ export default function ViewsOverlay({ const apiSort = sortOrder === 'custom' || sortOrder === 'processed' || sortOrder === 'not_processed' ? 'updated' : sortOrder; - const response = await fetch(`/api/nodes?limit=500&sortBy=${apiSort}${externalContextFilterId ? `&contextId=${externalContextFilterId}` : ''}`); + const response = await fetch(`/api/nodes?limit=500&sortBy=${apiSort}`); const data = await response.json(); if (!response.ok || !data.success) { throw new Error(data.error || 'Failed to fetch nodes'); @@ -301,45 +276,28 @@ export default function ViewsOverlay({ } finally { setFilteredNodesLoading(false); } - }, [sortOrder, customOrder, applyProcessedFilter, externalContextFilterId]); + }, [sortOrder, customOrder, applyProcessedFilter]); - // Fetch contexts on mount - useEffect(() => { - fetchContexts(); - }, [fetchContexts]); - - // Fetch nodes on mount and when sort/refreshToken/context filter change + // Fetch nodes on mount and when sort/refreshToken change useEffect(() => { if (refreshToken > 0) { console.log('🔄 Feed refreshing due to SSE event (refreshToken:', refreshToken, ')'); } fetchAllNodes(); - }, [fetchAllNodes, refreshToken, sortOrder, externalContextFilterId]); - - // Refresh contexts when data changes - useEffect(() => { - if (refreshToken > 0) { - fetchContexts(); - } - }, [refreshToken, fetchContexts]); + }, [fetchAllNodes, refreshToken, sortOrder]); // Close dropdowns on outside click - const contextPickerRef = useRef(null); const sortDropdownRef = useRef(null); - const [showContextPicker, setShowContextPicker] = useState(false); useEffect(() => { const handleClickOutside = (e: MouseEvent) => { - if (showContextPicker && contextPickerRef.current && !contextPickerRef.current.contains(e.target as HTMLElement)) { - setShowContextPicker(false); - } if (showSortDropdown && sortDropdownRef.current && !sortDropdownRef.current.contains(e.target as HTMLElement)) { setShowSortDropdown(false); } }; document.addEventListener('mousedown', handleClickOutside); return () => document.removeEventListener('mousedown', handleClickOutside); - }, [showContextPicker, showSortDropdown]); + }, [showSortDropdown]); // Reorder handlers const handleReorderDrop = useCallback((dropIdx: number) => { @@ -615,107 +573,7 @@ export default function ViewsOverlay({ gap: '10px', flexWrap: 'wrap' }}> -
- {externalContextFilterId ? ( -
- {contexts.find((context) => context.id === externalContextFilterId)?.name ?? 'Context'} - -
- ) : null} - -
- - - {showContextPicker && ( -
- - {contextsLoading ? ( -
- Loading contexts... -
- ) : contexts.map((context) => ( - - ))} -
- )} -
-
+
{/* Sort dropdown */}
diff --git a/src/config/skills/audit.md b/src/config/skills/audit.md index d802871..c67598f 100644 --- a/src/config/skills/audit.md +++ b/src/config/skills/audit.md @@ -9,7 +9,7 @@ description: "Use for structured review, QA, cleanup, or governance checks acros 1. Node quality: duplicates, vague descriptions, missing dates, weak titles. 2. Edge quality: missing links, weak explanations, wrong directionality. -3. Context quality: drift, overlap, low-signal buckets, or contexts being overused where stronger node metadata should carry the meaning. +3. Graph structure quality: weak clustering, redundant organizational nodes, or cases where stronger titles, descriptions, metadata, and edges should carry the meaning. 4. Skill quality: trigger clarity, overlap, dead/unused skills. ## Output Format diff --git a/src/config/skills/calibration.md b/src/config/skills/calibration.md index b0fe10b..4592dfb 100644 --- a/src/config/skills/calibration.md +++ b/src/config/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 nodes, changed priorities, and explicit deltas. Contexts are secondary when clearly useful." +success_criteria: "Graph reflects current reality: updated nodes, 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 the strongest active nodes first and use contexts only as a secondary check when they already exist and are clearly relevant. +1. Review the strongest active nodes first. 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/src/config/skills/db-operations.md b/src/config/skills/db-operations.md index fc80693..21a290b 100644 --- a/src/config/skills/db-operations.md +++ b/src/config/skills/db-operations.md @@ -13,7 +13,7 @@ description: "Use for graph read, write, connect, classify, or traverse operatio 4. Search before create to avoid duplicates. 5. Every create/update must include a natural description that clearly says what the thing is, why it matters here, and its current workflow status. 6. Use event dates when known (when it happened, not when saved). -7. Leave context blank by default. Only apply context when the user explicitly wants it or when one obvious existing context is clearly useful. One node gets at most one primary context, and leaving context blank is valid. +7. Work graph-first. Do not rely on a separate context layer for ordinary reads or writes. 8. Do not create edges autonomously. Surface likely edge candidates first, then create them only after the user explicitly confirms. 9. For user-authored ideas, notes, or dictated thoughts, preserve the user's wording in `source` as fully as possible with only minimal cleanup. @@ -24,9 +24,6 @@ description: "Use for graph read, write, connect, classify, or traverse operatio - `source`: full verbatim or canonical content of the node (transcript, article text, book passage, user's thoughts). This is what gets chunked and embedded for semantic search. - For idea capture from chat, the `source` should usually be the raw user thought, not a compressed assistant summary. - `link`: external source URL only. -- Normal writes should omit context entirely unless the user explicitly wants one. -- If context is intentionally provided, prefer `context_name`. -- Treat numeric `context_id` as an internal implementation detail, not a normal agent-facing field. - `metadata`: use the canonical node metadata contract when metadata is needed: - `type` - `state` (`processed` or `not_processed`) @@ -48,7 +45,7 @@ It must still make three things clear: 2. Why — why it is in the graph; what Brad is interested in; what it connects to 3. Status — where it sits in his workflow (queued, in progress, processed, unknown) -If the agent has graph context (context capsule, focused nodes, recent connected nodes, or an explicit active context), it should infer the why from that context and write it naturally. Do not let the service auto-generate a weak context-free description when you already have enough signal. +If the agent has graph context (focused nodes, recent connected nodes, or retrieved supporting nodes), it should infer the why from that context and write it naturally. Do not let the service auto-generate a weak context-free description when you already have enough signal. If the why genuinely cannot be inferred, say that naturally. Do not use labels like `WHAT:`, `WHY:`, or `STATUS:` and do not substitute vague filler like `insightful for understanding` or `relevant to Brad's work`. diff --git a/src/config/skills/node-context-enrichment.md b/src/config/skills/node-context-enrichment.md index eeab6a5..7841a3b 100644 --- a/src/config/skills/node-context-enrichment.md +++ b/src/config/skills/node-context-enrichment.md @@ -1,13 +1,13 @@ --- name: Node Context Enrichment -description: "Use to rewrite thin node descriptions into natural prose that still makes what, why, and status clear, with context review and edge suggestions." +description: "Use to rewrite thin node descriptions into natural prose that still makes what, why, and status clear, with graph review and edge suggestions." --- # Node Context Enrichment -Use this when a node already exists but its description is thin, generic, or missing personal context. +Use this when a node already exists but its description is thin, generic, or missing useful graph framing. -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. +This skill should not silently rewrite and move on when framing is inferred. If the enrichment depends on interpretation, update the node and then explicitly invite the user to correct or refine that framing. ## Goal @@ -17,21 +17,19 @@ Replace weak descriptions with a single clean natural description that captures: 2. Why it is in Brad's graph 3. Status in Brad's workflow -Also review whether the node needs a clearer context assignment or obvious edge suggestions. +Also review whether the node needs obvious edge suggestions. ## Workflow -1. Load the node and inspect title, description, source, link, metadata, context, and nearby edges. -2. Search for adjacent context before rewriting: - - the node's primary context and its anchor node when present +1. Load the node and inspect title, description, source, link, metadata, and nearby edges. +2. Search for adjacent graph context before rewriting: - recently connected project or belief nodes - related nodes with overlapping titles, creators, or source themes -3. Infer the best available "why" from that context. +3. Infer the best available "why" from that graph 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 whether the current context is actually useful. Keep it, clear it, or suggest a better explicit context only when confidence is high. -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: +5. Suggest 1-3 high-signal edges when obvious. +6. Update the node once the description is strong enough to be useful. +7. 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 @@ -49,7 +47,7 @@ Every rewritten description must naturally cover: 2. Why - why Brad saved it - what project, belief, question, or theme it connects to - - if genuinely unknown, say that naturally without inventing context + - if genuinely unknown, say that naturally without inventing graph framing 3. Status - queued, in progress, processed, not yet reviewed, saved for later, etc. - if unknown, say naturally that it has not been reviewed yet @@ -68,14 +66,13 @@ Use batch enrichment when cleaning up many nodes with the same failure mode. 3. Enrich each node individually. Do not reuse boilerplate "why" text across unrelated nodes. 4. Return a compact summary of: - nodes updated - - contexts to review - edge suggestions not yet created ## Quality Bar - No filler phrases like `insightful for understanding`, `relevant to`, or `important for`. - No generic summaries that only restate the topic. -- No invented certainty. If context is weak, say so explicitly. +- No invented certainty. If graph evidence is weak, say so explicitly. - Prefer one compact 3-sentence description over bloated prose. ## Output Pattern @@ -83,6 +80,6 @@ Use batch enrichment when cleaning up many nodes with the same failure mode. For each node: - New description -- Context recommendation: keep / clear / set +- Framing note: what graph context influenced the rewrite, if any - Edge suggestions: source -> target with explicit explanation -- One short invitation for user feedback when contextual framing was inferred +- One short invitation for user feedback when framing was inferred diff --git a/src/config/skills/onboarding.md b/src/config/skills/onboarding.md index bb94bf1..23eecd0 100644 --- a/src/config/skills/onboarding.md +++ b/src/config/skills/onboarding.md @@ -7,7 +7,7 @@ description: "Use for new-user setup, empty or near-empty graphs, or major reset ## Your Job -Three things: help the user understand the basic structure of the system, help them start building useful graph data in it, and bootstrap the context capsule when durable cross-session facts become clear. +Three things: help the user understand the basic structure of the system, help them start building useful graph data in it, and turn early useful context into strong nodes and edges. Adapt to the user. @@ -30,28 +30,6 @@ Only bring up setup details if the user actually needs them: - **Anthropic** — mainly relevant for compatible runtime paths and local/dev setups. 4. If they are not ready to configure anything yet, keep onboarding. They can still learn the structure and add manual content. -## Capsule Bootstrap - -As part of onboarding, also bootstrap the context capsule on the user's behalf. - -When the user gives durable identity, preference, worldview, project, or agent-behavior information: - -1. Call `readSkill('context-capsule')` -2. Call `readCapsule` -3. When you have enough confidence, call `writeCapsule` - -Use the capsule for: -- preferred name -- agent name or greeting preference -- interaction style -- major projects -- major interests -- explicit worldview or belief signals -- what the user is using RA-H for - -Do not write the capsule for tentative, one-off, or low-confidence statements. -Keep it compressed and canonical. Replace stale instructions instead of preserving contradictory history. - ## Explain the System First Before asking anything, orient the user. Be direct, not salesy: @@ -60,7 +38,6 @@ Before asking anything, orient the user. Be direct, not salesy: Explain the structure in simple terms: -- **Contexts** — an optional soft organization layer. These are broad areas like health, job, life, or research. A node can belong to one context when it is explicit and useful, but context is not required. - **Nodes** — individual things. A project, idea, person, source, belief, decision, or topic. Each node must have a clear description of what it is and why it matters. - **Edges** — explicit connections between things. Each edge must clearly explain the relationship. - **Metadata and edges** — secondary structure that makes nodes more useful once the core artifact is clear. @@ -71,7 +48,7 @@ Then say: Also explain one practical thing early: -> "You do not need to perfectly design this up front. We want a few concrete nodes, clear contexts when they matter, and a few clean edges so the graph becomes useful quickly." +> "You do not need to perfectly design this up front. We want a few concrete nodes and a few clean edges so the graph becomes useful quickly." ## Interview Flow @@ -101,7 +78,6 @@ Keep it conversational. Use these buckets and adapt based on what the user gives Work these in naturally when they are relevant: - **First node creation** — explain that a node is one concrete thing worth keeping: a project, source, person, belief, decision, or idea. -- **Contexts** — explain that contexts are optional helpers, not required setup. If the user has none, that is fine. - **MCP connection** — if the user mentions Claude Code or external agents, offer a quick setup path and point them to the MCP docs/skill flow rather than reciting a giant config block immediately. - **What to do after setup** — once the graph has a few solid nodes, the next useful move is usually one of: - connect related nodes with explicit edges @@ -113,9 +89,9 @@ Work these in naturally when they are relevant: Do your best to build the graph as useful context emerges. - Add nodes when the user mentions concrete things worth keeping. -- Assign a context only when it is an obvious match to one of the user's existing contexts. Prefer leaving context empty over low-confidence guessing. - Surface likely edges when relationships are clear enough to explain well, but create them only after the user confirms. - Explain what you're adding in plain language so the user understands the structure as it develops. +- During normal conversation outside explicit onboarding capture, do not keep asking to save every useful statement. Only suggest a save when the context is unusually durable and valuable, and keep the prompt brief. When the graph is empty or nearly empty, bias toward creating a small, clean starter set rather than over-modeling everything. @@ -125,22 +101,16 @@ Before writing anything, call `readSkill('db-operations')` for full quality stan - Search before creating — avoid duplicates from day one - Every description must be concrete: what it IS and why it matters to them, not what it "explores" or "discusses" -- Contexts are optional and should only be used for an obvious existing match; otherwise leave them empty - Every edge needs an explicit explanation sentence, and agent-driven edge creation should only happen after confirmation ## Propose Before Writing When there is enough context, summarize the proposed structure before touching the database: -> "Here's what I'm planning to create: [list contexts with one-line rationale], [list starter nodes], [list key edges]. Does this look right? Anything to adjust?" +> "Here's what I'm planning to create: [list starter nodes], [list key edges]. Does this look right? Anything to adjust?" Write only after confirmation. -If onboarding also surfaced durable cross-session facts, include the capsule update in the proposal: - -> "I also plan to bootstrap your context capsule with your name, interaction preferences, and current priorities so future chats start grounded. Sound right?" -> "I also plan to bootstrap your context capsule with your name and interaction preferences so future chats start grounded. Sound right?" - For very early setup, include the first actionable next step too: > "After this starter pass, the best next move will be [add a source / connect these nodes / capture another active project]." diff --git a/src/services/agents/quickAdd.ts b/src/services/agents/quickAdd.ts index 4ab58e0..d67dccc 100644 --- a/src/services/agents/quickAdd.ts +++ b/src/services/agents/quickAdd.ts @@ -15,7 +15,6 @@ export interface QuickAddInput { rawInput: string; mode?: QuickAddMode; description?: string; - contextId?: number | null; } export interface QuickAddResult { @@ -200,7 +199,7 @@ function isCreateNodeResponse(value: unknown): value is CreateNodeResponse { return true; } -async function handleExtractionQuickAdd(type: ExtractionQuickAddType, url: string, task: string, contextId?: number | null): Promise { +async function handleExtractionQuickAdd(type: ExtractionQuickAddType, url: string, task: string): Promise { const { toolName, execute } = EXTRACTION_TOOL_MAP[type]; if (!execute) { throw new Error(`Tool ${toolName} does not have an execute function`); @@ -269,7 +268,6 @@ async function handleExtractionQuickAdd(type: ExtractionQuickAddType, url: strin refined_at: capturedAt, }, }, - context_id: contextId, }), }); @@ -296,7 +294,7 @@ async function handleExtractionQuickAdd(type: ExtractionQuickAddType, url: strin } } -async function handleNoteQuickAdd(rawInput: string, task: string, userDescription?: string, contextId?: number | null): Promise { +async function handleNoteQuickAdd(rawInput: string, task: string, userDescription?: string): Promise { const content = rawInput.trim(); if (!content) { throw new Error('Input is required to create a note'); @@ -307,7 +305,6 @@ async function handleNoteQuickAdd(rawInput: string, task: string, userDescriptio const nodePayload: Record = { title, source: content, - context_id: contextId, metadata: { type: 'note', state: 'not_processed', @@ -357,7 +354,7 @@ async function handleNoteQuickAdd(rawInput: string, task: string, userDescriptio }); } -async function handleChatTranscriptQuickAdd(rawInput: string, task: string, contextId?: number | null): Promise { +async function handleChatTranscriptQuickAdd(rawInput: string, task: string): Promise { const transcript = rawInput.trim(); if (!transcript) { throw new Error('Input is required to import a chat transcript'); @@ -424,7 +421,6 @@ async function handleChatTranscriptQuickAdd(rawInput: string, task: string, cont title, description: nodeDescription, source: transcript, - context_id: contextId, metadata, }), }); @@ -451,7 +447,7 @@ async function handleChatTranscriptQuickAdd(rawInput: string, task: string, cont }); } -export async function enqueueQuickAdd({ rawInput, mode, description, contextId }: QuickAddInput): Promise { +export async function enqueueQuickAdd({ rawInput, mode, description }: QuickAddInput): Promise { const inputType = detectInputType(rawInput, mode); const task = buildTaskPrompt(inputType, rawInput); const id = `qa_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`; @@ -468,11 +464,11 @@ export async function enqueueQuickAdd({ rawInput, mode, description, contextId } try { let summary: string; if (inputType === 'note') { - summary = await handleNoteQuickAdd(rawInput, task, description, contextId); + summary = await handleNoteQuickAdd(rawInput, task, description); } else if (inputType === 'chat') { - summary = await handleChatTranscriptQuickAdd(rawInput, task, contextId); + summary = await handleChatTranscriptQuickAdd(rawInput, task); } else { - summary = await handleExtractionQuickAdd(inputType as ExtractionQuickAddType, rawInput, task, contextId); + summary = await handleExtractionQuickAdd(inputType as ExtractionQuickAddType, rawInput, task); } console.log(`[QuickAdd] Completed: ${task}`); diff --git a/src/services/agents/toolResultUtils.ts b/src/services/agents/toolResultUtils.ts index 779ff53..48a0663 100644 --- a/src/services/agents/toolResultUtils.ts +++ b/src/services/agents/toolResultUtils.ts @@ -123,13 +123,6 @@ export function summarizeToolExecution(toolName: string, args: any, result: any) return 'No edges found.'; } - if (toolName === 'writeContext') { - const formatted = ensureString(result.data?.formatted_display); - if (formatted) { - return `Saved context as ${formatted}.`; - } - } - if (result.data?.formatted_display) { return ensureString(result.data.formatted_display) || fallback; } diff --git a/src/services/auth/internalAuth.ts b/src/services/auth/internalAuth.ts new file mode 100644 index 0000000..16db1a0 --- /dev/null +++ b/src/services/auth/internalAuth.ts @@ -0,0 +1,24 @@ +import type { NextRequest } from 'next/server'; + +export function extractBearerToken(headerValue: string | null | undefined): string | null { + if (!headerValue) return null; + const parts = headerValue.split(' '); + if (parts.length !== 2 || !/^Bearer$/i.test(parts[0] || '')) { + return null; + } + return parts[1] || null; +} + +export function getCurrentSupabaseToken(): string | null { + return null; +} + +export function getInternalAuthHeaders( + headers: Record = {} +): Record { + return { ...headers }; +} + +export function applyRequestSupabaseAuth(_request: NextRequest): () => void { + return () => {}; +} diff --git a/src/services/context/autoContext.ts b/src/services/context/autoContext.ts index 42e9d99..1675702 100644 --- a/src/services/context/autoContext.ts +++ b/src/services/context/autoContext.ts @@ -8,23 +8,6 @@ export interface AutoContextSummary { edgeCount: number; } -export interface ContextAnchorSummary { - id: number; - title: string; - description: string; - updatedAt: string; - edgeCount: number; -} - -export interface PromptContextSummary { - id: number; - name: string; - description: string | null; - icon: string | null; - count: number; - anchor: ContextAnchorSummary | null; -} - function truncate(value: string | null | undefined, maxChars: number): string { const trimmed = (value || '').trim(); if (trimmed.length <= maxChars) return trimmed; @@ -67,128 +50,11 @@ function fetchAutoContextRows(limit: number): AutoContextSummary[] { })); } -export function getHubNodes(limit = 5): AutoContextSummary[] { +export function getHubNodes(limit = 10): AutoContextSummary[] { return fetchAutoContextRows(limit); } -export function getContextSummaries(limit = 12): PromptContextSummary[] { - const db = getSQLiteClient(); - const rows = db.query<{ - id: number; - name: string; - description: string | null; - icon: string | null; - count: number; - anchor_id: number | null; - anchor_title: string | null; - anchor_description: string | null; - anchor_updated_at: string | null; - anchor_edge_count: number | null; - }>(` - WITH context_counts AS ( - 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 - ), - ranked_anchors AS ( - SELECT - c.id AS context_id, - n.id AS node_id, - n.title, - n.description, - n.updated_at, - COUNT(e.id) AS edge_count, - 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.icon, - cc.count, - ra.node_id AS anchor_id, - ra.title AS anchor_title, - ra.description AS anchor_description, - ra.updated_at AS anchor_updated_at, - ra.edge_count AS anchor_edge_count - 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 - LIMIT ? - `, [limit]).rows; - - return rows.map((row) => ({ - id: row.id, - name: row.name, - description: row.description ?? null, - icon: row.icon ?? null, - count: Number(row.count ?? 0), - anchor: row.anchor_id == null ? null : { - id: Number(row.anchor_id), - title: row.anchor_title || 'Untitled node', - description: row.anchor_description || '', - updatedAt: row.anchor_updated_at || '', - edgeCount: Number(row.anchor_edge_count ?? 0), - }, - })); -} - -export function buildContextsBlock(limit = 12): string | null { - const contexts = getContextSummaries(limit); - if (contexts.length === 0) { - return null; - } - - const lines: string[] = [ - 'User Contexts', - 'Contexts are optional soft hints. Use them when they are explicit and useful, but rely primarily on title, description, source, edges, and recency.', - '', - ]; - - contexts.forEach((context, index) => { - const description = truncate(context.description, 140) || 'No description.'; - const iconPrefix = context.icon ? `${context.icon} ` : ''; - lines.push(`${index + 1}. ${iconPrefix}${context.name} (${context.count} nodes)`); - lines.push(` ${description}`); - }); - - return lines.join('\n'); -} - -export function buildContextAnchorsBlock(limit = 12): string | null { - const contexts = getContextSummaries(limit).filter((context) => context.anchor); - if (contexts.length === 0) { - return null; - } - - const lines: string[] = [ - 'Context Anchors', - 'Each context anchor is the highest-edge node in that context. Use it only as an optional waypoint when that context is already clearly relevant.', - '', - ]; - - contexts.forEach((context, index) => { - const anchor = context.anchor!; - lines.push(`${index + 1}. ${context.name}: [NODE:${anchor.id}:"${anchor.title}"] (${anchor.edgeCount} edges)`); - if (anchor.description) { - lines.push(` ${truncate(anchor.description, 160)}`); - } - }); - - return lines.join('\n'); -} - -export function buildHubNodesBlock(limit = 5): string | null { +export function buildHubNodesBlock(limit = 10): string | null { const summaries = getHubNodes(limit); if (summaries.length === 0) { return null; @@ -210,10 +76,10 @@ export function buildHubNodesBlock(limit = 5): string | null { return lines.join('\n'); } -export function getAutoContextSummaries(limit = 5): AutoContextSummary[] { +export function getAutoContextSummaries(limit = 10): AutoContextSummary[] { return getHubNodes(limit); } -export function buildAutoContextBlock(limit = 5): string | null { - return buildContextsBlock(limit); +export function buildAutoContextBlock(limit = 10): string | null { + return buildHubNodesBlock(limit); } diff --git a/src/services/database/chunks.ts b/src/services/database/chunks.ts index 8244063..2fc34df 100644 --- a/src/services/database/chunks.ts +++ b/src/services/database/chunks.ts @@ -463,7 +463,6 @@ export class ChunkService { return result.rows; } catch (error) { - sqlite.disableFtsTable('chunks', 'chunks_fts query failed during chunk search', error); console.warn('[ChunkSearch] FTS chunk search failed, falling back to LIKE:', error); return []; } diff --git a/src/services/database/contextService.ts b/src/services/database/contextService.ts deleted file mode 100644 index f1dbef0..0000000 --- a/src/services/database/contextService.ts +++ /dev/null @@ -1,237 +0,0 @@ -import { getSQLiteClient } from './sqlite-client'; -import type { Context, ContextSummary, Node } from '@/types/database'; -import { nodeService } from './nodes'; - -type ContextRow = Context; -export const MAX_CONTEXTS_PER_ACCOUNT = 10; - -function normalizeContextName(name: string): string { - return name.trim().replace(/\s+/g, ' '); -} - -function assertContextName(name: unknown): string { - if (typeof name !== 'string') { - throw new Error('Context name is required.'); - } - const normalized = normalizeContextName(name); - if (!normalized) { - throw new Error('Context name is required.'); - } - return normalized; -} - -function assertContextDescription(description: unknown): string { - if (typeof description !== 'string') { - throw new Error('Context description is required.'); - } - const normalized = description.trim(); - if (!normalized) { - throw new Error('Context description is required.'); - } - return normalized; -} - -function mapContextRow(row: ContextRow): Context { - return { - id: Number(row.id), - name: row.name, - description: row.description ?? null, - icon: row.icon ?? null, - created_at: row.created_at, - updated_at: row.updated_at, - }; -} - -export class ContextService { - async listContexts(): Promise { - const sqlite = getSQLiteClient(); - const rows = sqlite.query(` - 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 - `).rows; - - return rows.map((row) => ({ - id: Number(row.id), - name: row.name, - description: row.description ?? null, - icon: row.icon ?? null, - count: Number(row.count ?? 0), - })); - } - - async getContextById(id: number): Promise { - const sqlite = getSQLiteClient(); - const row = sqlite.query(` - 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 - `, [id]).rows[0]; - - if (!row) return null; - - return { - id: Number(row.id), - name: row.name, - description: row.description ?? null, - icon: row.icon ?? null, - count: Number(row.count ?? 0), - }; - } - - async getContextByName(name: string): Promise { - const normalized = assertContextName(name); - const sqlite = getSQLiteClient(); - const row = sqlite.query(` - SELECT id, name, description, icon, created_at, updated_at - FROM contexts - WHERE LOWER(TRIM(name)) = LOWER(TRIM(?)) - LIMIT 1 - `, [normalized]).rows[0]; - - return row ? mapContextRow(row) : null; - } - - async createContext(input: { name: string; description: string; icon?: string | null }): Promise { - const name = assertContextName(input.name); - const description = assertContextDescription(input.description); - const icon = typeof input.icon === 'string' && input.icon.trim() ? input.icon.trim() : null; - const sqlite = getSQLiteClient(); - const now = new Date().toISOString(); - - const existing = await this.getContextByName(name); - if (existing) { - throw new Error(`Context "${name}" already exists.`); - } - - const contextCountRow = sqlite.query<{ count: number }>(` - SELECT COUNT(*) AS count - FROM contexts - `).rows[0]; - const existingCount = Number(contextCountRow?.count ?? 0); - if (existingCount >= MAX_CONTEXTS_PER_ACCOUNT) { - throw new Error(`Context limit reached. Maximum ${MAX_CONTEXTS_PER_ACCOUNT} contexts are allowed per account.`); - } - - const result = sqlite.prepare(` - INSERT INTO contexts (name, description, icon, created_at, updated_at) - VALUES (?, ?, ?, ?, ?) - `).run(name, description, icon, now, now); - - const created = await this.getContextById(Number(result.lastInsertRowid)); - if (!created) { - throw new Error('Failed to create context.'); - } - - return { - ...created, - created_at: now, - updated_at: now, - }; - } - - async updateContext(input: { id: number; name?: string; description?: string; icon?: string | null }): Promise { - const sqlite = getSQLiteClient(); - const existing = sqlite.query(` - SELECT id, name, description, icon, created_at, updated_at - FROM contexts - WHERE id = ? - `, [input.id]).rows[0]; - - if (!existing) { - throw new Error(`Context ${input.id} not found.`); - } - - const nextName = input.name !== undefined ? assertContextName(input.name) : existing.name; - const nextDescription = input.description !== undefined - ? assertContextDescription(input.description) - : existing.description; - const nextIcon = input.icon !== undefined - ? (typeof input.icon === 'string' && input.icon.trim() ? input.icon.trim() : null) - : existing.icon; - - const conflicting = sqlite.query<{ id: number }>(` - SELECT id FROM contexts - WHERE LOWER(TRIM(name)) = LOWER(TRIM(?)) - AND id != ? - LIMIT 1 - `, [nextName, input.id]).rows[0]; - - if (conflicting) { - throw new Error(`Context "${nextName}" already exists.`); - } - - const now = new Date().toISOString(); - sqlite.prepare(` - UPDATE contexts - SET name = ?, description = ?, icon = ?, updated_at = ? - WHERE id = ? - `).run(nextName, nextDescription, nextIcon, now, input.id); - - return { - id: input.id, - name: nextName, - description: nextDescription ?? null, - icon: nextIcon ?? null, - created_at: existing.created_at, - updated_at: now, - }; - } - - async getNodesForContext(id: number): Promise { - return nodeService.getNodes({ contextId: id, limit: 500 }); - } - - async resolveContextId(input: { context_id?: number | null; context_name?: string | null }): Promise { - const hasContextId = - Object.prototype.hasOwnProperty.call(input, 'context_id') && - input.context_id !== undefined; - 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: Context | null = null; - if (hasContextId) { - if (typeof input.context_id !== 'number' || !Number.isInteger(input.context_id) || input.context_id <= 0) { - throw new Error('context_id must be a positive integer or null.'); - } - const byId = await this.getContextById(input.context_id); - if (!byId) { - throw new Error(`Context ${input.context_id} not found.`); - } - resolvedById = { - ...byId, - created_at: '', - updated_at: '', - }; - } - - if (hasContextName) { - const byName = await this.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; - } - - return resolvedById?.id; - } -} - -export const contextService = new ContextService(); diff --git a/src/services/database/index.ts b/src/services/database/index.ts index ef231f2..6466dc1 100644 --- a/src/services/database/index.ts +++ b/src/services/database/index.ts @@ -4,7 +4,6 @@ import type { DatabaseIntegrityReport } from './sqlite-client'; export { nodeService, NodeService } from './nodes'; export { chunkService, ChunkService } from './chunks'; export { edgeService, EdgeService } from './edges'; -export { contextService, ContextService } from './contextService'; // export { HelperService } from './helpers'; // Removed - migrated to JSON-based service // Types diff --git a/src/services/database/nodes.ts b/src/services/database/nodes.ts index 8a01c1e..861d222 100644 --- a/src/services/database/nodes.ts +++ b/src/services/database/nodes.ts @@ -2,12 +2,10 @@ import { getSQLiteClient } from './sqlite-client'; import { Node, NodeFilters } from '@/types/database'; import { eventBroadcaster } from '../events'; import { EmbeddingService } from '@/services/embeddings'; -import { scoreNodeSearchMatch } from './searchRanking'; +import { getHighSignalSearchTerms, scoreNodeSearchMatch } from './searchRanking'; import { buildCanonicalNodeMetadata, mergeNodeMetadata } from '@/services/nodes/metadata'; -type NodeRow = Node & { - context_json: string | null; -}; +type NodeRow = Node; type NodeSearchRow = NodeRow & { rank?: number; similarity?: number }; function sanitizeFtsQuery(input: string): string { @@ -21,35 +19,7 @@ function sanitizeFtsQuery(input: string): string { } function extractRelaxedSearchTerms(query: string): string[] { - const stopWords = new Set([ - 'a', 'an', 'and', 'are', 'as', 'at', 'be', 'by', 'can', 'do', 'find', - 'for', 'from', 'hello', 'i', 'in', 'is', 'it', 'me', 'my', 'of', 'on', - 'or', 'recent', 'stuff', 'term', 'that', 'the', 'this', 'to', 'with', 'you' - ]); - - const rawTerms = query - .toLowerCase() - .replace(/[^a-z0-9\s]+/g, ' ') - .split(/\s+/) - .map(term => term.trim()) - .filter(Boolean); - - const expanded = new Set(); - - for (const term of rawTerms) { - if (!stopWords.has(term) && term.length >= 3) { - expanded.add(term); - } - - const alphaParts = term.replace(/\d+/g, ' ').split(/\s+/).filter(Boolean); - for (const part of alphaParts) { - if (!stopWords.has(part) && part.length >= 3) { - expanded.add(part); - } - } - } - - return Array.from(expanded).slice(0, 8); + return getHighSignalSearchTerms(query).slice(0, 8); } function reciprocalRankFuse( @@ -90,7 +60,6 @@ export class NodeService { eventAfter, eventBefore, chunkStatus, - contextId, } = filters; if (search?.trim()) { @@ -112,8 +81,6 @@ export class NodeService { if (eventAfter) { query += ` AND n.event_date >= ?`; params.push(eventAfter); } if (eventBefore) { query += ` AND n.event_date < ?`; params.push(eventBefore); } if (chunkStatus) { query += ` AND n.chunk_status = ?`; params.push(chunkStatus); } - if (contextId !== undefined) { query += ` AND n.context_id = ?`; params.push(contextId); } - const result = sqlite.query<{ total: number }>(query, params); return result.rows[0]?.total ?? 0; } @@ -131,7 +98,6 @@ export class NodeService { eventAfter, eventBefore, chunkStatus, - contextId, } = filters; if (search?.trim()) { @@ -143,14 +109,9 @@ export class NodeService { let query = ` SELECT n.id, n.title, n.description, n.source, n.link, n.event_date, n.metadata, n.chunk_status, n.embedding_updated_at, n.embedding_text, - n.created_at, n.updated_at, n.context_id, - 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, + n.created_at, n.updated_at, (SELECT COUNT(*) FROM edges WHERE from_node_id = n.id OR to_node_id = n.id) as edge_count FROM nodes n - LEFT JOIN contexts c ON c.id = n.context_id WHERE 1=1 `; const params: any[] = []; @@ -182,11 +143,6 @@ export class NodeService { query += ` AND n.chunk_status = ?`; params.push(chunkStatus); } - if (contextId !== undefined) { - query += ` AND n.context_id = ?`; - params.push(contextId); - } - // Sorting logic if (search) { // For search queries, prioritize by relevance: exact title → starts with → contains in title → description → source @@ -243,13 +199,8 @@ export class NodeService { const query = ` SELECT n.id, n.title, n.description, n.source, n.link, n.event_date, n.metadata, n.chunk_status, n.embedding_updated_at, n.embedding_text, - n.created_at, n.updated_at, n.context_id, - 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 + n.created_at, n.updated_at FROM nodes n - LEFT JOIN contexts c ON c.id = n.context_id WHERE n.id = ? `; const result = sqlite.query(query, [id]); @@ -275,7 +226,6 @@ export class NodeService { event_date, chunk_status, metadata = {}, - context_id, } = nodeData; const canonicalMetadata = buildCanonicalNodeMetadata({ metadata }); const now = new Date().toISOString(); @@ -284,8 +234,8 @@ export class NodeService { const nodeId = sqlite.transaction(() => { // Insert node using prepare/run for lastInsertRowid access const nodeResult = sqlite.prepare(` - INSERT INTO nodes (title, description, source, link, event_date, metadata, chunk_status, context_id, created_at, updated_at) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + INSERT INTO nodes (title, description, source, link, event_date, metadata, chunk_status, created_at, updated_at) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?) `).run( title, description ?? null, @@ -294,7 +244,6 @@ export class NodeService { event_date ?? null, JSON.stringify(canonicalMetadata), chunk_status ?? null, - context_id ?? null, now, now ); @@ -353,10 +302,6 @@ export class NodeService { if (source !== undefined) { setFields.push('source = ?'); params.push(source); } if (link !== undefined) { setFields.push('link = ?'); params.push(link); } if (event_date !== undefined) { setFields.push('event_date = ?'); params.push(event_date); } - if (Object.prototype.hasOwnProperty.call(updates, 'context_id')) { - setFields.push('context_id = ?'); - params.push(updates.context_id ?? null); - } if (Object.prototype.hasOwnProperty.call(updates, 'chunk_status')) { setFields.push('chunk_status = ?'); params.push(updates.chunk_status ?? null); @@ -419,11 +364,9 @@ export class NodeService { } private mapNodeRow(row: NodeRow): Node { - const { context_json, ...baseRow } = row; return { - ...baseRow, - metadata: baseRow.metadata ? (typeof baseRow.metadata === 'string' ? JSON.parse(baseRow.metadata) : baseRow.metadata) : null, - context: context_json ? JSON.parse(context_json) : null, + ...row, + metadata: row.metadata ? (typeof row.metadata === 'string' ? JSON.parse(row.metadata) : row.metadata) : null, }; } @@ -433,7 +376,6 @@ export class NodeService { createdBefore, eventAfter, eventBefore, - contextId, } = filters; const clauses: string[] = []; @@ -443,8 +385,6 @@ export class NodeService { if (createdBefore) { clauses.push(`${alias}.created_at < ?`); params.push(createdBefore); } if (eventAfter) { clauses.push(`${alias}.event_date >= ?`); params.push(eventAfter); } if (eventBefore) { clauses.push(`${alias}.event_date < ?`); params.push(eventBefore); } - if (contextId !== undefined) { clauses.push(`${alias}.context_id = ?`); params.push(contextId); } - return { clauses, params }; } @@ -517,7 +457,8 @@ export class NodeService { return Number(result.rows[0]?.total ?? 0); } catch (error) { - sqlite.disableFtsTable('nodes', 'nodes_fts query failed during count search', error); + sqlite.getIntegrityReport(true); + console.warn('[NodeSearch] FTS count failed, falling back to LIKE count:', error); } } @@ -546,6 +487,7 @@ export class NodeService { ): NodeSearchRow[] { const ftsQuery = sanitizeFtsQuery(search); if (!ftsQuery) return []; + if (!sqlite.canUseFtsTable('nodes')) return []; const { clauses, params } = this.buildNodeFilterClauses(filters); @@ -561,15 +503,10 @@ export class NodeService { ) SELECT n.id, n.title, n.description, n.source, n.link, n.event_date, n.metadata, n.chunk_status, n.embedding_updated_at, n.embedding_text, - n.created_at, n.updated_at, n.context_id, - 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, + n.created_at, n.updated_at, fm.rank FROM fts_matches fm JOIN nodes n ON n.id = fm.rowid - LEFT JOIN contexts c ON c.id = n.context_id ${whereClauses} ORDER BY fm.rank LIMIT ? @@ -577,7 +514,8 @@ export class NodeService { return result.rows; } catch (error) { - sqlite.disableFtsTable('nodes', 'nodes_fts query failed during node search', error); + sqlite.getIntegrityReport(true); + console.warn('[NodeSearch] FTS search failed, falling back to LIKE:', error); return []; } } @@ -593,13 +531,8 @@ export class NodeService { let query = ` SELECT n.id, n.title, n.description, n.source, n.link, n.event_date, n.metadata, n.chunk_status, n.embedding_updated_at, n.embedding_text, - n.created_at, n.updated_at, n.context_id, - 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 + n.created_at, n.updated_at FROM nodes n - LEFT JOIN contexts c ON c.id = n.context_id WHERE 1=1 `; const queryParams = [...params]; @@ -641,13 +574,8 @@ export class NodeService { let query = ` SELECT n.id, n.title, n.description, n.source, n.link, n.event_date, n.metadata, n.chunk_status, n.embedding_updated_at, n.embedding_text, - n.created_at, n.updated_at, n.context_id, - 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 + n.created_at, n.updated_at FROM nodes n - LEFT JOIN contexts c ON c.id = n.context_id WHERE 1=1 `; const queryParams = [...params]; @@ -718,15 +646,10 @@ export class NodeService { ) SELECT n.id, n.title, n.description, n.source, n.link, n.event_date, n.metadata, n.chunk_status, n.embedding_updated_at, n.embedding_text, - n.created_at, n.updated_at, n.context_id, - 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, + n.created_at, n.updated_at, (1.0 / (1.0 + vm.distance)) AS similarity FROM vector_matches vm JOIN nodes n ON n.id = vm.node_id - LEFT JOIN contexts c ON c.id = n.context_id ${whereClauses} ORDER BY vm.distance LIMIT ? @@ -769,6 +692,14 @@ export class NodeService { return updatedNodes; } + async getAllDimensions(): Promise { + return []; + } + + async getDimensionStats(): Promise<{dimension: string, count: number}[]> { + return []; + } + } // Export singleton instance diff --git a/src/services/database/sqlite-client.ts b/src/services/database/sqlite-client.ts index 40e1581..4cfea51 100644 --- a/src/services/database/sqlite-client.ts +++ b/src/services/database/sqlite-client.ts @@ -1,12 +1,7 @@ import Database from 'better-sqlite3'; +import fs from 'fs'; +import path from 'path'; import { DatabaseError } from '@/types/database'; -import { - ensureDatabaseDirectory, - getDatabasePath, - getVecExtensionPath, - loadVecExtension, - type VectorCapability, -} from './sqlite-runtime'; export interface SQLiteConfig { dbPath: string; @@ -52,35 +47,28 @@ class SQLiteClient { private db: Database.Database; private config: SQLiteConfig; private readonly readOnly: boolean; - private readonly vectorCapability: VectorCapability; private integrityReport: DatabaseIntegrityReport | null = null; - private readonly ftsUsable: Record = { - nodes: true, - chunks: true, - }; - private readonly ftsDisabledReason: Record = { - nodes: null, - chunks: null, - }; private constructor() { this.config = this.getSQLiteConfig(); this.readOnly = process.env.SQLITE_READONLY === 'true'; // Initialize database connection - if (!this.readOnly) { - ensureDatabaseDirectory(this.config.dbPath); + const dbDirectory = path.dirname(this.config.dbPath); + if (!this.readOnly && !fs.existsSync(dbDirectory)) { + fs.mkdirSync(dbDirectory, { recursive: true }); } this.db = this.readOnly ? new Database(this.config.dbPath, { readonly: true, fileMustExist: true }) : new Database(this.config.dbPath); // Load sqlite-vec extension - this.vectorCapability = loadVecExtension(this.db, this.config.vecExtensionPath); - if (this.vectorCapability.available) { + try { + this.db.loadExtension(this.config.vecExtensionPath); console.log('SQLite vector extension loaded successfully'); - } else { - console.warn(`Warning: ${this.vectorCapability.reason}`); + } catch (error) { + // Do not fail hard — allow the app to run without vector features + console.error('Warning: Failed to load vector extension:', error); } // Configure SQLite settings @@ -98,20 +86,22 @@ class SQLiteClient { this.db.pragma('temp_store = memory'); this.db.pragma('busy_timeout = 5000'); - this.ensureCoreSchema(); - // Ensure vector virtual tables are present and healthy - if (this.vectorCapability.available) { + this.withStartupWriteLock(() => { + this.ensureCoreSchema(); + this.recoverInterruptedContextMigration(); + // Ensure vector virtual tables are present and healthy this.ensureVectorTables(); this.healVectorTablesIfCorrupt(); - } - // Ensure logging schema (rename memory->logs if needed, create triggers/views) - this.ensureLoggingAndMemorySchema(); - this.ensureContextsSchema(); + // Ensure logging schema (rename memory->logs if needed, create triggers/views) + this.ensureLoggingAndMemorySchemaLocked(); + }); this.integrityReport = this.inspectIntegrity(); if (this.integrityReport.state === 'healthy') { - this.ensureFtsTables(); + this.withStartupWriteLock(() => { + this.ensureFtsTables(); + }); this.integrityReport = this.inspectIntegrity(); } else { console.warn( @@ -124,9 +114,17 @@ class SQLiteClient { } private getSQLiteConfig(): SQLiteConfig { + const dbPath = process.env.SQLITE_DB_PATH || path.join( + process.env.HOME || '~', + 'Library/Application Support/RA-H/db/rah.sqlite' + ); + + const vecExtensionPath = process.env.SQLITE_VEC_EXTENSION_PATH || + './vendor/sqlite-extensions/vec0.dylib'; + return { - dbPath: getDatabasePath(), - vecExtensionPath: getVecExtensionPath(), + dbPath, + vecExtensionPath }; } @@ -182,9 +180,7 @@ class SQLiteClient { } as DatabaseError; } // Proactively validate/repair vec vtables before any write transaction - if (this.vectorCapability.available) { - this.healVectorTablesIfCorrupt(); - } + this.healVectorTablesIfCorrupt(); const txn = this.db.transaction(callback); try { return txn(); @@ -205,19 +201,13 @@ class SQLiteClient { } public async checkVectorExtension(): Promise { - return this.vectorCapability.available; - } - - public getVectorCapability(): VectorCapability { - return this.vectorCapability; - } - - public isNodesFtsUsable(): boolean { - return this.canUseFtsTable('nodes'); - } - - public disableNodesFts(reason: string, error?: unknown): void { - this.disableFtsTable('nodes', reason, error); + try { + const result = this.query('SELECT vec_version() as version'); + return result.rows.length > 0; + } catch (error) { + console.error('Vector extension check failed:', error); + return false; + } } public async checkTables(): Promise { @@ -233,10 +223,6 @@ class SQLiteClient { } public ensureVectorExtensions(): void { - if (!this.vectorCapability.available) { - return; - } - try { // Test for vec_nodes and vec_chunks; create them if missing const hasVecNodes = this.db.prepare("SELECT name FROM sqlite_master WHERE type='table' AND name=?").get('vec_nodes'); @@ -265,33 +251,28 @@ class SQLiteClient { } } - public canUseFtsTable(tableName: FtsSurfaceName): boolean { - return this.ftsUsable[tableName] && this.getIntegrityReport().ftsTables[tableName]; - } - - public disableFtsTable(tableName: FtsSurfaceName, reason: string, error?: unknown): void { - this.ftsUsable[tableName] = false; - if (this.ftsDisabledReason[tableName] === reason) { - return; - } - this.ftsDisabledReason[tableName] = reason; - - if (error && !this.isSqliteCorruptError(error)) { - console.warn(`[SQLite] ${tableName}_fts disabled: ${reason}`, error); - return; - } - - console.warn(`[SQLite] ${tableName}_fts disabled: ${reason}. Falling back to non-FTS behavior for this database session.`); - } - private ensureVectorTables(): void { - if (this.readOnly || !this.vectorCapability.available) { + if (this.readOnly) { return; } // Wrapper to keep existing public API stable this.ensureVectorExtensions(); } + private withStartupWriteLock(callback: () => T): T { + this.db.exec('BEGIN IMMEDIATE'); + try { + const result = callback(); + this.db.exec('COMMIT'); + return result; + } catch (error) { + try { + this.db.exec('ROLLBACK'); + } catch {} + throw error; + } + } + private ensureCoreSchema(): void { if (this.readOnly) { return; @@ -311,23 +292,21 @@ class SQLiteClient { embedding BLOB, embedding_updated_at TEXT, embedding_text TEXT, - chunk_status TEXT DEFAULT 'not_chunked', - context_id INTEGER, - FOREIGN KEY (context_id) REFERENCES contexts(id) ON DELETE SET NULL + chunk_status TEXT DEFAULT 'not_chunked' ); - CREATE TABLE IF NOT EXISTS contexts ( + CREATE TABLE IF NOT EXISTS edges ( id INTEGER PRIMARY KEY, - name TEXT NOT NULL, - description TEXT NOT NULL, - icon TEXT, - created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, - updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP + from_node_id INTEGER NOT NULL, + to_node_id INTEGER NOT NULL, + source TEXT, + created_at TEXT DEFAULT CURRENT_TIMESTAMP, + context TEXT, + explanation TEXT, + FOREIGN KEY (from_node_id) REFERENCES nodes(id) ON DELETE CASCADE, + FOREIGN KEY (to_node_id) REFERENCES nodes(id) ON DELETE CASCADE ); - CREATE UNIQUE INDEX IF NOT EXISTS idx_contexts_name_normalized - ON contexts(LOWER(TRIM(name))); - CREATE TABLE IF NOT EXISTS chunks ( id INTEGER PRIMARY KEY, node_id INTEGER NOT NULL, @@ -355,218 +334,138 @@ class SQLiteClient { FOREIGN KEY (focused_node_id) REFERENCES nodes(id) ON DELETE SET NULL ); - CREATE INDEX IF NOT EXISTS idx_nodes_updated_at ON nodes(updated_at DESC); - CREATE INDEX IF NOT EXISTS idx_chunks_node_id ON chunks(node_id); - `); - - this.ensureEdgesTableSchema(); - } - - private ensureEdgesTableSchema(): void { - const hasEdgesTable = this.db - .prepare("SELECT 1 FROM sqlite_master WHERE type='table' AND name='edges'") - .get(); - - if (!hasEdgesTable) { - this.db.exec(` - CREATE TABLE edges ( - id INTEGER PRIMARY KEY, - from_node_id INTEGER NOT NULL, - to_node_id INTEGER NOT NULL, - source TEXT, - created_at TEXT DEFAULT CURRENT_TIMESTAMP, - context TEXT, - explanation TEXT, - FOREIGN KEY (from_node_id) REFERENCES nodes(id) ON DELETE CASCADE, - FOREIGN KEY (to_node_id) REFERENCES nodes(id) ON DELETE CASCADE - ); - `); - } else { - const edgeCols = this.db.prepare('PRAGMA table_info(edges)').all() as Array<{ name: string }>; - const edgeColNames = new Set(edgeCols.map((col) => col.name)); - const needsLegacyRewrite = - !edgeColNames.has('from_node_id') || - !edgeColNames.has('to_node_id') || - !edgeColNames.has('source') || - !edgeColNames.has('created_at') || - !edgeColNames.has('context') || - edgeColNames.has('from_id') || - edgeColNames.has('to_id') || - edgeColNames.has('description') || - edgeColNames.has('updated_at'); - - if (needsLegacyRewrite) { - this.rebuildLegacyEdgesTable(edgeColNames); - } - } - - this.db.exec(` CREATE INDEX IF NOT EXISTS idx_edges_from ON edges(from_node_id); CREATE INDEX IF NOT EXISTS idx_edges_to ON edges(to_node_id); + CREATE INDEX IF NOT EXISTS idx_nodes_updated_at ON nodes(updated_at DESC); + CREATE INDEX IF NOT EXISTS idx_chunks_node_id ON chunks(node_id); + CREATE INDEX IF NOT EXISTS idx_chats_thread ON chats(thread_id); `); } - private rebuildLegacyEdgesTable(edgeColNames: Set): void { - const fromExpr = edgeColNames.has('from_node_id') - ? 'from_node_id' - : edgeColNames.has('from_id') - ? 'from_id' - : 'NULL'; - const toExpr = edgeColNames.has('to_node_id') - ? 'to_node_id' - : edgeColNames.has('to_id') - ? 'to_id' - : 'NULL'; - const sourceExpr = edgeColNames.has('source') ? 'source' : "'legacy'"; - const createdAtExpr = edgeColNames.has('created_at') ? 'created_at' : 'CURRENT_TIMESTAMP'; - const contextExpr = edgeColNames.has('context') ? 'context' : 'NULL'; - const explanationExpr = edgeColNames.has('explanation') - ? 'explanation' - : edgeColNames.has('description') - ? 'description' - : edgeColNames.has('context') - ? "CASE WHEN json_valid(context) THEN json_extract(context, '$.explanation') ELSE NULL END" - : 'NULL'; + private recoverInterruptedContextMigration(): void { + const hasTempNodes = this.db.prepare( + "SELECT 1 FROM sqlite_master WHERE type='table' AND name='nodes__without_context'" + ).get(); - console.log('Migrating legacy edges table to canonical schema'); + if (!hasTempNodes) { + return; + } - let flippedForeignKeys = false; - try { - this.db.exec('PRAGMA foreign_keys=OFF;'); - flippedForeignKeys = true; - } catch {} + const tempNodeCount = Number( + this.db.prepare('SELECT COUNT(*) FROM nodes__without_context').pluck().get() ?? 0 + ); + const hasNodesTable = this.db.prepare( + "SELECT 1 FROM sqlite_master WHERE type='table' AND name='nodes'" + ).get(); - try { - this.db.exec('BEGIN TRANSACTION;'); + if (!hasNodesTable) { this.db.exec(` - DROP INDEX IF EXISTS idx_edges_from; - DROP INDEX IF EXISTS idx_edges_to; - ALTER TABLE edges RENAME TO edges_legacy_migration; - CREATE TABLE edges ( - id INTEGER PRIMARY KEY, - from_node_id INTEGER NOT NULL, - to_node_id INTEGER NOT NULL, - source TEXT, - created_at TEXT DEFAULT CURRENT_TIMESTAMP, - context TEXT, - explanation TEXT, - FOREIGN KEY (from_node_id) REFERENCES nodes(id) ON DELETE CASCADE, - FOREIGN KEY (to_node_id) REFERENCES nodes(id) ON DELETE CASCADE - ); - INSERT INTO edges (id, from_node_id, to_node_id, source, created_at, context, explanation) - SELECT - id, - ${fromExpr}, - ${toExpr}, - ${sourceExpr}, - COALESCE(${createdAtExpr}, CURRENT_TIMESTAMP), - ${contextExpr}, - ${explanationExpr} - FROM edges_legacy_migration - WHERE ${fromExpr} IS NOT NULL - AND ${toExpr} IS NOT NULL; - DROP TABLE edges_legacy_migration; - COMMIT; + ALTER TABLE nodes__without_context RENAME TO nodes; + CREATE INDEX IF NOT EXISTS idx_nodes_updated_at ON nodes(updated_at DESC); `); - } catch (error) { - try { - this.db.exec('ROLLBACK;'); - } catch {} - throw error; - } finally { - if (flippedForeignKeys) { - try { - this.db.exec('PRAGMA foreign_keys=ON;'); - } catch {} + console.warn( + `[SQLiteMigration] Restored missing nodes table from nodes__without_context (${tempNodeCount} rows).` + ); + return; + } + + const nodeColumns = this.db.prepare('PRAGMA table_info(nodes)').all() as Array<{ name: string }>; + const hasContextId = nodeColumns.some((column) => column.name === 'context_id'); + const liveNodeCount = Number(this.db.prepare('SELECT COUNT(*) FROM nodes').pluck().get() ?? 0); + + if (!hasContextId && liveNodeCount === 0 && tempNodeCount > 0) { + this.db.exec(` + INSERT INTO nodes ( + id, title, description, source, link, event_date, created_at, updated_at, + metadata, embedding, embedding_updated_at, embedding_text, chunk_status + ) + SELECT + id, title, description, source, link, event_date, created_at, updated_at, + metadata, embedding, embedding_updated_at, embedding_text, chunk_status + FROM nodes__without_context; + `); + + const restoredNodeCount = Number(this.db.prepare('SELECT COUNT(*) FROM nodes').pluck().get() ?? 0); + if (restoredNodeCount === tempNodeCount) { + this.db.exec('DROP TABLE nodes__without_context;'); } + + console.warn( + `[SQLiteMigration] Recovered ${restoredNodeCount} nodes from interrupted context-removal migration.` + ); } } - private rebuildLegacyChatsTable(chatColNames: Set): void { - const chatTypeExpr = chatColNames.has('chat_type') ? 'chat_type' : 'NULL'; - const helperNameExpr = chatColNames.has('helper_name') - ? 'helper_name' - : chatColNames.has('title') - ? 'title' - : 'NULL'; - const agentTypeExpr = chatColNames.has('agent_type') - ? "COALESCE(agent_type, 'orchestrator')" - : "'orchestrator'"; - const delegationIdExpr = chatColNames.has('delegation_id') ? 'delegation_id' : 'NULL'; - const userMessageExpr = chatColNames.has('user_message') ? 'user_message' : 'NULL'; - const assistantMessageExpr = chatColNames.has('assistant_message') ? 'assistant_message' : 'NULL'; - const threadIdExpr = chatColNames.has('thread_id') ? 'thread_id' : 'NULL'; - const focusedNodeIdExpr = chatColNames.has('focused_node_id') ? 'focused_node_id' : 'NULL'; - const createdAtExpr = chatColNames.has('created_at') ? 'created_at' : 'CURRENT_TIMESTAMP'; - const metadataExpr = chatColNames.has('metadata') ? 'metadata' : 'NULL'; + private dropContextsSchema(): void { + const nodeColumns = this.db.prepare('PRAGMA table_info(nodes)').all() as Array<{ name: string }>; + const hasContextId = nodeColumns.some((column) => column.name === 'context_id'); - console.log('Migrating legacy chats table to canonical schema'); + this.db.exec('DROP INDEX IF EXISTS idx_nodes_context_id;'); + this.db.exec('DROP INDEX IF EXISTS idx_contexts_name_normalized;'); - let flippedForeignKeys = false; - try { - this.db.exec('PRAGMA foreign_keys=OFF;'); - flippedForeignKeys = true; - } catch {} - - try { - this.db.exec('BEGIN TRANSACTION;'); - this.db.exec(` - DROP INDEX IF EXISTS idx_chats_thread; - ALTER TABLE chats RENAME TO chats_legacy_cleanup; - CREATE TABLE chats ( - id INTEGER PRIMARY KEY, - chat_type TEXT, - helper_name TEXT, - agent_type TEXT DEFAULT 'orchestrator', - delegation_id INTEGER, - user_message TEXT, - assistant_message TEXT, - thread_id TEXT, - focused_node_id INTEGER, - created_at TEXT DEFAULT CURRENT_TIMESTAMP, - metadata TEXT, - FOREIGN KEY (focused_node_id) REFERENCES nodes(id) ON DELETE SET NULL - ); - INSERT INTO chats ( - id, chat_type, helper_name, agent_type, delegation_id, - user_message, assistant_message, thread_id, focused_node_id, - created_at, metadata - ) - SELECT - id, - ${chatTypeExpr}, - ${helperNameExpr}, - ${agentTypeExpr}, - ${delegationIdExpr}, - ${userMessageExpr}, - ${assistantMessageExpr}, - ${threadIdExpr}, - ${focusedNodeIdExpr}, - COALESCE(${createdAtExpr}, CURRENT_TIMESTAMP), - ${metadataExpr} - FROM chats_legacy_cleanup; - DROP TABLE chats_legacy_cleanup; - COMMIT; - `); - } catch (error) { + if (hasContextId) { try { - this.db.exec('ROLLBACK;'); - } catch {} - throw error; - } finally { - if (flippedForeignKeys) { + this.db.exec('DROP TABLE IF EXISTS nodes__without_context;'); + this.db.exec('PRAGMA foreign_keys = OFF;'); + this.db.exec(` + CREATE TABLE nodes__without_context ( + id INTEGER PRIMARY KEY, + title TEXT, + description TEXT, + source TEXT, + link TEXT, + event_date TEXT, + created_at TEXT DEFAULT CURRENT_TIMESTAMP, + updated_at TEXT DEFAULT CURRENT_TIMESTAMP, + metadata TEXT, + embedding BLOB, + embedding_updated_at TEXT, + embedding_text TEXT, + chunk_status TEXT DEFAULT 'not_chunked' + ); + + INSERT INTO nodes__without_context ( + id, title, description, source, link, event_date, created_at, updated_at, + metadata, embedding, embedding_updated_at, embedding_text, chunk_status + ) + SELECT + id, title, description, source, link, event_date, created_at, updated_at, + metadata, embedding, embedding_updated_at, embedding_text, chunk_status + FROM nodes; + + DROP TABLE nodes; + ALTER TABLE nodes__without_context RENAME TO nodes; + CREATE INDEX IF NOT EXISTS idx_nodes_updated_at ON nodes(updated_at DESC); + `); + } catch (error) { try { - this.db.exec('PRAGMA foreign_keys=ON;'); + this.db.exec('PRAGMA foreign_keys = ON;'); } catch {} + + if (error instanceof Error && /SQLITE_LOCKED|database table is locked/i.test(error.message)) { + console.warn('[SQLiteMigration] Skipping context-column removal in this process because another startup process holds the schema lock.'); + return; + } + + throw error; } + + this.db.exec('PRAGMA foreign_keys = ON;'); } + + this.db.exec('DROP TABLE IF EXISTS contexts;'); } private ensureLoggingAndMemorySchema(): void { if (this.readOnly) { return; } + this.withStartupWriteLock(() => this.ensureLoggingAndMemorySchemaLocked()); + } + + private ensureLoggingAndMemorySchemaLocked(): void { try { + const hasChats = this.db.prepare("SELECT name FROM sqlite_master WHERE type='table' AND name='chats'").get(); // Existing installs may already have logs but still need the idempotent schema pass below. // Only skip the legacy memory rename step when logs already exists. const hasLogs = this.db.prepare("SELECT name FROM sqlite_master WHERE type='table' AND name='logs'").get(); @@ -581,16 +480,16 @@ class SQLiteClient { const hasLogsNow = this.db.prepare("SELECT name FROM sqlite_master WHERE type='table' AND name='logs'").get(); if (!hasLogsNow) { this.db.exec(` - CREATE TABLE logs ( - id INTEGER PRIMARY KEY, - ts TEXT NOT NULL DEFAULT (CURRENT_TIMESTAMP), - table_name TEXT NOT NULL, - action TEXT NOT NULL, - row_id INTEGER NOT NULL, - summary TEXT, - snapshot_json TEXT - ); - `); + CREATE TABLE logs ( + id INTEGER PRIMARY KEY, + ts TEXT NOT NULL DEFAULT (CURRENT_TIMESTAMP), + table_name TEXT NOT NULL, + action TEXT NOT NULL, + row_id INTEGER NOT NULL, + summary TEXT, + snapshot_json TEXT + ); + `); } // Ensure nodes table has expected columns for memory nodes @@ -615,222 +514,202 @@ class SQLiteClient { console.warn('Failed to ensure nodes columns:', nodeErr); } - // Ensure chats table tracks creation timestamp for ordering - try { - const chatCols = this.db.prepare('PRAGMA table_info(chats)').all() as Array<{ name: string }>; - if (chatCols.some(col => col.name === 'created_at')) { - // no-op, column exists - } else if (chatCols.length > 0) { - this.db.exec("ALTER TABLE chats ADD COLUMN created_at TEXT DEFAULT (CURRENT_TIMESTAMP);"); - } - } catch (chatErr) { - console.warn('Failed to ensure chats.created_at column:', chatErr); - } - - // Normalize legacy chats table before creating chat triggers or views that reference modern columns. - try { - const hasChatsTable = this.db.prepare("SELECT name FROM sqlite_master WHERE type='table' AND name='chats'").get(); - if (hasChatsTable) { + // Ensure chats table tracks creation timestamp for ordering + try { const chatCols = this.db.prepare('PRAGMA table_info(chats)').all() as Array<{ name: string }>; - const chatColNames = new Set(chatCols.map((col) => col.name)); - const needsChatRewrite = - chatColNames.has('focused_memory_id') || - ['chat_type', 'helper_name', 'agent_type', 'delegation_id', 'user_message', 'assistant_message', 'thread_id', 'focused_node_id', 'created_at', 'metadata'] - .some((name) => !chatColNames.has(name)); - - if (needsChatRewrite) { - this.rebuildLegacyChatsTable(chatColNames); + if (chatCols.some(col => col.name === 'created_at')) { + // no-op, column exists + } else if (chatCols.length > 0) { + this.db.exec("ALTER TABLE chats ADD COLUMN created_at TEXT DEFAULT (CURRENT_TIMESTAMP);"); } + } catch (chatErr) { + console.warn('Failed to ensure chats.created_at column:', chatErr); } - } catch (chatSchemaErr) { - console.warn('Failed to normalize chats schema before log setup:', chatSchemaErr); - } - // 3) Helpful indexes on logs (clean up old names first) - this.db.exec(` - DROP INDEX IF EXISTS idx_memory_ts; - DROP INDEX IF EXISTS idx_memory_table_ts; - DROP INDEX IF EXISTS idx_memory_table_row; - CREATE INDEX IF NOT EXISTS idx_logs_ts ON logs(ts); - CREATE INDEX IF NOT EXISTS idx_logs_table_ts ON logs(table_name, ts); - CREATE INDEX IF NOT EXISTS idx_logs_table_row ON logs(table_name, row_id); - `); - - // 4) Recreate triggers to write to logs (use CREATE IF NOT EXISTS) - const hasChats = this.db.prepare("SELECT name FROM sqlite_master WHERE type='table' AND name='chats'").get(); - this.db.exec(` - DROP TRIGGER IF EXISTS trg_nodes_ai; - DROP TRIGGER IF EXISTS trg_nodes_au; - CREATE TRIGGER IF NOT EXISTS trg_nodes_ai AFTER INSERT ON nodes BEGIN - INSERT INTO logs(table_name, action, row_id, summary, snapshot_json) - VALUES('nodes', 'insert', NEW.id, - printf('node created: %s', COALESCE(NEW.title,'')), - json_object('id', NEW.id, 'title', NEW.title, 'link', NEW.link)); - END; - CREATE TRIGGER IF NOT EXISTS trg_nodes_au AFTER UPDATE ON nodes BEGIN - INSERT INTO logs(table_name, action, row_id, summary, snapshot_json) - VALUES('nodes', 'update', NEW.id, - printf('node updated: %s', COALESCE(NEW.title,'')), - json_object('id', NEW.id, 'title', NEW.title, 'link', NEW.link)); - END; - - DROP TRIGGER IF EXISTS trg_edges_ai; - DROP TRIGGER IF EXISTS trg_edges_au; - CREATE TRIGGER IF NOT EXISTS trg_edges_ai AFTER INSERT ON edges BEGIN - INSERT INTO logs(table_name, action, row_id, summary, snapshot_json) - VALUES('edges', 'insert', NEW.id, - printf('edge %d→%d (%s)', NEW.from_node_id, NEW.to_node_id, COALESCE(NEW.source,'')), - json_object( - 'id', NEW.id, - 'from', NEW.from_node_id, - 'to', NEW.to_node_id, - 'source', NEW.source, - 'from_title', substr((SELECT title FROM nodes WHERE id = NEW.from_node_id), 1, 120), - 'to_title', substr((SELECT title FROM nodes WHERE id = NEW.to_node_id), 1, 120) - )); - END; - CREATE TRIGGER IF NOT EXISTS trg_edges_au AFTER UPDATE ON edges BEGIN - INSERT INTO logs(table_name, action, row_id, summary, snapshot_json) - VALUES('edges', 'update', NEW.id, - printf('edge updated %d→%d', NEW.from_node_id, NEW.to_node_id), - json_object( - 'id', NEW.id, - 'from', NEW.from_node_id, - 'to', NEW.to_node_id, - 'source', NEW.source, - 'from_title', substr((SELECT title FROM nodes WHERE id = NEW.from_node_id), 1, 120), - 'to_title', substr((SELECT title FROM nodes WHERE id = NEW.to_node_id), 1, 120) - )); - END; - `); - - if (hasChats) { + // 3) Helpful indexes on logs (clean up old names first) this.db.exec(` - DROP TRIGGER IF EXISTS trg_chats_ai; - CREATE TRIGGER IF NOT EXISTS trg_chats_ai AFTER INSERT ON chats BEGIN + DROP INDEX IF EXISTS idx_memory_ts; + DROP INDEX IF EXISTS idx_memory_table_ts; + DROP INDEX IF EXISTS idx_memory_table_row; + CREATE INDEX IF NOT EXISTS idx_logs_ts ON logs(ts); + CREATE INDEX IF NOT EXISTS idx_logs_table_ts ON logs(table_name, ts); + CREATE INDEX IF NOT EXISTS idx_logs_table_row ON logs(table_name, row_id); + `); + + // 4) Recreate triggers to write to logs (use CREATE IF NOT EXISTS) + this.db.exec(` + DROP TRIGGER IF EXISTS trg_nodes_ai; + DROP TRIGGER IF EXISTS trg_nodes_au; + CREATE TRIGGER IF NOT EXISTS trg_nodes_ai AFTER INSERT ON nodes BEGIN INSERT INTO logs(table_name, action, row_id, summary, snapshot_json) - VALUES('chats', 'insert', NEW.id, - printf('chat: %s (%s)', COALESCE(NEW.helper_name,''), COALESCE(NEW.thread_id,'')), + VALUES('nodes', 'insert', NEW.id, + printf('node created: %s', COALESCE(NEW.title,'')), + json_object('id', NEW.id, 'title', NEW.title, 'link', NEW.link)); + END; + CREATE TRIGGER IF NOT EXISTS trg_nodes_au AFTER UPDATE ON nodes BEGIN + INSERT INTO logs(table_name, action, row_id, summary, snapshot_json) + VALUES('nodes', 'update', NEW.id, + printf('node updated: %s', COALESCE(NEW.title,'')), + json_object('id', NEW.id, 'title', NEW.title, 'link', NEW.link)); + END; + + DROP TRIGGER IF EXISTS trg_edges_ai; + DROP TRIGGER IF EXISTS trg_edges_au; + CREATE TRIGGER IF NOT EXISTS trg_edges_ai AFTER INSERT ON edges BEGIN + INSERT INTO logs(table_name, action, row_id, summary, snapshot_json) + VALUES('edges', 'insert', NEW.id, + printf('edge %d→%d (%s)', NEW.from_node_id, NEW.to_node_id, COALESCE(NEW.source,'')), json_object( 'id', NEW.id, - 'helper', NEW.helper_name, - 'thread', NEW.thread_id, - 'user_message', COALESCE(NEW.user_message,''), - 'assistant_message', COALESCE(NEW.assistant_message,''), - 'user_preview', substr(COALESCE(NEW.user_message,''), 1, 120), - 'assistant_preview', substr(COALESCE(NEW.assistant_message,''), 1, 120), - 'system_message', COALESCE(json_extract(NEW.metadata, '$.system_message'), ''), - 'input_tokens', COALESCE(json_extract(NEW.metadata, '$.input_tokens'), 0), - 'output_tokens', COALESCE(json_extract(NEW.metadata, '$.output_tokens'), 0), - 'cost_usd', COALESCE(json_extract(NEW.metadata, '$.estimated_cost_usd'), 0.0), - 'cache_hit', COALESCE(json_extract(NEW.metadata, '$.cache_hit'), 0), - 'model', COALESCE(json_extract(NEW.metadata, '$.model_used'), ''), - 'tools_count', COALESCE(json_extract(NEW.metadata, '$.tool_calls_count'), 0), - 'tools_used', COALESCE(json_extract(NEW.metadata, '$.tools_used'), json('[]')), - 'latency_ms', COALESCE(json_extract(NEW.metadata, '$.latency_ms'), 0), - 'prompt_build_ms', COALESCE(json_extract(NEW.metadata, '$.timing_breakdown.promptBuildMs'), 0), - 'tools_build_ms', COALESCE(json_extract(NEW.metadata, '$.timing_breakdown.toolsBuildMs'), 0), - 'model_resolve_ms', COALESCE(json_extract(NEW.metadata, '$.timing_breakdown.modelResolveMs'), 0), - 'message_assembly_ms', COALESCE(json_extract(NEW.metadata, '$.timing_breakdown.messageAssemblyMs'), 0), - 'stream_setup_ms', COALESCE(json_extract(NEW.metadata, '$.timing_breakdown.streamSetupMs'), 0), - 'tool_loop_ms', COALESCE(json_extract(NEW.metadata, '$.timing_breakdown.toolLoopMs'), 0), - 'first_token_latency_ms', COALESCE(json_extract(NEW.metadata, '$.first_token_latency_ms'), 0), - 'first_chunk_latency_ms', COALESCE(json_extract(NEW.metadata, '$.first_chunk_latency_ms'), 0), - 'tool_timings', COALESCE(json_extract(NEW.metadata, '$.tool_timings'), json('[]')), - 'trace_id', COALESCE(json_extract(NEW.metadata, '$.trace_id'), ''), - 'voice_tts_chars', COALESCE(json_extract(NEW.metadata, '$.voice_tts_chars'), 0), - 'voice_tts_cost_usd', COALESCE(json_extract(NEW.metadata, '$.voice_tts_cost_usd'), 0), - 'voice_tts_chars_total', COALESCE(json_extract(NEW.metadata, '$.voice_tts_chars_total'), 0), - 'voice_tts_cost_usd_total', COALESCE(json_extract(NEW.metadata, '$.voice_tts_cost_usd_total'), 0), - 'voice_request_id', COALESCE(json_extract(NEW.metadata, '$.voice_request_id'), ''), - 'voice_tts_request_count', COALESCE(json_extract(NEW.metadata, '$.voice_tts_request_count'), 0) + 'from', NEW.from_node_id, + 'to', NEW.to_node_id, + 'source', NEW.source, + 'from_title', substr((SELECT title FROM nodes WHERE id = NEW.from_node_id), 1, 120), + 'to_title', substr((SELECT title FROM nodes WHERE id = NEW.to_node_id), 1, 120) + )); + END; + CREATE TRIGGER IF NOT EXISTS trg_edges_au AFTER UPDATE ON edges BEGIN + INSERT INTO logs(table_name, action, row_id, summary, snapshot_json) + VALUES('edges', 'update', NEW.id, + printf('edge updated %d→%d', NEW.from_node_id, NEW.to_node_id), + json_object( + 'id', NEW.id, + 'from', NEW.from_node_id, + 'to', NEW.to_node_id, + 'source', NEW.source, + 'from_title', substr((SELECT title FROM nodes WHERE id = NEW.from_node_id), 1, 120), + 'to_title', substr((SELECT title FROM nodes WHERE id = NEW.to_node_id), 1, 120) )); END; `); - } - this.db.exec(` - CREATE TABLE IF NOT EXISTS voice_usage ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - chat_id INTEGER, - session_id TEXT, - helper_name TEXT, - request_id TEXT, - message_id TEXT, - voice TEXT, - model TEXT, - chars INTEGER, - cost_usd REAL, - duration_ms INTEGER, - text_preview TEXT, - created_at TEXT DEFAULT CURRENT_TIMESTAMP, - FOREIGN KEY (chat_id) REFERENCES chats(id) ON DELETE SET NULL - ); - CREATE INDEX IF NOT EXISTS idx_voice_usage_session ON voice_usage(session_id, created_at); - CREATE INDEX IF NOT EXISTS idx_voice_usage_chat ON voice_usage(chat_id); - `); - - // 5) Views: logs_v (drop any legacy memory_v alias) - this.db.exec(`DROP VIEW IF EXISTS logs_v; DROP VIEW IF EXISTS memory_v;`); - try { - this.db.exec(` - CREATE VIEW logs_v AS - SELECT - m.id, - m.ts, - m.table_name, - m.action, - m.row_id, - m.summary, - m.enriched_summary, - m.snapshot_json, - CASE WHEN m.table_name='nodes' THEN n.title END AS node_title, - CASE WHEN m.table_name='edges' THEN nf.title END AS edge_from_title, - CASE WHEN m.table_name='edges' THEN nt.title END AS edge_to_title, - CASE WHEN m.table_name='chats' THEN c.helper_name END AS chat_helper, - CASE WHEN m.table_name='chats' THEN substr(c.user_message,1,120) END AS chat_user_preview, - CASE WHEN m.table_name='chats' THEN substr(c.assistant_message,1,120) END AS chat_assistant_preview, - CASE WHEN m.table_name='chats' THEN c.user_message END AS chat_user_full, - CASE WHEN m.table_name='chats' THEN c.assistant_message END AS chat_assistant_full - FROM logs m - LEFT JOIN nodes n ON (m.table_name='nodes' AND m.row_id = n.id) - LEFT JOIN edges e ON (m.table_name='edges' AND m.row_id = e.id) - LEFT JOIN nodes nf ON e.from_node_id = nf.id - LEFT JOIN nodes nt ON e.to_node_id = nt.id - LEFT JOIN chats c ON (m.table_name='chats' AND m.row_id = c.id); - `); - } catch (error) { - if ( - !(error instanceof Error) || - !/already exists/i.test(error.message || '') - ) { - throw error; + if (hasChats) { + this.db.exec(` + DROP TRIGGER IF EXISTS trg_chats_ai; + CREATE TRIGGER IF NOT EXISTS trg_chats_ai AFTER INSERT ON chats BEGIN + INSERT INTO logs(table_name, action, row_id, summary, snapshot_json) + VALUES('chats', 'insert', NEW.id, + printf('chat: %s (%s)', COALESCE(NEW.helper_name,''), COALESCE(NEW.thread_id,'')), + json_object( + 'id', NEW.id, + 'helper', NEW.helper_name, + 'thread', NEW.thread_id, + 'user_message', COALESCE(NEW.user_message,''), + 'assistant_message', COALESCE(NEW.assistant_message,''), + 'user_preview', substr(COALESCE(NEW.user_message,''), 1, 120), + 'assistant_preview', substr(COALESCE(NEW.assistant_message,''), 1, 120), + 'system_message', COALESCE(json_extract(NEW.metadata, '$.system_message'), ''), + 'input_tokens', COALESCE(json_extract(NEW.metadata, '$.input_tokens'), 0), + 'output_tokens', COALESCE(json_extract(NEW.metadata, '$.output_tokens'), 0), + 'cost_usd', COALESCE(json_extract(NEW.metadata, '$.estimated_cost_usd'), 0.0), + 'cache_hit', COALESCE(json_extract(NEW.metadata, '$.cache_hit'), 0), + 'model', COALESCE(json_extract(NEW.metadata, '$.model_used'), ''), + 'tools_count', COALESCE(json_extract(NEW.metadata, '$.tool_calls_count'), 0), + 'tools_used', COALESCE(json_extract(NEW.metadata, '$.tools_used'), json('[]')), + 'latency_ms', COALESCE(json_extract(NEW.metadata, '$.latency_ms'), 0), + 'prompt_build_ms', COALESCE(json_extract(NEW.metadata, '$.timing_breakdown.promptBuildMs'), 0), + 'tools_build_ms', COALESCE(json_extract(NEW.metadata, '$.timing_breakdown.toolsBuildMs'), 0), + 'model_resolve_ms', COALESCE(json_extract(NEW.metadata, '$.timing_breakdown.modelResolveMs'), 0), + 'message_assembly_ms', COALESCE(json_extract(NEW.metadata, '$.timing_breakdown.messageAssemblyMs'), 0), + 'stream_setup_ms', COALESCE(json_extract(NEW.metadata, '$.timing_breakdown.streamSetupMs'), 0), + 'tool_loop_ms', COALESCE(json_extract(NEW.metadata, '$.timing_breakdown.toolLoopMs'), 0), + 'first_token_latency_ms', COALESCE(json_extract(NEW.metadata, '$.first_token_latency_ms'), 0), + 'first_chunk_latency_ms', COALESCE(json_extract(NEW.metadata, '$.first_chunk_latency_ms'), 0), + 'tool_timings', COALESCE(json_extract(NEW.metadata, '$.tool_timings'), json('[]')), + 'trace_id', COALESCE(json_extract(NEW.metadata, '$.trace_id'), ''), + 'voice_tts_chars', COALESCE(json_extract(NEW.metadata, '$.voice_tts_chars'), 0), + 'voice_tts_cost_usd', COALESCE(json_extract(NEW.metadata, '$.voice_tts_cost_usd'), 0), + 'voice_tts_chars_total', COALESCE(json_extract(NEW.metadata, '$.voice_tts_chars_total'), 0), + 'voice_tts_cost_usd_total', COALESCE(json_extract(NEW.metadata, '$.voice_tts_cost_usd_total'), 0), + 'voice_request_id', COALESCE(json_extract(NEW.metadata, '$.voice_request_id'), ''), + 'voice_tts_request_count', COALESCE(json_extract(NEW.metadata, '$.voice_tts_request_count'), 0) + )); + END; + `); } - } - // Do not recreate memory_v; alias has been removed. - // 6) Clean up removed chat_memory_state table - try { - this.db.exec(`DROP TABLE IF EXISTS chat_memory_state;`); - } catch (e) { - // Ignore if table doesn't exist - } + this.db.exec(` + CREATE TABLE IF NOT EXISTS voice_usage ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + chat_id INTEGER, + session_id TEXT, + helper_name TEXT, + request_id TEXT, + message_id TEXT, + voice TEXT, + model TEXT, + chars INTEGER, + cost_usd REAL, + duration_ms INTEGER, + text_preview TEXT, + created_at TEXT DEFAULT CURRENT_TIMESTAMP, + FOREIGN KEY (chat_id) REFERENCES chats(id) ON DELETE SET NULL + ); + CREATE INDEX IF NOT EXISTS idx_voice_usage_session ON voice_usage(session_id, created_at); + CREATE INDEX IF NOT EXISTS idx_voice_usage_chat ON voice_usage(chat_id); + `); - // Clean up removed agent_delegations table - try { - this.db.exec(`DROP TABLE IF EXISTS agent_delegations;`); - } catch (e) { - console.warn('Failed to drop agent_delegations table:', e); - } + // 5) Views: logs_v (drop any legacy memory_v alias) + this.db.exec(`DROP VIEW IF EXISTS logs_v; DROP VIEW IF EXISTS memory_v;`); + try { + this.db.exec(` + CREATE VIEW logs_v AS + SELECT + m.id, + m.ts, + m.table_name, + m.action, + m.row_id, + m.summary, + m.enriched_summary, + m.snapshot_json, + CASE WHEN m.table_name='nodes' THEN n.title END AS node_title, + CASE WHEN m.table_name='edges' THEN nf.title END AS edge_from_title, + CASE WHEN m.table_name='edges' THEN nt.title END AS edge_to_title, + CASE WHEN m.table_name='chats' THEN c.helper_name END AS chat_helper, + CASE WHEN m.table_name='chats' THEN substr(c.user_message,1,120) END AS chat_user_preview, + CASE WHEN m.table_name='chats' THEN substr(c.assistant_message,1,120) END AS chat_assistant_preview, + CASE WHEN m.table_name='chats' THEN c.user_message END AS chat_user_full, + CASE WHEN m.table_name='chats' THEN c.assistant_message END AS chat_assistant_full + FROM logs m + LEFT JOIN nodes n ON (m.table_name='nodes' AND m.row_id = n.id) + LEFT JOIN edges e ON (m.table_name='edges' AND m.row_id = e.id) + LEFT JOIN nodes nf ON e.from_node_id = nf.id + LEFT JOIN nodes nt ON e.to_node_id = nt.id + LEFT JOIN chats c ON (m.table_name='chats' AND m.row_id = c.id); + `); + } catch (error) { + if ( + !(error instanceof Error) || + !/already exists/i.test(error.message || '') + ) { + throw error; + } + } + // Do not recreate memory_v; alias has been removed. - // 8) Logs retention trigger (~10k most recent rows) + // 6) Clean up removed chat_memory_state table + try { + this.db.exec(`DROP TABLE IF EXISTS chat_memory_state;`); + } catch (e) { + // Ignore if table doesn't exist + } + + // Clean up removed agent_delegations table + try { + this.db.exec(`DROP TABLE IF EXISTS agent_delegations;`); + } catch (e) { + console.warn('Failed to drop agent_delegations table:', e); + } + + // 8) Logs retention trigger (~10k most recent rows) try { this.db.exec(` - DROP TRIGGER IF EXISTS trg_logs_prune; - CREATE TRIGGER IF NOT EXISTS trg_logs_prune AFTER INSERT ON logs BEGIN - DELETE FROM logs WHERE id < NEW.id - 10000; - END; - `); + DROP TRIGGER IF EXISTS trg_logs_prune; + CREATE TRIGGER IF NOT EXISTS trg_logs_prune AFTER INSERT ON logs BEGIN + DELETE FROM logs WHERE id < NEW.id - 10000; + END; + `); } catch {} // 7) Ensure agents table schema (backward compatibility) @@ -861,20 +740,55 @@ class SQLiteClient { if (hasChats) { try { let chatCols = this.db.prepare('PRAGMA table_info(chats)').all() as any[]; - const chatColNames = new Set(chatCols.map((c: any) => c.name)); - const needsChatRewrite = - chatColNames.has('focused_memory_id') || - ['chat_type', 'helper_name', 'agent_type', 'delegation_id', 'user_message', 'assistant_message', 'thread_id', 'focused_node_id', 'created_at', 'metadata'] - .some((name) => !chatColNames.has(name)); - - if (needsChatRewrite) { - this.rebuildLegacyChatsTable(chatColNames); + const hasFocusedMemoryId = chatCols.some((c: any) => c.name === 'focused_memory_id'); + if (hasFocusedMemoryId) { + console.log('Removing legacy chats.focused_memory_id column'); + let flippedForeignKeys = false; + try { + this.db.exec('PRAGMA foreign_keys=OFF;'); + flippedForeignKeys = true; + this.db.exec(` + BEGIN TRANSACTION; + ALTER TABLE chats RENAME TO chats_legacy_cleanup; + CREATE TABLE chats ( + id INTEGER PRIMARY KEY, + chat_type TEXT, + helper_name TEXT, + agent_type TEXT DEFAULT 'orchestrator', + delegation_id INTEGER, + user_message TEXT, + assistant_message TEXT, + thread_id TEXT, + focused_node_id INTEGER, + created_at TEXT DEFAULT (CURRENT_TIMESTAMP), + metadata TEXT, + FOREIGN KEY (focused_node_id) REFERENCES nodes(id) ON DELETE SET NULL + ); + INSERT INTO chats ( + id, chat_type, helper_name, agent_type, delegation_id, + user_message, assistant_message, thread_id, focused_node_id, + created_at, metadata + ) + SELECT id, chat_type, helper_name, agent_type, delegation_id, + user_message, assistant_message, thread_id, focused_node_id, + created_at, metadata + FROM chats_legacy_cleanup; + DROP TABLE chats_legacy_cleanup; + CREATE INDEX IF NOT EXISTS idx_chats_thread ON chats(thread_id); + COMMIT; + `); + } catch (migrationErr) { + console.warn('Failed to migrate chats table (focused_memory_id removal):', migrationErr); + try { this.db.exec('ROLLBACK;'); } catch {} + } finally { + if (flippedForeignKeys) { + try { this.db.exec('PRAGMA foreign_keys=ON;'); } catch {} + } + } chatCols = this.db.prepare('PRAGMA table_info(chats)').all() as any[]; } - if (chatCols.some((c: any) => c.name === 'thread_id')) { - this.db.exec("CREATE INDEX IF NOT EXISTS idx_chats_thread ON chats(thread_id);"); - } + this.db.exec("CREATE INDEX IF NOT EXISTS idx_chats_thread ON chats(thread_id);"); const ensureCol = (name: string, ddl: string) => { if (!chatCols.some((c: any) => c.name === name)) { @@ -910,7 +824,7 @@ class SQLiteClient { console.warn('Failed to drop legacy memory pipeline tables:', dropLegacyErr); } - // 9) Final schema pass migrations (source-first backfill, event_date, soft contexts, drop dimensions) + // 9) Final schema pass migrations (source-first backfill, event_date, legacy category cleanup, drop dimensions) try { let nodeCols2 = this.db.prepare('PRAGMA table_info(nodes)').all() as Array<{ name: string }>; let nodeColNames = nodeCols2.map(c => c.name); @@ -1002,46 +916,7 @@ class SQLiteClient { } catch {} } - if (!nodeColNames.includes('context_id')) { - this.db.exec('ALTER TABLE nodes ADD COLUMN context_id INTEGER REFERENCES contexts(id) ON DELETE SET NULL;'); - } - - const hasContexts = this.db.prepare("SELECT name FROM sqlite_master WHERE type='table' AND name='contexts'").get(); - if (!hasContexts) { - this.db.exec(` - CREATE TABLE contexts ( - id INTEGER PRIMARY KEY, - name TEXT NOT NULL, - description TEXT NOT NULL, - icon TEXT, - created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, - updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP - ); - `); - } - - const contextCols = this.db.prepare('PRAGMA table_info(contexts)').all() as Array<{ name: string }>; - const ensureContextCol = (name: string, ddl: string) => { - if (!contextCols.some(col => col.name === name)) { - this.db.exec(ddl); - } - }; - ensureContextCol('description', "ALTER TABLE contexts ADD COLUMN description TEXT NOT NULL DEFAULT '';"); - ensureContextCol('icon', 'ALTER TABLE contexts ADD COLUMN icon TEXT;'); - ensureContextCol('created_at', "ALTER TABLE contexts ADD COLUMN created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP;"); - ensureContextCol('updated_at', "ALTER TABLE contexts ADD COLUMN updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP;"); - - this.db.exec(` - UPDATE contexts - SET description = COALESCE(NULLIF(TRIM(description), ''), name) - WHERE description IS NULL OR LENGTH(TRIM(description)) = 0; - `); - - this.db.exec(` - 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); - `); + this.dropContextsSchema(); const hasLegacyDimensions = this.db.prepare("SELECT 1 FROM sqlite_master WHERE type='table' AND name='dimensions'").get(); const hasLegacyNodeDimensions = this.db.prepare("SELECT 1 FROM sqlite_master WHERE type='table' AND name='node_dimensions'").get(); @@ -1138,60 +1013,8 @@ class SQLiteClient { } } - private ensureContextsSchema(): void { - if (this.readOnly) { - return; - } - - try { - this.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 nodeCols = this.db.prepare('PRAGMA table_info(nodes)').all() as Array<{ name: string }>; - const nodeColNames = nodeCols.map((column) => column.name); - if (!nodeColNames.includes('context_id')) { - this.db.exec('ALTER TABLE nodes ADD COLUMN context_id INTEGER REFERENCES contexts(id) ON DELETE SET NULL;'); - } - - const contextCols = this.db.prepare('PRAGMA table_info(contexts)').all() as Array<{ name: string }>; - const contextColNames = contextCols.map((column) => column.name); - if (!contextColNames.includes('description')) { - this.db.exec("ALTER TABLE contexts ADD COLUMN description TEXT NOT NULL DEFAULT '';"); - } - if (!contextColNames.includes('icon')) { - this.db.exec('ALTER TABLE contexts ADD COLUMN icon TEXT;'); - } - if (!contextColNames.includes('created_at')) { - this.db.exec("ALTER TABLE contexts ADD COLUMN created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP;"); - } - if (!contextColNames.includes('updated_at')) { - this.db.exec("ALTER TABLE contexts ADD COLUMN updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP;"); - } - - this.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); - `); - } catch (error) { - console.warn('Failed to ensure contexts schema:', error); - } - } - private healVectorTablesIfCorrupt(): void { - if (this.readOnly || !this.vectorCapability.available) { + if (this.readOnly) { return; } // Attempt lightweight reads to detect CORRUPT_VTAB; if detected, drop/recreate vtables @@ -1344,7 +1167,20 @@ class SQLiteClient { } private refreshIntegrityReportForCorruptionError(error: unknown): void { - if (this.isSqliteCorruptError(error)) { + const message = error instanceof Error ? error.message : String(error); + const code = + typeof error === 'object' && + error !== null && + 'code' in error && + typeof (error as { code?: unknown }).code === 'string' + ? String((error as { code: string }).code) + : ''; + + if ( + code.includes('CORRUPT') || + message.includes('database disk image is malformed') || + message.includes('SQLITE_CORRUPT') + ) { this.getIntegrityReport(true); } } @@ -1476,6 +1312,10 @@ class SQLiteClient { return this.integrityReport; } + public canUseFtsTable(tableName: FtsSurfaceName): boolean { + return this.getIntegrityReport().ftsTables[tableName]; + } + private handleError(error: any): DatabaseError { return { message: error.message || 'SQLite operation failed', @@ -1487,18 +1327,6 @@ class SQLiteClient { public close(): void { this.db.close(); } - - private isSqliteCorruptError(error: unknown): boolean { - if (!(error instanceof Error)) { - return false; - } - - const sqliteError = error as Error & { code?: string }; - return ( - sqliteError.code?.includes('CORRUPT') === true || - /database disk image is malformed/i.test(sqliteError.message || '') - ); - } } // Export singleton instance (similar to PostgreSQL client interface) diff --git a/src/services/events.ts b/src/services/events.ts index 60232ce..8e268c9 100644 --- a/src/services/events.ts +++ b/src/services/events.ts @@ -16,6 +16,7 @@ export interface DatabaseEvent { | 'AGENT_DELEGATION_CREATED' | 'AGENT_DELEGATION_UPDATED' | 'GUIDE_UPDATED' + | 'SKILL_UPDATED' | 'QUICK_ADD_COMPLETED' | 'QUICK_ADD_FAILED' | 'CONNECTION_ESTABLISHED'; diff --git a/src/services/retrieval/directNodeLookup.ts b/src/services/retrieval/directNodeLookup.ts index d006cd9..2c19ed5 100644 --- a/src/services/retrieval/directNodeLookup.ts +++ b/src/services/retrieval/directNodeLookup.ts @@ -1,4 +1,3 @@ -import { contextService } from '@/services/database/contextService'; import { nodeService } from '@/services/database/nodes'; import { countHighSignalQueryTermMatches, getHighSignalSearchTerms, scoreNodeSearchMatch } from '@/services/database/searchRanking'; import type { Node } from '@/types/database'; @@ -6,8 +5,6 @@ import type { Node } from '@/types/database'; export interface DirectNodeLookupInput { search?: string; limit?: number; - context_name?: string; - contextId?: number; createdAfter?: string; createdBefore?: string; eventAfter?: string; @@ -20,7 +17,6 @@ export interface DirectNodeLookupResult { filtersApplied: { search?: string; limit: number; - context_name?: string; createdAfter?: string; createdBefore?: string; eventAfter?: string; @@ -28,41 +24,6 @@ export interface DirectNodeLookupResult { }; } -function normalizeContextName(value: string | undefined): string | undefined { - if (typeof value !== 'string') return undefined; - const normalized = value.trim().replace(/\s+/g, ' '); - return normalized || undefined; -} - -async function resolveSearchContext(input: DirectNodeLookupInput): Promise<{ contextId?: number; context_name?: string }> { - const normalizedName = normalizeContextName(input.context_name); - if (normalizedName) { - const context = await contextService.getContextByName(normalizedName); - if (!context) { - console.warn(`directNodeLookup received unknown context_name "${normalizedName}"; ignoring context filter.`); - return {}; - } - return { - contextId: context.id, - context_name: context.name, - }; - } - - if (typeof input.contextId === 'number') { - const context = await contextService.getContextById(input.contextId); - if (!context) { - console.warn(`directNodeLookup received invalid legacy contextId ${input.contextId}; ignoring context filter.`); - return {}; - } - return { - contextId: context.id, - context_name: context.name, - }; - } - - return {}; -} - function hasStrongAnchorMatch(nodes: Node[], searchTerm: string): boolean { if (!searchTerm || nodes.length === 0) return false; const highSignalTerms = getHighSignalSearchTerms(searchTerm); @@ -87,11 +48,9 @@ export async function directNodeLookup(input: DirectNodeLookupInput): Promise term.trim()) + .map(term => term.trim()) .filter(Boolean) .length; } @@ -161,7 +158,7 @@ function extractHighSignalTerms(query: string): string[] { .toLowerCase() .replace(/[^a-z0-9\s]+/g, ' ') .split(/\s+/) - .map((term) => singularizeTerm(term.trim())) + .map(term => singularizeTerm(term.trim())) .filter(Boolean); const seen = new Set(); @@ -184,8 +181,7 @@ function isLikelyUserNoteRecallQuery(query: string): boolean { const explicitRecall = USER_RECALL_PATTERN.test(normalized); const firstPersonRecall = FIRST_PERSON_PATTERN.test(normalized) && /\bwhat\b/i.test(normalized); - const explicitLookup = LOOKUP_PATTERN.test(normalized) - && (FIRST_PERSON_PATTERN.test(normalized) || /\b(created|saved|wrote|added)\b/i.test(normalized)); + const explicitLookup = LOOKUP_PATTERN.test(normalized) && (FIRST_PERSON_PATTERN.test(normalized) || /\b(created|saved|wrote|added)\b/i.test(normalized)); const firstPersonShareRecall = FIRST_PERSON_SHARE_PATTERN.test(normalized); const noteHint = NOTE_HINT_PATTERN.test(normalized); const recentHint = RECENT_REFERENCE_PATTERN.test(normalized); @@ -211,8 +207,8 @@ function buildRecallSearchVariants(query: string): string[] { const terms = extractHighSignalTerms(query); if (terms.length === 0) return []; - const topicalTerms = terms.filter((term) => !NOTE_TERMS.has(term)); - const noteTerms = terms.filter((term) => NOTE_TERMS.has(term)); + const topicalTerms = terms.filter(term => !NOTE_TERMS.has(term)); + const noteTerms = terms.filter(term => NOTE_TERMS.has(term)); const phraseVariants = extractRecallPhraseVariants(query); const variants: string[] = []; @@ -281,8 +277,8 @@ function scoreRecallMatch(node: Node, query: string): number { if (normalizedSource.includes(normalizedPhrase)) score += 900; } - const titleTermMatches = terms.filter((term) => normalizedTitle.includes(term)).length; - const totalTermMatches = terms.filter((term) => combined.includes(term)).length; + const titleTermMatches = terms.filter(term => normalizedTitle.includes(term)).length; + const totalTermMatches = terms.filter(term => combined.includes(term)).length; score += titleTermMatches * 250; score += totalTermMatches * 120; @@ -298,7 +294,7 @@ function scoreRecallMatch(node: Node, query: string): number { } function hasStrongRecallMatch(nodes: Node[], query: string): boolean { - return nodes.some((node) => scoreRecallMatch(node, query) >= 1800); + return nodes.some(node => scoreRecallMatch(node, query) >= 1800); } function isLikelyUserAuthoredNote(node: Node): boolean { @@ -345,7 +341,7 @@ export function isFocusedSourceRequest(query: string): boolean { export function shouldRetrieveForQuery(query: string): boolean { const trimmed = normalizeWhitespace(query); if (!trimmed) return false; - if (LOW_SIGNAL_PATTERNS.some((pattern) => pattern.test(trimmed))) return false; + if (LOW_SIGNAL_PATTERNS.some(pattern => pattern.test(trimmed))) return false; if (isFocusedSourceRequest(trimmed)) return true; if (SOURCE_DETAIL_PATTERN.test(trimmed)) return true; @@ -413,7 +409,6 @@ function rankRetrievedNodes(nodes: RetrievedContextNode[]): RetrievedContextNode export async function retrieveQueryContext(input: RetrieveQueryContextInput): Promise { const query = normalizeWhitespace(input.query || ''); const focusedNodeId = input.focused_node_id ?? null; - const requestedActiveContextId = input.active_context_id ?? null; const limit = Math.min(Math.max(input.limit ?? 6, 1), 12); const shouldRetrieve = shouldRetrieveForQuery(query); @@ -424,16 +419,11 @@ export async function retrieveQueryContext(input: RetrieveQueryContextInput): Pr mode: 'skip', reason: 'Query is too lightweight or conversational to justify retrieval.', focused_node_id: focusedNodeId, - active_context_id: requestedActiveContextId, nodes: [], chunks: [], }; } - const activeContextId = requestedActiveContextId - ? (await contextService.getContextById(requestedActiveContextId))?.id ?? null - : null; - const focusedRequest = isFocusedSourceRequest(query); const nodesById = new Map(); @@ -470,19 +460,6 @@ export async function retrieveQueryContext(input: RetrieveQueryContextInput): Pr }); }); - if (activeContextId && !strongRecallMatch) { - const contextMatches = query - ? await nodeService.getNodes({ search: query, contextId: activeContextId, limit: Math.max(limit, 4) }) - : []; - contextMatches.forEach((node, index) => { - addNodeWithReason(nodesById, node, { - kind: 'context_hint', - reason: 'Also matched inside the active context.', - searchRank: directQueryMatches.length + index, - }); - }); - } - if (!strongRecallMatch) { const rankedSeedNodes = rankRetrievedNodes(Array.from(nodesById.values())).slice(0, Math.max(3, limit)); for (const seed of rankedSeedNodes.slice(0, 3)) { @@ -518,7 +495,6 @@ export async function retrieveQueryContext(input: RetrieveQueryContextInput): Pr ? 'Direct node retrieval query: search the graph directly first and broaden only if needed.' : 'Substantive query: search the graph directly, then pull additional supporting context if helpful.', focused_node_id: focusedNodeId, - active_context_id: activeContextId, nodes: finalNodes, chunks: chunks.map((chunk) => { const owner = finalNodes.find((node) => node.id === chunk.node_id) || directQueryMatches.find((node) => node.id === chunk.node_id); diff --git a/src/services/typescript/embed-nodes.ts b/src/services/typescript/embed-nodes.ts index 45b7e6f..d008b86 100644 --- a/src/services/typescript/embed-nodes.ts +++ b/src/services/typescript/embed-nodes.ts @@ -19,7 +19,6 @@ interface NodeRecord { title: string; source: string | null; description: string | null; - context_name: string | null; embedding?: Buffer | null; embedding_updated_at?: string | null; embedding_text?: string | null; @@ -57,7 +56,6 @@ export class NodeEmbedder { Title: ${node.title} Source: ${node.source || 'No source'} -Context: ${node.context_name || 'none'} Focus on the main concepts, key relationships, and practical implications.`; @@ -103,7 +101,7 @@ Focus on the main concepts, key relationships, and practical implications.`; node.title, node.source || '', node.description, - node.context_name + null ); // Add AI analysis if source exists @@ -173,29 +171,26 @@ Focus on the main concepts, key relationships, and practical implications.`; if (nodeId) { // Single node query = ` - SELECT n.id, n.title, n.source, n.description, c.name as context_name, + SELECT n.id, n.title, n.source, n.description, n.embedding, n.embedding_updated_at FROM nodes n - LEFT JOIN contexts c ON c.id = n.context_id WHERE n.id = ? `; params = [nodeId]; } else if (forceReEmbed) { // All nodes query = ` - SELECT n.id, n.title, n.source, n.description, c.name as context_name, + SELECT n.id, n.title, n.source, n.description, n.embedding, n.embedding_updated_at FROM nodes n - LEFT JOIN contexts c ON c.id = n.context_id ORDER BY n.id `; } else { // Only nodes without embeddings query = ` - SELECT n.id, n.title, n.source, n.description, c.name as context_name, + SELECT n.id, n.title, n.source, n.description, n.embedding, n.embedding_updated_at FROM nodes n - LEFT JOIN contexts c ON c.id = n.context_id WHERE n.embedding IS NULL OR n.embedding_updated_at IS NULL ORDER BY n.id `; diff --git a/src/tools/database/createNode.ts b/src/tools/database/createNode.ts index d3b0ec8..ba2fcd9 100644 --- a/src/tools/database/createNode.ts +++ b/src/tools/database/createNode.ts @@ -2,6 +2,7 @@ import { tool } from 'ai'; import { z } from 'zod'; import { getInternalApiBaseUrl } from '@/services/runtime/apiBase'; import { formatNodeForChat } from '../infrastructure/nodeFormatter'; +import { getInternalAuthHeaders } from '@/services/auth/internalAuth'; function extractTextFromMessageContent(content: unknown): string { if (typeof content === 'string') { @@ -56,14 +57,13 @@ function inferSourceFromContext(params: { title: string; description?: string; s } export const createNodeTool = tool({ - description: 'Create a node after you have already decided this should be a net-new write. If the user explicitly asked to save or import something and duplicate/update checks are complete, write immediately. If you are only suggesting a save, propose the node first and wait for confirmation. Leave context blank by default. Only set context when the user explicitly wants one and it is clear and useful; when that happens, use context_name rather than a numeric ID. Focus on a clean title, a strong natural description that says what the thing is, preserved source text, and the right metadata. When the node comes from the user\'s own idea, note, or dictated thought, preserve their actual wording in source with only minimal cleanup instead of flattening it into a summary. Do not block creation if the description is incomplete. If the description framing is materially inferred, create the node first and then invite one concise user correction pass.', + description: 'Create a node after you have already decided this should be a net-new write. If the user explicitly asked to save or import something and duplicate/update checks are complete, write immediately. If you are only suggesting a save, propose the node first and wait for confirmation. Focus on a clean title, a strong natural description that says what the thing is, preserved source text, and the right metadata. When the node comes from the user\'s own idea, note, or dictated thought, preserve their actual wording in source with only minimal cleanup instead of flattening it into a summary. Do not block creation if the description is incomplete. If the description framing is materially inferred, create the node first and then invite one concise user correction pass.', inputSchema: z.object({ title: z.string().describe('The title of the node'), description: z.string().max(500).optional().describe('Optional natural description. If you have enough context, describe what this is, why it belongs in Brad\'s graph, and its current workflow status in normal prose. Do not use labels like WHAT:, WHY:, or STATUS:.'), source: z.string().optional().describe('Canonical source content for embedding. For external content, store the actual transcript/article/document text. For user-authored ideas or dictated notes, store the user\'s original wording as fully as possible with only minimal cleanup such as obvious whitespace or transcription artifacts. Do not replace raw user thinking with a thin summary.'), link: z.string().optional().describe('A URL link to the source'), event_date: z.string().optional().describe('When the thing actually happened (ISO 8601). Not when it was added to the graph.'), - context_name: z.string().optional().describe('Optional primary context name. Use only when the user explicitly wants this node assigned to a known context.'), metadata: z.record(z.any()).optional().describe('Optional node metadata. Use canonical keys when known: type, state, captured_method, captured_by, and source_metadata. Source-specific facts belong inside source_metadata.') }).passthrough(), execute: async (params, context) => { @@ -74,12 +74,12 @@ export const createNodeTool = tool({ // Call the nodes API endpoint const response = await fetch(`${getInternalApiBaseUrl()}/api/nodes`, { method: 'POST', - headers: { 'Content-Type': 'application/json' }, + headers: getInternalAuthHeaders({ 'Content-Type': 'application/json' }), body: JSON.stringify({ ...params, source: canonicalSource ?? params.source }) }); const result = await response.json(); - + if (!response.ok) { return { success: false, @@ -88,6 +88,7 @@ export const createNodeTool = tool({ }; } + // Format the created node for chat display const formattedDisplay = formatNodeForChat({ id: result.data.id, title: result.data.title, @@ -97,9 +98,9 @@ export const createNodeTool = tool({ success: true, data: { ...result.data, - formatted_display: formattedDisplay, + formatted_display: formattedDisplay }, - message: `Created: ${formattedDisplay}` + message: `Created node ${formattedDisplay}` }; } catch (error) { return { @@ -111,4 +112,5 @@ export const createNodeTool = tool({ } }); +// Legacy export for backwards compatibility export const createItemTool = createNodeTool; diff --git a/src/tools/database/getNodesById.ts b/src/tools/database/getNodesById.ts index e02183f..c36401e 100644 --- a/src/tools/database/getNodesById.ts +++ b/src/tools/database/getNodesById.ts @@ -36,8 +36,6 @@ export const getNodesByIdTool = tool({ title: node.title, link: node.link, event_date: node.event_date ?? null, - context_id: node.context_id ?? null, - context: node.context ?? null, chunk_status: node.chunk_status || 'unknown', created_at: node.created_at, updated_at: node.updated_at, diff --git a/src/tools/database/queryContexts.ts b/src/tools/database/queryContexts.ts deleted file mode 100644 index 7e058f7..0000000 --- a/src/tools/database/queryContexts.ts +++ /dev/null @@ -1,104 +0,0 @@ -import { tool } from 'ai'; -import { z } from 'zod'; -import { contextService } from '@/services/database/contextService'; - -type QueryContextFilters = { - id?: number; - name?: string; - search?: string; - limit?: number; - includeNodes?: boolean; -}; - -function matchesSearch(value: string | null | undefined, search: string): boolean { - if (!value) return false; - return value.toLowerCase().includes(search); -} - -export const queryContextsTool = tool({ - description: 'List and inspect contexts. Use this to discover available primary scopes before filtering nodes or assigning node context.', - inputSchema: z.object({ - filters: z.object({ - id: z.number().int().positive().describe('Exact context ID lookup.').optional(), - name: z.string().describe('Exact context name lookup.').optional(), - search: z.string().describe('Case-insensitive search across context names and descriptions.').optional(), - limit: z.number().min(1).max(100).default(50).describe('Maximum number of contexts to return.').optional(), - includeNodes: z.boolean().default(false).describe('Include the node list for an exact single-context lookup.').optional(), - }).optional(), - }), - execute: async ({ filters = {} }: { filters?: QueryContextFilters }) => { - try { - const limit = filters.limit || 50; - const normalizedName = filters.name?.trim(); - const normalizedSearch = filters.search?.trim().toLowerCase(); - - let contexts = await contextService.listContexts(); - - if (filters.id !== undefined) { - contexts = contexts.filter((context) => context.id === filters.id); - } - - if (normalizedName) { - contexts = contexts.filter((context) => context.name.toLowerCase() === normalizedName.toLowerCase()); - } - - if (normalizedSearch) { - contexts = contexts.filter((context) => - matchesSearch(context.name, normalizedSearch) || - matchesSearch(context.description, normalizedSearch) - ); - } - - const limitedContexts = contexts.slice(0, limit); - const canIncludeNodes = - filters.includeNodes === true && - limitedContexts.length === 1 && - (filters.id !== undefined || Boolean(normalizedName)); - - const contextsWithNodes = await Promise.all( - limitedContexts.map(async (context) => { - if (!canIncludeNodes) return context; - - const nodes = await contextService.getNodesForContext(context.id); - return { - ...context, - nodes: nodes.map((node) => ({ - id: node.id, - title: node.title, - description: node.description ?? null, - context_id: node.context_id ?? null, - context: node.context ?? null, - updated_at: node.updated_at, - })), - }; - }) - ); - - const message = contextsWithNodes.length === 0 - ? 'No contexts found.' - : `Found ${contextsWithNodes.length} context${contextsWithNodes.length === 1 ? '' : 's'}:\n${contextsWithNodes.map((context) => `• ${context.name} (#${context.id}, ${context.count} nodes)`).join('\n')}`; - - return { - success: true, - data: { - contexts: contextsWithNodes, - count: contextsWithNodes.length, - total_available: contexts.length, - filters_applied: filters, - }, - message, - }; - } catch (error) { - console.error('QueryContexts tool error:', error); - return { - success: false, - error: error instanceof Error ? error.message : 'Failed to query contexts', - data: { - contexts: [], - count: 0, - filters_applied: filters, - }, - }; - } - }, -}); diff --git a/src/tools/database/queryEdge.ts b/src/tools/database/queryEdge.ts index e40b8bb..03a2804 100644 --- a/src/tools/database/queryEdge.ts +++ b/src/tools/database/queryEdge.ts @@ -84,7 +84,6 @@ export const queryEdgeTool = tool({ id: connection.connected_node.id, title: connection.connected_node.title, description: truncateText(connection.connected_node.description, 140), - context: connection.connected_node.context ?? null, formatted_display: formattedNode } }; diff --git a/src/tools/database/queryNodes.ts b/src/tools/database/queryNodes.ts index ec3a963..a49c1da 100644 --- a/src/tools/database/queryNodes.ts +++ b/src/tools/database/queryNodes.ts @@ -4,8 +4,6 @@ import { formatNodeForChat } from '../infrastructure/nodeFormatter'; import { directNodeLookup } from '@/services/retrieval/directNodeLookup'; type QueryNodeFilters = { - contextId?: number; - context_name?: string; search?: string; limit?: number; createdAfter?: string; @@ -15,10 +13,9 @@ type QueryNodeFilters = { }; export const queryNodesTool = tool({ - description: 'Find specific existing nodes in the graph by searching title, description, and source. Use this first when the user is trying to locate a node they already created or a specific existing podcast, article, idea, person, project, or note. For broader current-turn grounding of a substantive question, use retrieveQueryContext instead. Leave context blank by default. If the user explicitly wants a context filter, use context_name rather than a numeric ID.', + description: 'Find specific existing nodes in the graph by searching title, description, and source. Use this first when the user is trying to locate a node they already created or a specific existing podcast, article, idea, person, project, or note. For broader current-turn grounding of a substantive question, use retrieveQueryContext instead.', inputSchema: z.object({ filters: z.object({ - context_name: z.string().describe('Optional primary context name filter. Use only when the user explicitly wants a context-specific lookup.').optional(), search: z.string().describe('Search term to match against node title, description, or source').optional(), limit: z.number().min(1).max(50).default(10).describe('Maximum number of results to return'), createdAfter: z.string().optional().describe('ISO date (YYYY-MM-DD). Only return nodes created on or after this date.'), @@ -33,8 +30,6 @@ export const queryNodesTool = tool({ const result = await directNodeLookup({ search: filters.search, limit: filters.limit, - context_name: filters.context_name, - contextId: filters.contextId, createdAfter: filters.createdAfter, createdBefore: filters.createdBefore, eventAfter: filters.eventAfter, diff --git a/src/tools/database/retrieveQueryContext.ts b/src/tools/database/retrieveQueryContext.ts index b1dc3c2..5b71371 100644 --- a/src/tools/database/retrieveQueryContext.ts +++ b/src/tools/database/retrieveQueryContext.ts @@ -7,15 +7,13 @@ export const retrieveQueryContextTool = tool({ inputSchema: z.object({ query: z.string().min(1).describe('The raw user query for this turn.'), focused_node_id: z.number().int().positive().nullable().optional().describe('Optional currently focused node ID.'), - active_context_id: z.number().int().positive().nullable().optional().describe('Optional active context ID as a soft hint.'), limit: z.number().int().min(1).max(12).optional().describe('Maximum number of nodes to return.'), }), - execute: async ({ query, focused_node_id, active_context_id, limit }) => { + execute: async ({ query, focused_node_id, limit }) => { try { const result = await retrieveQueryContext({ query, focused_node_id: focused_node_id ?? null, - active_context_id: active_context_id ?? null, limit, }); diff --git a/src/tools/database/updateNode.ts b/src/tools/database/updateNode.ts index b483fbb..a4c68d0 100644 --- a/src/tools/database/updateNode.ts +++ b/src/tools/database/updateNode.ts @@ -1,9 +1,10 @@ import { tool } from 'ai'; import { z } from 'zod'; import { getInternalApiBaseUrl } from '@/services/runtime/apiBase'; +import { getInternalAuthHeaders } from '@/services/auth/internalAuth'; export const updateNodeTool = tool({ - description: 'Update node fields when the existing node is clearly the same artifact and a net-new node would be redundant. Explicit user-directed updates should proceed once the target node is clear; if you are only proposing a change, ask first. Use this to enrich or correct nodes without losing canonical source content. Context is preserved unless the user explicitly wants it changed. When that happens, prefer context_name rather than a numeric ID. Use clear_context only when the user explicitly wants the context removed. When fixing a user-authored idea node, source should preserve the user\'s original wording as fully as possible. Never block an update because the description is incomplete. If the new description framing is materially inferred, complete the update and then invite one concise user feedback pass.', + description: 'Update node fields when the existing node is clearly the same artifact and a net-new node would be redundant. Explicit user-directed updates should proceed once the target node is clear; if you are only proposing a change, ask first. Use this to enrich or correct nodes without losing canonical source content. When fixing a user-authored idea node, source should preserve the user\'s original wording as fully as possible. Never block an update because the description is incomplete. If the new description framing is materially inferred, complete the update and then invite one concise user feedback pass.', inputSchema: z.object({ id: z.number().describe('The ID of the node to update'), updates: z.object({ @@ -12,8 +13,6 @@ export const updateNodeTool = tool({ source: z.string().optional().describe('Canonical source content for embedding. Use this to set or correct the raw source text. For user-authored ideas or dictated notes, preserve the user\'s original wording with only minimal cleanup rather than compressing it into a summary.'), link: z.string().optional().describe('New link'), event_date: z.string().optional().describe('When the thing actually happened (ISO 8601). Not when it was added to the graph.'), - context_name: z.string().optional().describe('Optional primary context name. Use only when the user explicitly wants to assign this node to a known context.'), - clear_context: z.boolean().optional().describe('Set true only when the user explicitly wants to remove the node context.'), metadata: z.record(z.any()).optional().describe('Metadata patch. It now merges with existing metadata instead of replacing the full blob. Use canonical keys: type, state, captured_method, captured_by, source_metadata.') }).passthrough().describe('Object containing the fields to update. Derived analysis should be stored in a separate linked node, not appended to the source node.') }), @@ -30,12 +29,12 @@ export const updateNodeTool = tool({ // Call the nodes API endpoint const response = await fetch(`${getInternalApiBaseUrl()}/api/nodes/${id}`, { method: 'PUT', - headers: { 'Content-Type': 'application/json' }, + headers: getInternalAuthHeaders({ 'Content-Type': 'application/json' }), body: JSON.stringify(updates) }); const result = await response.json(); - + if (!response.ok) { return { success: false, diff --git a/src/tools/database/writeContext.ts b/src/tools/database/writeContext.ts deleted file mode 100644 index 6e7231f..0000000 --- a/src/tools/database/writeContext.ts +++ /dev/null @@ -1,75 +0,0 @@ -import { tool } from 'ai'; -import { z } from 'zod'; -import { getInternalApiBaseUrl } from '@/services/runtime/apiBase'; -import { formatNodeForChat } from '../infrastructure/nodeFormatter'; - -export const writeContextTool = tool({ - description: 'Write one atomic durable context node to the graph only after the user has explicitly approved the save. Use this for agent-suggested capture after you already proposed the node briefly and got a clear yes. Prefer ordinary create/update flows for explicit user-directed capture. Never call it unless the user has clearly said yes.', - inputSchema: z.object({ - title: z.string().min(1).max(160).describe('Clear proposed node title.'), - description: z.string().min(1).max(500).describe('Natural description of what this context is and why it matters.'), - source: z.string().optional().describe('Optional source or verbatim user wording to preserve.'), - context_name: z.string().optional().describe('Optional primary context name. Use only when the user explicitly wants this saved under a known context.'), - metadata: z.record(z.any()).optional().describe('Optional metadata patch.'), - confirmed_by_user: z.boolean().describe('Must be true. Reject the write otherwise.'), - }).passthrough(), - execute: async ({ title, description, source, context_name, metadata, confirmed_by_user, ...legacyParams }) => { - if (!confirmed_by_user) { - return { - success: false, - error: 'writeContext requires explicit user confirmation before writing to the graph.', - data: null, - }; - } - - try { - const response = await fetch(`${getInternalApiBaseUrl()}/api/nodes`, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ - title: title.trim(), - description: description.trim(), - source: source?.trim() || undefined, - context_name: context_name?.trim() || undefined, - context_id: typeof legacyParams.context_id === 'number' || legacyParams.context_id === null - ? legacyParams.context_id - : undefined, - metadata: { - captured_by: 'human', - captured_method: 'write_context', - ...(metadata || {}), - }, - }), - }); - - const result = await response.json(); - if (!response.ok) { - return { - success: false, - error: result.error || 'Failed to write context node', - data: null, - }; - } - - const formattedDisplay = formatNodeForChat({ - id: result.data.id, - title: result.data.title, - }); - - return { - success: true, - data: { - ...result.data, - formatted_display: formattedDisplay, - }, - message: `Saved context as ${formattedDisplay}`, - }; - } catch (error) { - return { - success: false, - error: error instanceof Error ? error.message : 'Failed to write context node', - data: null, - }; - } - }, -}); diff --git a/src/tools/infrastructure/groups.ts b/src/tools/infrastructure/groups.ts index 5a730d0..512c8c6 100644 --- a/src/tools/infrastructure/groups.ts +++ b/src/tools/infrastructure/groups.ts @@ -38,8 +38,9 @@ export const TOOL_GROUP_ASSIGNMENTS: Record = { retrieveQueryContext: 'core', getNodesById: 'core', queryEdge: 'core', - queryContexts: 'core', searchContentEmbeddings: 'core', + listSkills: 'core', + readSkill: 'core', // Orchestration: Web search and reasoning (orchestrator only) webSearch: 'orchestration', @@ -47,7 +48,6 @@ export const TOOL_GROUP_ASSIGNMENTS: Record = { // Execution: Write operations and extraction (workers only) createNode: 'execution', - writeContext: 'execution', updateNode: 'execution', deleteNode: 'execution', createEdge: 'execution', @@ -56,6 +56,8 @@ export const TOOL_GROUP_ASSIGNMENTS: Record = { youtubeExtract: 'execution', websiteExtract: 'execution', paperExtract: 'execution', + writeSkill: 'execution', + deleteSkill: 'execution' }; /** diff --git a/src/tools/infrastructure/registry.ts b/src/tools/infrastructure/registry.ts index f496cbb..0a7afef 100644 --- a/src/tools/infrastructure/registry.ts +++ b/src/tools/infrastructure/registry.ts @@ -3,10 +3,8 @@ import { queryNodesTool } from '../database/queryNodes'; import { retrieveQueryContextTool } from '../database/retrieveQueryContext'; import { getNodesByIdTool } from '../database/getNodesById'; import { queryEdgeTool } from '../database/queryEdge'; -import { queryContextsTool } from '../database/queryContexts'; import { createNodeTool } from '../database/createNode'; import { updateNodeTool } from '../database/updateNode'; -import { writeContextTool } from '../database/writeContext'; import { deleteNodeTool } from '../database/deleteNode'; import { createEdgeTool } from '../database/createEdge'; import { updateEdgeTool } from '../database/updateEdge'; @@ -17,17 +15,22 @@ import { youtubeExtractTool } from '../other/youtubeExtract'; import { websiteExtractTool } from '../other/websiteExtract'; import { paperExtractTool } from '../other/paperExtract'; import { sqliteQueryTool } from '../other/sqliteQuery'; +import { listSkillsTool } from '../skills/listSkills'; +import { readSkillTool } from '../skills/readSkill'; +import { writeSkillTool } from '../skills/writeSkill'; +import { deleteSkillTool } from '../skills/deleteSkill'; import { logEvalToolCall } from '@/services/evals/evalsLogger'; -// Read tools (graph queries) +// Read tools (graph queries + skills) const CORE_TOOLS: Record = { sqliteQuery: sqliteQueryTool, queryNodes: queryNodesTool, retrieveQueryContext: retrieveQueryContextTool, getNodesById: getNodesByIdTool, queryEdge: queryEdgeTool, - queryContexts: queryContextsTool, searchContentEmbeddings: searchContentEmbeddingsTool, + listSkills: listSkillsTool, + readSkill: readSkillTool, }; // Utility tools @@ -39,7 +42,6 @@ const ORCHESTRATION_TOOLS: Record = { // Write tools (includes extraction) const EXECUTION_TOOLS: Record = { createNode: createNodeTool, - writeContext: writeContextTool, updateNode: updateNodeTool, deleteNode: deleteNodeTool, createEdge: createEdgeTool, @@ -47,6 +49,8 @@ const EXECUTION_TOOLS: Record = { youtubeExtract: youtubeExtractTool, websiteExtract: websiteExtractTool, paperExtract: paperExtractTool, + writeSkill: writeSkillTool, + deleteSkill: deleteSkillTool, }; export const TOOL_SETS = { diff --git a/src/tools/other/sqliteQuery.ts b/src/tools/other/sqliteQuery.ts index 5b27a58..4ed6ac3 100644 --- a/src/tools/other/sqliteQuery.ts +++ b/src/tools/other/sqliteQuery.ts @@ -45,7 +45,7 @@ function isReadOnlyQuery(sql: string): boolean { } export const sqliteQueryTool = tool({ - description: 'Execute read-only SQL queries (SELECT/WITH/PRAGMA). Tables: nodes, contexts, edges, chunks, logs, and migration snapshots. Use PRAGMA table_info(tablename) for schema.', + description: 'Execute read-only SQL queries (SELECT/WITH/PRAGMA). Tables: nodes, edges, chunks, logs, chats, voice_usage, and migration snapshots. Use PRAGMA table_info(tablename) for schema.', inputSchema: z.object({ sql: z.string().describe('The SQL query to execute. Must be a SELECT, WITH, or PRAGMA statement.'), diff --git a/src/tools/skills/deleteSkill.ts b/src/tools/skills/deleteSkill.ts new file mode 100644 index 0000000..14c1623 --- /dev/null +++ b/src/tools/skills/deleteSkill.ts @@ -0,0 +1,37 @@ +import { tool } from 'ai'; +import { z } from 'zod'; +import { deleteSkill } from '@/services/skills/skillService'; +import { eventBroadcaster } from '@/services/events'; + +export const deleteSkillTool = tool({ + description: 'Delete a skill by name.', + inputSchema: z.object({ + name: z.string().describe('The name of the skill to delete'), + }), + execute: async ({ name }) => { + try { + const result = deleteSkill(name); + if (!result.success) { + return { + success: false, + error: result.error, + data: null, + }; + } + + eventBroadcaster.broadcast({ type: 'GUIDE_UPDATED', data: { name } }); + + return { + success: true, + data: { name }, + message: `Skill "${name}" deleted`, + }; + } catch (error) { + return { + success: false, + error: error instanceof Error ? error.message : 'Failed to delete skill', + data: null, + }; + } + }, +}); diff --git a/src/tools/skills/listSkills.ts b/src/tools/skills/listSkills.ts new file mode 100644 index 0000000..4c41a52 --- /dev/null +++ b/src/tools/skills/listSkills.ts @@ -0,0 +1,24 @@ +import { tool } from 'ai'; +import { z } from 'zod'; +import { listSkills } from '@/services/skills/skillService'; + +export const listSkillsTool = tool({ + description: 'List all available skills with their names and descriptions.', + inputSchema: z.object({}), + execute: async () => { + try { + const skills = listSkills(); + return { + success: true, + data: skills, + message: `Found ${skills.length} skills`, + }; + } catch (error) { + return { + success: false, + error: error instanceof Error ? error.message : 'Failed to list skills', + data: [], + }; + } + }, +}); diff --git a/src/tools/skills/readSkill.ts b/src/tools/skills/readSkill.ts new file mode 100644 index 0000000..2bfbef3 --- /dev/null +++ b/src/tools/skills/readSkill.ts @@ -0,0 +1,34 @@ +import { tool } from 'ai'; +import { z } from 'zod'; +import { readSkill } from '@/services/skills/skillService'; + +export const readSkillTool = tool({ + description: 'Read a skill by name. Returns the full markdown content with instructions.', + inputSchema: z.object({ + name: z.string().describe('The name of the skill to read'), + }), + execute: async ({ name }) => { + try { + const skill = readSkill(name); + if (!skill) { + return { + success: false, + error: `Skill "${name}" not found`, + data: null, + }; + } + + return { + success: true, + data: skill, + message: `Loaded skill: ${skill.name}`, + }; + } catch (error) { + return { + success: false, + error: error instanceof Error ? error.message : 'Failed to read skill', + data: null, + }; + } + }, +}); diff --git a/src/tools/skills/writeSkill.ts b/src/tools/skills/writeSkill.ts new file mode 100644 index 0000000..ab7e2e8 --- /dev/null +++ b/src/tools/skills/writeSkill.ts @@ -0,0 +1,38 @@ +import { tool } from 'ai'; +import { z } from 'zod'; +import { writeSkill } from '@/services/skills/skillService'; +import { eventBroadcaster } from '@/services/events'; + +export const writeSkillTool = tool({ + description: 'Write or update a skill. Content should be full markdown with YAML frontmatter (name, description).', + inputSchema: z.object({ + name: z.string().describe('The name of the skill to write'), + content: z.string().describe('Full markdown content including YAML frontmatter'), + }), + execute: async ({ name, content }) => { + try { + const result = writeSkill(name, content); + if (!result.success) { + return { + success: false, + error: result.error, + data: null, + }; + } + + eventBroadcaster.broadcast({ type: 'GUIDE_UPDATED', data: { name } }); + + return { + success: true, + data: { name }, + message: `Skill "${name}" saved`, + }; + } catch (error) { + return { + success: false, + error: error instanceof Error ? error.message : 'Failed to write skill', + data: null, + }; + } + }, +}); diff --git a/src/types/database.ts b/src/types/database.ts index 0e1da40..942a55a 100644 --- a/src/types/database.ts +++ b/src/types/database.ts @@ -10,23 +10,6 @@ export interface CanonicalNodeMetadata { [key: string]: unknown; } -export interface Context { - id: number; - name: string; - description: string | null; - icon: string | null; - created_at: string; - updated_at: string; -} - -export interface ContextSummary { - id: number; - name: string; - description: string | null; - icon: string | null; - count: number; -} - // New Node-based type system replacing rigid Item categorization export interface Node { id: number; @@ -42,8 +25,6 @@ export interface Node { created_at: string; updated_at: string; edge_count?: number; // Derived count of edges, included in some queries - context_id?: number | null; - context?: Pick | null; // Optional embedding fields embedding_updated_at?: string; @@ -96,7 +77,6 @@ export interface EdgeContext { // New NodeFilters interface replacing rigid ItemFilters export interface NodeFilters { - contextId?: number; search?: string; // Text search in title/content searchMode?: 'standard' | 'hybrid'; // standard = FTS/LIKE, hybrid = add node-vector retrieval chunkStatus?: 'not_chunked' | 'chunking' | 'chunked' | 'error'; @@ -148,3 +128,11 @@ export interface DatabaseError { code?: string; details?: any; } + +export interface Dimension { + name: string; + description?: string | null; + icon?: string | null; + is_priority: boolean; + updated_at: string; +} diff --git a/tests/unit/mcp/stdio-server.test.ts b/tests/unit/mcp/stdio-server.test.ts index cf1045d..ec21653 100644 --- a/tests/unit/mcp/stdio-server.test.ts +++ b/tests/unit/mcp/stdio-server.test.ts @@ -5,20 +5,12 @@ import type { AddressInfo } from 'node:net'; import { Client } from '@modelcontextprotocol/sdk/client/index.js'; import { StdioClientTransport } from '@modelcontextprotocol/sdk/client/stdio.js'; -type ContextRecord = { - id: number; - name: string; - description: string | null; - icon: string | null; -}; - type NodeRecord = { id: number; title: string; description: string | null; source: string | null; link: string | null; - context_id: number | null; metadata: Record; updated_at: string; created_at: string; @@ -31,11 +23,6 @@ type RequestLogEntry = { body: Record | null; }; -const contexts: ContextRecord[] = [ - { id: 1, name: 'Work', description: 'Work projects and execution context.', icon: 'Briefcase' }, - { id: 2, name: 'Personal', description: 'Personal life and planning context.', icon: 'Heart' }, -]; - let server: http.Server; let baseUrl = ''; let nodes: NodeRecord[] = []; @@ -83,19 +70,6 @@ async function handleRequest(req: http.IncomingMessage, res: http.ServerResponse requestLog.push({ method, pathname, body }); if (method === 'POST' && pathname === '/api/nodes') { - const contextId = typeof body?.context_id === 'number' ? body.context_id : null; - const contextName = typeof body?.context_name === 'string' ? body.context_name.trim() : ''; - const resolvedContextId = contextId - ?? (contextName ? contexts.find((context) => context.name.toLowerCase() === contextName.toLowerCase())?.id ?? null : null); - - if (contextId && !contexts.some((context) => context.id === contextId)) { - return sendJson(res, 400, { success: false, error: 'Context not found.' }); - } - - if (contextName && resolvedContextId === null) { - return sendJson(res, 400, { success: false, error: 'Context not found.' }); - } - const timestamp = nowIso(); const node: NodeRecord = { id: nextNodeId++, @@ -103,7 +77,6 @@ async function handleRequest(req: http.IncomingMessage, res: http.ServerResponse description: typeof body?.description === 'string' ? body.description : null, source: typeof body?.source === 'string' ? body.source : null, link: typeof body?.link === 'string' ? body.link : null, - context_id: resolvedContextId, metadata: typeof body?.metadata === 'object' && body.metadata ? body.metadata as Record : {}, updated_at: timestamp, created_at: timestamp, @@ -116,20 +89,20 @@ async function handleRequest(req: http.IncomingMessage, res: http.ServerResponse if (method === 'GET' && pathname === '/api/nodes') { const search = url.searchParams.get('search')?.trim() || ''; const limit = Number(url.searchParams.get('limit') || '10'); - const contextIdParam = url.searchParams.get('contextId'); - const contextId = contextIdParam ? Number(contextIdParam) : undefined; + const sortBy = url.searchParams.get('sortBy'); let filtered = nodes.slice(); if (search) { filtered = filtered.filter((node) => matchesNode(node, search)); } - if (contextId !== undefined) { - filtered = filtered.filter((node) => node.context_id === contextId); + if (sortBy === 'edges') { + filtered.sort((a, b) => b.edge_count - a.edge_count || b.updated_at.localeCompare(a.updated_at)); } return sendJson(res, 200, { success: true, total: filtered.length, + count: filtered.length, data: filtered.slice(0, Number.isFinite(limit) ? limit : 10), }); } @@ -143,46 +116,55 @@ async function handleRequest(req: http.IncomingMessage, res: http.ServerResponse return sendJson(res, 200, { success: true, node }); } - if (method === 'GET' && pathname === '/api/contexts') { - return sendJson(res, 200, { - success: true, - data: contexts.map((context) => ({ - ...context, - count: nodes.filter((node) => node.context_id === context.id).length, - })), - }); - } - - if (method === 'GET' && /^\/api\/contexts\/\d+$/.test(pathname)) { - const id = Number(pathname.split('/').pop()); - const context = contexts.find((entry) => entry.id === id); - if (!context) { - return sendJson(res, 404, { success: false, error: 'Context not found.' }); - } + if (method === 'POST' && pathname === '/api/nodes/direct-search') { + const query = typeof body?.query === 'string' ? body.query : ''; + const limit = typeof body?.limit === 'number' ? body.limit : 10; + const filtered = nodes.filter((node) => matchesNode(node, query)).slice(0, limit); return sendJson(res, 200, { success: true, data: { - ...context, - count: nodes.filter((node) => node.context_id === context.id).length, + count: filtered.length, + nodes: filtered, + filters_applied: { + search: query, + limit, + createdAfter: body?.createdAfter ?? undefined, + createdBefore: body?.createdBefore ?? undefined, + eventAfter: body?.eventAfter ?? undefined, + eventBefore: body?.eventBefore ?? undefined, + }, }, }); } - if (method === 'GET' && /^\/api\/contexts\/\d+\/nodes$/.test(pathname)) { - const parts = pathname.split('/'); - const id = Number(parts[parts.length - 2]); + if (method === 'POST' && pathname === '/api/retrieval/query-context') { return sendJson(res, 200, { success: true, - data: nodes - .filter((node) => node.context_id === id) - .map((node) => ({ + data: { + query: typeof body?.query === 'string' ? body.query : '', + shouldRetrieve: true, + mode: 'query', + reason: 'Retrieved graph context.', + focused_node_id: typeof body?.focused_node_id === 'number' ? body.focused_node_id : null, + nodes: nodes.slice(0, 1).map((node) => ({ id: node.id, title: node.title, description: node.description, link: node.link, - context_id: node.context_id, updated_at: node.updated_at, + kind: 'query_match', + reason: 'Matched the query through direct graph search.', })), + chunks: [], + }, + }); + } + + if (method === 'GET' && pathname === '/api/edges') { + return sendJson(res, 200, { + success: true, + count: 3, + data: [], }); } @@ -246,34 +228,35 @@ describe('stdio MCP server contract', () => { resetState(); }); - it('does not expose dimension tools or dimension fields in the MCP registry', async () => { + it('does not expose removed context tools or fields in the MCP registry', async () => { await withMcpClient(async (client) => { const result = await client.listTools(); const toolNames = result.tools.map((tool) => tool.name); expect(toolNames).not.toEqual(expect.arrayContaining([ - 'rah_query_dimensions', - 'rah_create_dimension', - 'rah_update_dimension', - 'rah_delete_dimension', - 'rah_get_dimension', + 'rah_query_contexts', + 'rah_write_context', ])); const addNodeTool = result.tools.find((tool) => tool.name === 'rah_add_node'); expect(addNodeTool).toBeDefined(); - expect(addNodeTool?.inputSchema).toBeTruthy(); - expect(JSON.stringify(addNodeTool?.inputSchema)).not.toContain('dimensions'); + expect(JSON.stringify(addNodeTool?.inputSchema)).not.toContain('context_name'); + expect(JSON.stringify(addNodeTool?.inputSchema)).not.toContain('context_id'); + + const retrieveTool = result.tools.find((tool) => tool.name === 'rah_retrieve_query_context'); + expect(retrieveTool).toBeDefined(); + expect(JSON.stringify(retrieveTool?.inputSchema)).not.toContain('active_context_id'); }); }); - it('creates nodes through MCP without context and without legacy taxonomy payloads', async () => { + it('creates and searches nodes through MCP without context-era payloads', async () => { await withMcpClient(async (client) => { const result = await client.callTool({ name: 'rah_add_node', arguments: { title: 'MCP Contract Test Node', - description: 'Node created through MCP to verify the post-dimensions write contract.', - source: 'Concrete source text proving rah_add_node works without dimensions or automatic context assignment.', + description: 'Node created through MCP to verify the no-context write contract.', + source: 'Concrete source text proving rah_add_node works without context fields.', metadata: { source: 'mcp-contract-test' }, }, }); @@ -285,19 +268,19 @@ describe('stdio MCP server contract', () => { const createRequest = requestLog.find((entry) => entry.method === 'POST' && entry.pathname === '/api/nodes'); expect(createRequest?.body).toMatchObject({ title: 'MCP Contract Test Node', - description: 'Node created through MCP to verify the post-dimensions write contract.', - source: 'Concrete source text proving rah_add_node works without dimensions or automatic context assignment.', + description: 'Node created through MCP to verify the no-context write contract.', + source: 'Concrete source text proving rah_add_node works without context fields.', metadata: { source: 'mcp-contract-test' }, }); - expect(createRequest?.body).not.toHaveProperty('dimensions'); + expect(createRequest?.body).not.toHaveProperty('context_name'); expect(createRequest?.body).not.toHaveProperty('context_id'); - expect(nodes[0]?.context_id).toBeNull(); const searchResult = await client.callTool({ name: 'rah_search_nodes', arguments: { query: 'contract test node', limit: 5, + createdAfter: '2026-01-01', }, }); @@ -307,36 +290,25 @@ describe('stdio MCP server contract', () => { id: structured.nodeId, title: 'MCP Contract Test Node', }); - expect(searchStructured.nodes[0]).not.toHaveProperty('dimensions'); - }); - }); - it('surfaces invalid explicit contexts instead of inferring a fallback context', async () => { - await withMcpClient(async (client) => { - const result = await client.callTool({ - name: 'rah_add_node', - arguments: { - title: 'Bad Context Node', - description: 'This should fail because the context does not exist.', - source: 'Source text for invalid context test.', - context_id: 999, - }, + const searchRequest = requestLog.find((entry) => entry.method === 'POST' && entry.pathname === '/api/nodes/direct-search'); + expect(searchRequest?.body).toMatchObject({ + query: 'contract test node', + limit: 5, + createdAfter: '2026-01-01', }); - - expect(result.isError).toBe(true); - expect(JSON.stringify(result.content)).toContain('Context not found'); - expect(nodes).toHaveLength(0); + expect(searchRequest?.body).not.toHaveProperty('context_name'); + expect(searchStructured.nodes[0]).not.toHaveProperty('context_id'); }); }); - it('returns soft-context orientation data without dimension state', async () => { + it('returns orientation data without contexts and retrieval payloads without active context ids', async () => { nodes.push({ id: nextNodeId++, title: 'Work Hub Node', description: 'High-signal work hub used to verify MCP context orientation.', source: 'Hub node source', link: null, - context_id: 1, metadata: {}, updated_at: nowIso(), created_at: nowIso(), @@ -344,28 +316,24 @@ describe('stdio MCP server contract', () => { }); await withMcpClient(async (client) => { - const contextsResult = await client.callTool({ - name: 'rah_query_contexts', + const retrievalResult = await client.callTool({ + name: 'rah_retrieve_query_context', arguments: { - contextId: 1, - includeNodes: true, + query: 'help me think about work hub priorities', + focused_node_id: 1, }, }); - const contextsStructured = getStructured<{ - count: number; - contexts: Array<{ id: number; name: string; nodes?: Array> }>; - }>(contextsResult); - expect(contextsStructured.count).toBe(1); - expect(contextsStructured.contexts[0]).toMatchObject({ - id: 1, - name: 'Work', + const retrievalRequest = requestLog.find((entry) => entry.method === 'POST' && entry.pathname === '/api/retrieval/query-context'); + expect(retrievalRequest?.body).toMatchObject({ + query: 'help me think about work hub priorities', + focused_node_id: 1, }); - expect(contextsStructured.contexts[0].nodes?.[0]).toMatchObject({ - title: 'Work Hub Node', - context_id: 1, - }); - expect(contextsStructured.contexts[0].nodes?.[0]).not.toHaveProperty('dimensions'); + expect(retrievalRequest?.body).not.toHaveProperty('active_context_id'); + + const retrievalStructured = getStructured<{ focused_node_id: number | null; nodes: Array<{ title: string }> }>(retrievalResult); + expect(retrievalStructured.focused_node_id).toBe(1); + expect(retrievalStructured.nodes[0]?.title).toBe('Work Hub Node'); const graphContextResult = await client.callTool({ name: 'rah_get_context', @@ -373,24 +341,18 @@ describe('stdio MCP server contract', () => { }); const graphStructured = getStructured<{ - stats: { contextCount: number }; - contexts: Array<{ id: number; name: string }>; + stats: { nodeCount: number; edgeCount: number }; hubNodes: Array<{ title: string }>; guides: string[]; }>(graphContextResult); - expect(graphStructured.stats.contextCount).toBe(contexts.length); - expect(graphStructured.contexts).toEqual( - expect.arrayContaining([ - expect.objectContaining({ id: 1, name: 'Work' }), - expect.objectContaining({ id: 2, name: 'Personal' }), - ]) - ); + expect(graphStructured.stats).toEqual({ nodeCount: 1, edgeCount: 3 }); expect(graphStructured.hubNodes).toEqual( expect.arrayContaining([expect.objectContaining({ title: 'Work Hub Node' })]) ); expect(graphStructured.guides).toEqual(expect.arrayContaining(['schema', 'creating-nodes'])); - expect(JSON.stringify(graphStructured)).not.toContain('dimensions'); + expect(graphStructured).not.toHaveProperty('contexts'); + expect(graphStructured.stats).not.toHaveProperty('contextCount'); }); }); }); diff --git a/tests/unit/services/contextService.test.ts b/tests/unit/services/contextService.test.ts deleted file mode 100644 index bcdd1ba..0000000 --- a/tests/unit/services/contextService.test.ts +++ /dev/null @@ -1,93 +0,0 @@ -import { beforeEach, describe, expect, it, vi } from 'vitest'; - -vi.mock('@/services/database/sqlite-client', () => ({ - getSQLiteClient: vi.fn(), -})); - -vi.mock('@/services/database/nodes', () => ({ - nodeService: { - getNodes: vi.fn(), - }, -})); - -import { getSQLiteClient } from '@/services/database/sqlite-client'; -import { ContextService, MAX_CONTEXTS_PER_ACCOUNT } from '@/services/database/contextService'; - -type QueryResult = { rows: Array> }; - -describe('ContextService', () => { - const query = vi.fn<(...args: unknown[]) => QueryResult>(); - const run = vi.fn(); - const prepare = vi.fn(() => ({ run })); - const sqlite = { query, prepare }; - - beforeEach(() => { - query.mockReset(); - prepare.mockClear(); - run.mockReset(); - vi.mocked(getSQLiteClient).mockReturnValue(sqlite as never); - }); - - it('rejects creating a context once the account reaches the hard cap', async () => { - query.mockImplementation((sql: unknown) => { - const text = String(sql); - if (text.includes('WHERE LOWER(TRIM(name))')) { - return { rows: [] }; - } - if (text.includes('SELECT COUNT(*) AS count')) { - return { rows: [{ count: MAX_CONTEXTS_PER_ACCOUNT }] }; - } - return { rows: [] }; - }); - - const service = new ContextService(); - - await expect( - service.createContext({ - name: 'Health', - description: 'Health-related items.', - }) - ).rejects.toThrow(`Maximum ${MAX_CONTEXTS_PER_ACCOUNT} contexts are allowed per account.`); - - expect(prepare).not.toHaveBeenCalled(); - }); - - it('creates a context when the account is below the hard cap', async () => { - run.mockReturnValue({ lastInsertRowid: 11 }); - query.mockImplementation((sql: unknown) => { - const text = String(sql); - if (text.includes('WHERE LOWER(TRIM(name))')) { - return { rows: [] }; - } - if (text.includes('SELECT COUNT(*) AS count')) { - return { rows: [{ count: MAX_CONTEXTS_PER_ACCOUNT - 1 }] }; - } - if (text.includes('WHERE c.id = ?')) { - return { - rows: [{ - id: 11, - name: 'Health', - description: 'Health-related items.', - icon: null, - count: 0, - }], - }; - } - return { rows: [] }; - }); - - const service = new ContextService(); - const created = await service.createContext({ - name: 'Health', - description: 'Health-related items.', - }); - - expect(prepare).toHaveBeenCalledOnce(); - expect(created).toMatchObject({ - id: 11, - name: 'Health', - description: 'Health-related items.', - icon: null, - }); - }); -});