feat: sync open-source context and capsule removal

- remove legacy contexts surfaces from the app, MCP bridge, standalone server, and docs
- keep getContext and retrieveQueryContext while aligning the simplified graph-only contract
- harden dev startup migration behavior and disable the accidental chat surface in open source

Generated with Codex
This commit is contained in:
“BeeRad”
2026-04-17 12:34:51 +10:00
parent 97eeb0789f
commit 07754f5030
87 changed files with 2688 additions and 4861 deletions
+5 -7
View File
@@ -29,10 +29,10 @@ Your data stays on your machine. Nothing is sent anywhere unless you configure a
Current contract: Current contract:
- no runtime `dimensions` - no runtime `dimensions`
- optional `contexts` - no separate runtime `contexts` layer or context capsule
- node quality comes from `title`, `description`, `source`, `metadata`, and explicit `edges` - node quality comes from `title`, `description`, `source`, `metadata`, and explicit `edges`
- direct node lookup first for specific-node intent - 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 - 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: Available tools:
| Tool | What it does | | 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 | | `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 | | `queryNodes` | Find nodes by keyword |
| `createNode` | Create a new node | | `createNode` | Create a new node |
| `writeContext` | Save one confirmed durable context node after explicit user approval |
| `getNodesById` | Fetch nodes by ID | | `getNodesById` | Fetch nodes by ID |
| `updateNode` | Edit an existing node | | `updateNode` | Edit an existing node |
| `createEdge` | Link two nodes together after explicit confirmation | | `createEdge` | Link two nodes together after explicit confirmation |
@@ -158,7 +156,7 @@ Available tools:
- "What's in my knowledge graph?" - "What's in my knowledge graph?"
- "Search my knowledge base for notes about React performance" - "Search my knowledge base for notes about React performance"
- "Add a node about the article I just read on transformers" - "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"
--- ---
-22
View File
@@ -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 });
}
}
-26
View File
@@ -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 });
}
}
-47
View File
@@ -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 });
}
}
+8 -8
View File
@@ -5,8 +5,6 @@ import { chunkService } from '@/services/database/chunks';
export async function GET() { export async function GET() {
try { try {
const sqlite = getSQLiteClient(); const sqlite = getSQLiteClient();
const vectorCapability = sqlite.getVectorCapability();
// Test basic database connection // Test basic database connection
const connectionTest = await sqlite.testConnection(); const connectionTest = await sqlite.testConnection();
if (!connectionTest) { if (!connectionTest) {
@@ -21,7 +19,7 @@ export async function GET() {
const vectorExtensionTest = await sqlite.checkVectorExtension(); const vectorExtensionTest = await sqlite.checkVectorExtension();
let vectorStats = null; let vectorStats = null;
let chunkStats = null; let chunkStats = null;
let vectorHealth = vectorCapability.available ? 'healthy' : 'unavailable'; let vectorHealth = vectorExtensionTest ? 'healthy' : 'unavailable';
try { try {
const totalChunks = await chunkService.getChunkCount(); const totalChunks = await chunkService.getChunkCount();
@@ -32,7 +30,7 @@ export async function GET() {
coverage_percentage: null, coverage_percentage: null,
}; };
if (vectorCapability.available && vectorExtensionTest) { if (vectorExtensionTest) {
try { try {
const chunksWithoutEmbeddings = await chunkService.getChunksWithoutEmbeddings(); const chunksWithoutEmbeddings = await chunkService.getChunksWithoutEmbeddings();
const vectorizedCount = totalChunks - chunksWithoutEmbeddings.length; const vectorizedCount = totalChunks - chunksWithoutEmbeddings.length;
@@ -62,9 +60,8 @@ export async function GET() {
} else { } else {
vectorHealth = 'unavailable'; vectorHealth = 'unavailable';
vectorStats = { vectorStats = {
backend: vectorCapability.backend, extension_loaded: false,
extension_path: vectorCapability.extensionPath, reason: 'Vector extension unavailable in this environment.',
reason: vectorCapability.available ? null : vectorCapability.reason,
}; };
} }
@@ -81,7 +78,10 @@ export async function GET() {
data: { data: {
database_connected: connectionTest, database_connected: connectionTest,
vector_extension_loaded: vectorExtensionTest, vector_extension_loaded: vectorExtensionTest,
vector_capability: vectorCapability, vector_capability: {
available: vectorExtensionTest,
backend: vectorExtensionTest ? 'sqlite-vec' : 'unavailable',
},
vector_health: vectorHealth, vector_health: vectorHealth,
chunk_stats: chunkStats, chunk_stats: chunkStats,
vector_stats: vectorStats, vector_stats: vectorStats,
+25 -172
View File
@@ -13,15 +13,13 @@ const SERVER_INFO = {
const instructions = [ const instructions = [
'RA-H is a personal knowledge graph — local-first, vendor-neutral.', '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 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.', '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 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.', 'Search before creating: use rah_search_nodes to check if content already exists.',
'Every edge needs an explanation: why does this connection exist?', '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 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(' '); ].join(' ');
function getBaseUrl(request: NextRequest): string { function getBaseUrl(request: NextRequest): string {
@@ -56,19 +54,18 @@ function createServer(request: NextRequest): McpServer {
'rah_add_node', 'rah_add_node',
{ {
title: 'Add RA-H 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: { inputSchema: {
title: z.string().min(1).max(160), title: z.string().min(1).max(160),
content: z.string().max(20000).optional(), content: z.string().max(20000).optional(),
source: z.string().max(50000).optional(), source: z.string().max(50000).optional(),
link: z.string().url().optional(), link: z.string().url().optional(),
description: z.string().max(500).optional(), description: z.string().max(500).optional(),
context_name: z.string().optional(),
metadata: z.record(z.any()).optional(), metadata: z.record(z.any()).optional(),
chunk: z.string().max(50000).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', { const payload = await callRaHApi(request, '/api/nodes', {
method: 'POST', method: 'POST',
body: JSON.stringify({ body: JSON.stringify({
@@ -76,7 +73,6 @@ function createServer(request: NextRequest): McpServer {
source: source?.trim() || content?.trim() || chunk?.trim() || undefined, source: source?.trim() || content?.trim() || chunk?.trim() || undefined,
link: link?.trim() || undefined, link: link?.trim() || undefined,
description: description?.trim() || undefined, description: description?.trim() || undefined,
context_name: context_name?.trim() || undefined,
metadata: metadata || {}, metadata: metadata || {},
}), }),
}); });
@@ -97,20 +93,26 @@ function createServer(request: NextRequest): McpServer {
'rah_search_nodes', 'rah_search_nodes',
{ {
title: 'Search RA-H 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: { inputSchema: {
query: z.string().min(1).max(400), query: z.string().min(1).max(400),
limit: z.number().min(1).max(25).optional(), 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', { const payload = await callRaHApi(request, '/api/nodes/direct-search', {
method: 'POST', method: 'POST',
body: JSON.stringify({ body: JSON.stringify({
query: query.trim(), query: query.trim(),
limit: Math.min(Math.max(limit, 1), 25), 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 : []; const nodes = Array.isArray(payload.data?.nodes) ? payload.data.nodes : [];
@@ -140,17 +142,15 @@ function createServer(request: NextRequest): McpServer {
inputSchema: { inputSchema: {
query: z.string().min(1).max(1000), query: z.string().min(1).max(1000),
focused_node_id: z.number().int().positive().nullable().optional(), 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(), 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', { const payload = await callRaHApi(request, '/api/retrieval/query-context', {
method: 'POST', method: 'POST',
body: JSON.stringify({ body: JSON.stringify({
query, query,
focused_node_id: focused_node_id ?? null, focused_node_id: focused_node_id ?? null,
active_context_id: active_context_id ?? null,
limit, 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( server.registerTool(
'rah_update_node', 'rah_update_node',
{ {
title: 'Update RA-H 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: { inputSchema: {
id: z.number().int().positive(), id: z.number().int().positive(),
updates: z.object({ updates: z.object({
@@ -262,8 +180,6 @@ function createServer(request: NextRequest): McpServer {
content: z.string().optional(), content: z.string().optional(),
source: z.string().optional(), source: z.string().optional(),
link: z.string().optional(), link: z.string().optional(),
context_name: z.string().optional(),
clear_context: z.boolean().optional(),
metadata: z.record(z.any()).optional(), metadata: z.record(z.any()).optional(),
}), }),
}, },
@@ -283,10 +199,6 @@ function createServer(request: NextRequest): McpServer {
delete mappedUpdates.content; delete mappedUpdates.content;
delete mappedUpdates.chunk; 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}`, { const payload = await callRaHApi(request, `/api/nodes/${id}`, {
method: 'PUT', method: 'PUT',
body: JSON.stringify(mappedUpdates), body: JSON.stringify(mappedUpdates),
@@ -571,15 +483,15 @@ function createServer(request: NextRequest): McpServer {
'rah_get_context', 'rah_get_context',
{ {
title: 'Get RA-H 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: {}, inputSchema: {},
}, },
async () => { async () => {
const [hubPayload, contextsPayload, guidesPayload, countPayload] = await Promise.all([ const [hubPayload, guidesPayload, countPayload, edgesPayload] = await Promise.all([
callRaHApi(request, '/api/nodes?sortBy=edges&limit=5'), callRaHApi(request, '/api/nodes?sortBy=edges&limit=10'),
callRaHApi(request, '/api/contexts'),
callRaHApi(request, '/api/guides').catch(() => ({ data: [] })), 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) => ({ 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, 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 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 { 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: { structuredContent: {
stats: { stats: {
nodeCount: countPayload.total ?? 0, nodeCount,
edgeCount: 0, edgeCount,
contextCount: contexts.length,
}, },
hubNodes, hubNodes,
contexts,
guides, 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; return server;
} }
@@ -717,7 +572,6 @@ export async function GET(request: NextRequest) {
'rah_add_node', 'rah_add_node',
'rah_search_nodes', 'rah_search_nodes',
'rah_retrieve_query_context', 'rah_retrieve_query_context',
'rah_query_contexts',
'rah_update_node', 'rah_update_node',
'rah_get_nodes', 'rah_get_nodes',
'rah_create_edge', 'rah_create_edge',
@@ -728,7 +582,6 @@ export async function GET(request: NextRequest) {
'rah_extract_youtube', 'rah_extract_youtube',
'rah_extract_pdf', 'rah_extract_pdf',
'rah_get_context', 'rah_get_context',
'rah_write_context',
]; ];
return NextResponse.json( return NextResponse.json(
+16 -32
View File
@@ -1,8 +1,9 @@
import { NextRequest, NextResponse } from 'next/server'; import { NextRequest, NextResponse } from 'next/server';
import { contextService, nodeService } from '@/services/database'; import { nodeService } from '@/services/database';
import { autoEmbedQueue } from '@/services/embedding/autoEmbedQueue'; import { autoEmbedQueue } from '@/services/embedding/autoEmbedQueue';
import { hasSufficientContent } from '@/services/embedding/constants'; import { hasSufficientContent } from '@/services/embedding/constants';
import { coerceDescriptionForStorage } from '@/services/database/quality'; import { coerceDescriptionForStorage } from '@/services/database/quality';
import { applyRequestSupabaseAuth, getCurrentSupabaseToken } from '@/services/auth/internalAuth';
import { normalizeNodeLink } from '@/utils/nodeLink'; import { normalizeNodeLink } from '@/utils/nodeLink';
import { mergeNodeMetadata } from '@/services/nodes/metadata'; import { mergeNodeMetadata } from '@/services/nodes/metadata';
@@ -12,6 +13,7 @@ export async function GET(
request: NextRequest, request: NextRequest,
{ params }: { params: Promise<{ id: string }> } { params }: { params: Promise<{ id: string }> }
) { ) {
const cleanupAuth = applyRequestSupabaseAuth(request);
try { try {
const { id } = await params; const { id } = await params;
const nodeId = parseInt(id, 10); const nodeId = parseInt(id, 10);
@@ -43,6 +45,8 @@ export async function GET(
success: false, success: false,
error: error instanceof Error ? error.message : 'Failed to fetch node' error: error instanceof Error ? error.message : 'Failed to fetch node'
}, { status: 500 }); }, { status: 500 });
} finally {
cleanupAuth();
} }
} }
@@ -50,6 +54,7 @@ export async function PUT(
request: NextRequest, request: NextRequest,
{ params }: { params: Promise<{ id: string }> } { params }: { params: Promise<{ id: string }> }
) { ) {
const cleanupAuth = applyRequestSupabaseAuth(request);
try { try {
const { id } = await params; const { id } = await params;
const nodeId = parseInt(id, 10); const nodeId = parseInt(id, 10);
@@ -100,34 +105,6 @@ export async function PUT(
updates.metadata = mergeNodeMetadata(existingNode.metadata, body.metadata); 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 incomingSource = typeof body.source === 'string' ? body.source : undefined;
const existingSource = existingNode.source ?? ''; const existingSource = existingNode.source ?? '';
@@ -147,9 +124,11 @@ export async function PUT(
const node = await nodeService.updateNode(nodeId, updates); const node = await nodeService.updateNode(nodeId, updates);
if (shouldQueueEmbed) { if (shouldQueueEmbed) {
autoEmbedQueue.enqueue(nodeId, { reason: 'node_updated' }); autoEmbedQueue.enqueue(nodeId, {
} reason: 'node_updated',
});
}
return NextResponse.json({ return NextResponse.json({
success: true, success: true,
@@ -162,6 +141,8 @@ export async function PUT(
success: false, success: false,
error: error instanceof Error ? error.message : 'Failed to update node' error: error instanceof Error ? error.message : 'Failed to update node'
}, { status: 500 }); }, { status: 500 });
} finally {
cleanupAuth();
} }
} }
@@ -169,6 +150,7 @@ export async function DELETE(
request: NextRequest, request: NextRequest,
{ params }: { params: Promise<{ id: string }> } { params }: { params: Promise<{ id: string }> }
) { ) {
const cleanupAuth = applyRequestSupabaseAuth(request);
try { try {
const { id } = await params; const { id } = await params;
const nodeId = parseInt(id, 10); const nodeId = parseInt(id, 10);
@@ -193,5 +175,7 @@ export async function DELETE(
success: false, success: false,
error: error instanceof Error ? error.message : 'Failed to delete node' error: error instanceof Error ? error.message : 'Failed to delete node'
}, { status: 500 }); }, { status: 500 });
} finally {
cleanupAuth();
} }
} }
+5 -2
View File
@@ -1,9 +1,12 @@
import { NextRequest, NextResponse } from 'next/server'; import { NextRequest, NextResponse } from 'next/server';
import { applyRequestSupabaseAuth } from '@/services/auth/internalAuth';
import { directNodeLookup } from '@/services/retrieval/directNodeLookup'; import { directNodeLookup } from '@/services/retrieval/directNodeLookup';
export const runtime = 'nodejs'; export const runtime = 'nodejs';
export async function POST(request: NextRequest) { export async function POST(request: NextRequest) {
const cleanupAuth = applyRequestSupabaseAuth(request);
try { try {
const body = await request.json(); const body = await request.json();
const query = typeof body.query === 'string' ? body.query : ''; const query = typeof body.query === 'string' ? body.query : '';
@@ -18,8 +21,6 @@ export async function POST(request: NextRequest) {
const result = await directNodeLookup({ const result = await directNodeLookup({
search: query, search: query,
limit: typeof body.limit === 'number' ? body.limit : undefined, 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, createdAfter: typeof body.createdAfter === 'string' ? body.createdAfter : undefined,
createdBefore: typeof body.createdBefore === 'string' ? body.createdBefore : undefined, createdBefore: typeof body.createdBefore === 'string' ? body.createdBefore : undefined,
eventAfter: typeof body.eventAfter === 'string' ? body.eventAfter : undefined, eventAfter: typeof body.eventAfter === 'string' ? body.eventAfter : undefined,
@@ -39,5 +40,7 @@ export async function POST(request: NextRequest) {
success: false, success: false,
error: error instanceof Error ? error.message : 'Failed to run direct node lookup', error: error instanceof Error ? error.message : 'Failed to run direct node lookup',
}, { status: 500 }); }, { status: 500 });
} finally {
cleanupAuth();
} }
} }
+11 -24
View File
@@ -1,15 +1,17 @@
import { NextRequest, NextResponse } from 'next/server'; import { NextRequest, NextResponse } from 'next/server';
import { contextService, nodeService } from '@/services/database'; import { nodeService } from '@/services/database';
import { Node, NodeFilters } from '@/types/database'; import { Node, NodeFilters } from '@/types/database';
import { autoEmbedQueue } from '@/services/embedding/autoEmbedQueue'; import { autoEmbedQueue } from '@/services/embedding/autoEmbedQueue';
import { generateDescription } from '@/services/database/descriptionService'; import { generateDescription } from '@/services/database/descriptionService';
import { coerceDescriptionForStorage } from '@/services/database/quality'; import { coerceDescriptionForStorage } from '@/services/database/quality';
import { applyRequestSupabaseAuth, getCurrentSupabaseToken } from '@/services/auth/internalAuth';
import { normalizeNodeLink } from '@/utils/nodeLink'; import { normalizeNodeLink } from '@/utils/nodeLink';
import { buildCanonicalNodeMetadata } from '@/services/nodes/metadata'; import { buildCanonicalNodeMetadata } from '@/services/nodes/metadata';
export const runtime = 'nodejs'; export const runtime = 'nodejs';
export async function GET(request: NextRequest) { export async function GET(request: NextRequest) {
const cleanupAuth = applyRequestSupabaseAuth(request);
try { try {
const searchParams = request.nextUrl.searchParams; const searchParams = request.nextUrl.searchParams;
@@ -19,14 +21,6 @@ export async function GET(request: NextRequest) {
offset: searchParams.get('offset') ? parseInt(searchParams.get('offset')!) : 0 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) // Handle sortBy parameter (sortBy=edges|updated|created)
const sortByParam = searchParams.get('sortBy'); const sortByParam = searchParams.get('sortBy');
if (sortByParam === 'edges' || sortByParam === 'updated' || sortByParam === 'created' || sortByParam === 'event_date') { if (sortByParam === 'edges' || sortByParam === 'updated' || sortByParam === 'created' || sortByParam === 'event_date') {
@@ -57,6 +51,8 @@ export async function GET(request: NextRequest) {
success: false, success: false,
error: error instanceof Error ? error.message : 'Failed to fetch nodes' error: error instanceof Error ? error.message : 'Failed to fetch nodes'
}, { status: 500 }); }, { status: 500 });
} finally {
cleanupAuth();
} }
} }
@@ -75,6 +71,7 @@ function sanitizeTitle(title: string): string {
} }
export async function POST(request: NextRequest) { export async function POST(request: NextRequest) {
const cleanupAuth = applyRequestSupabaseAuth(request);
try { try {
const body = await request.json(); const body = await request.json();
@@ -148,19 +145,6 @@ export async function POST(request: NextRequest) {
? body.metadata.source ? body.metadata.source
: undefined; : 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({ const node = await nodeService.createNode({
title: body.title, title: body.title,
description: finalDescription, description: finalDescription,
@@ -168,7 +152,6 @@ export async function POST(request: NextRequest) {
event_date: eventDate ?? undefined, event_date: eventDate ?? undefined,
link: normalizedLink ?? undefined, link: normalizedLink ?? undefined,
chunk_status: chunkStatus, chunk_status: chunkStatus,
context_id: resolvedContextId,
metadata: buildCanonicalNodeMetadata({ metadata: buildCanonicalNodeMetadata({
metadata: body.metadata || {}, metadata: body.metadata || {},
type: inferredType, type: inferredType,
@@ -177,7 +160,9 @@ export async function POST(request: NextRequest) {
}); });
if (chunkStatus === 'not_chunked' && node.id) { if (chunkStatus === 'not_chunked' && node.id) {
autoEmbedQueue.enqueue(node.id, { reason: 'node_created' }); autoEmbedQueue.enqueue(node.id, {
reason: 'node_created',
});
} }
return NextResponse.json({ return NextResponse.json({
@@ -192,5 +177,7 @@ export async function POST(request: NextRequest) {
success: false, success: false,
error: error instanceof Error ? error.message : 'Failed to create node' error: error instanceof Error ? error.message : 'Failed to create node'
}, { status: 500 }); }, { status: 500 });
} finally {
cleanupAuth();
} }
} }
+5 -1
View File
@@ -1,9 +1,12 @@
import { NextRequest, NextResponse } from 'next/server'; import { NextRequest, NextResponse } from 'next/server';
import { applyRequestSupabaseAuth } from '@/services/auth/internalAuth';
import { retrieveQueryContext } from '@/services/retrieval/queryContext'; import { retrieveQueryContext } from '@/services/retrieval/queryContext';
export const runtime = 'nodejs'; export const runtime = 'nodejs';
export async function POST(request: NextRequest) { export async function POST(request: NextRequest) {
const cleanupAuth = applyRequestSupabaseAuth(request);
try { try {
const body = await request.json(); const body = await request.json();
const query = typeof body.query === 'string' ? body.query : ''; const query = typeof body.query === 'string' ? body.query : '';
@@ -18,7 +21,6 @@ export async function POST(request: NextRequest) {
const result = await retrieveQueryContext({ const result = await retrieveQueryContext({
query, query,
focused_node_id: typeof body.focused_node_id === 'number' ? body.focused_node_id : null, 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, limit: typeof body.limit === 'number' ? body.limit : undefined,
}); });
@@ -31,5 +33,7 @@ export async function POST(request: NextRequest) {
success: false, success: false,
error: error instanceof Error ? error.message : 'Failed to retrieve query context', error: error instanceof Error ? error.message : 'Failed to retrieve query context',
}, { status: 500 }); }, { status: 500 });
} finally {
cleanupAuth();
} }
} }
+1 -11
View File
@@ -80,12 +80,10 @@ Do not create contradictory instruction files. Prefer one short reinforcement li
| Tool | Description | | 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 | | `retrieveQueryContext` | Pull relevant graph context for a broader current-turn task |
| `createNode` | Create a new node | | `createNode` | Create a new node |
| `writeContext` | Save one confirmed durable context node after explicit user approval |
| `queryNodes` | Search nodes by keyword | | `queryNodes` | Search nodes by keyword |
| `queryContexts` | List or inspect contexts |
| `getNodesById` | Load nodes by ID | | `getNodesById` | Load nodes by ID |
| `updateNode` | Update an existing node | | `updateNode` | Update an existing node |
| `createEdge` | Create a confirmed connection between nodes | | `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 - use `captured_by = "human"` for direct user creation and user-requested agent capture
- reserve `captured_by = "agent"` for autonomous/background creation only - 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 ## Writeback Rule
- do not ask to save every moderately useful point from the conversation - do not ask to save every moderately useful point from the conversation
- only suggest a save when the context is unusually durable and valuable - 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?` - 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 ## Edge Rule
+12 -176
View File
@@ -32,7 +32,6 @@ const packageJson = require('./package.json');
const { initDatabase, getDatabasePath, closeDatabase, getDb, query } = require('./services/sqlite-client'); const { initDatabase, getDatabasePath, closeDatabase, getDb, query } = require('./services/sqlite-client');
const nodeService = require('./services/nodeService'); const nodeService = require('./services/nodeService');
const edgeService = require('./services/edgeService'); const edgeService = require('./services/edgeService');
const contextService = require('./services/contextService');
const skillService = require('./services/skillService'); const skillService = require('./services/skillService');
const retrievalService = require('./services/retrievalService'); const retrievalService = require('./services/retrievalService');
const { directNodeLookup } = require('./services/directNodeLookupService'); const { directNodeLookup } = require('./services/directNodeLookupService');
@@ -68,17 +67,10 @@ function buildInstructions() {
5. For simple tasks, tool descriptions have everything you need. 5. For simple tasks, tool descriptions have everything you need.
6. For complex tasks, call readSkill("db-operations"). 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 ## 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? 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. 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. 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. 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'), source: z.string().max(50000).optional().describe('Canonical source content for embedding'),
link: z.string().url().optional().describe('Source URL'), 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.'), 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.'), 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') chunk: z.string().max(50000).optional().describe('Legacy alias for source text')
}; };
@@ -104,7 +95,6 @@ const addNodeInputSchema = {
const searchNodesInputSchema = { const searchNodesInputSchema = {
query: z.string().min(1).max(400).describe('Search query'), query: z.string().min(1).max(400).describe('Search query'),
limit: z.number().min(1).max(50).optional().describe('Max results (default 10)'), 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_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.'), 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.'), 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 = { const retrieveQueryContextInputSchema = {
query: z.string().min(1).max(800).describe('The raw user query for this turn'), 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'), 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') 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 = { const getNodesInputSchema = {
nodeIds: z.array(z.number().int().positive()).min(1).max(10).describe('Node IDs to load') 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'), content: z.string().optional().describe('Legacy alias for source'),
source: z.string().optional().describe('Canonical source content for embedding'), source: z.string().optional().describe('Canonical source content for embedding'),
link: z.string().optional().describe('New link'), 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.') 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') }).describe('Fields to update')
}; };
@@ -163,14 +141,6 @@ const queryEdgesInputSchema = {
limit: z.number().min(1).max(50).optional().describe('Max edges (default 25)') 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 = { const readSkillInputSchema = {
name: z.string().min(1).describe('Skill name (e.g. "db-operations", "onboarding", "persona")') name: z.string().min(1).describe('Skill name (e.g. "db-operations", "onboarding", "persona")')
}; };
@@ -276,7 +246,7 @@ async function main() {
'getContext', 'getContext',
{ {
title: 'Get RA-H context', 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: {} inputSchema: {}
}, },
async () => { async () => {
@@ -288,16 +258,16 @@ async function main() {
// First-run welcome message // First-run welcome message
if (context.stats.nodeCount === 0) { if (context.stats.nodeCount === 0) {
return { 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: { structuredContent: {
...context, ...context,
welcome: true, 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 { return {
content: [{ type: 'text', text: summary }], content: [{ type: 'text', text: summary }],
structuredContent: context 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.', 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 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({ const result = retrievalService.retrieveQueryContext({
query: rawQuery, query: rawQuery,
focused_node_id: focused_node_id ?? null, focused_node_id: focused_node_id ?? null,
active_context_id: active_context_id ?? null,
limit, limit,
}); });
@@ -337,26 +306,18 @@ async function main() {
'createNode', 'createNode',
{ {
title: 'Add RA-H node', 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 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 sourceText = source?.trim() || content?.trim() || chunk?.trim();
const normalizedDescription = typeof description === 'string' ? description.trim() : description; 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({ const node = nodeService.createNode({
title: title.trim(), title: title.trim(),
source: sourceText, source: sourceText,
link: link?.trim(), link: link?.trim(),
description: normalizedDescription, description: normalizedDescription,
context_id: resolvedContextId,
metadata: metadata || {} metadata: metadata || {}
}); });
@@ -377,15 +338,14 @@ async function main() {
'queryNodes', 'queryNodes',
{ {
title: 'Search RA-H nodes', 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 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 safeLimit = Math.min(Math.max(limit, 1), 50);
const result = directNodeLookup({ const result = directNodeLookup({
search: searchQuery.trim(), search: searchQuery.trim(),
limit: safeLimit, limit: safeLimit,
context_name,
createdAfter: created_after, createdAfter: created_after,
createdBefore: created_before, createdBefore: created_before,
eventAfter: event_after, 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( registerToolWithAliases(
'getNodesById', 'getNodesById',
{ {
@@ -512,7 +428,7 @@ async function main() {
'updateNode', 'updateNode',
{ {
title: 'Update RA-H 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 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 inputSchema: updateNodeInputSchema
}, },
async ({ id, updates }) => { async ({ id, updates }) => {
@@ -537,19 +453,6 @@ async function main() {
: mappedUpdates.description; : 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 node = nodeService.updateNode(id, mappedUpdates);
const message = `Updated node #${id}`; const message = `Updated node #${id}`;
@@ -652,73 +555,6 @@ async function main() {
// ========== DIMENSION TOOLS ========== // ========== 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 ========== // ========== SKILL TOOLS ==========
registerToolWithAliases( registerToolWithAliases(
@@ -934,7 +770,7 @@ async function main() {
'sqliteQuery', 'sqliteQuery',
{ {
title: 'Execute read-only SQL', 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 inputSchema: sqliteQueryInputSchema
}, },
async ({ sql: userSql, format = 'json' }) => { async ({ sql: userSql, format = 'json' }) => {
@@ -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,
};
@@ -1,7 +1,6 @@
'use strict'; 'use strict';
const nodeService = require('./nodeService'); const nodeService = require('./nodeService');
const contextService = require('./contextService');
const SEARCH_STOP_WORDS = new Set([ const SEARCH_STOP_WORDS = new Set([
'a', 'about', 'added', 'already', 'an', 'and', 'are', 'as', 'at', 'be', 'by', 'a', 'about', 'added', 'already', 'an', 'and', 'are', 'as', 'at', 'be', 'by',
@@ -109,41 +108,6 @@ function scoreNodeSearchMatch(node, query) {
return score; 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) { function hasStrongAnchorMatch(nodes, searchTerm) {
if (!searchTerm || nodes.length === 0) return false; if (!searchTerm || nodes.length === 0) return false;
const highSignalTerms = getHighSignalSearchTerms(searchTerm); const highSignalTerms = getHighSignalSearchTerms(searchTerm);
@@ -168,12 +132,9 @@ function directNodeLookup(input = {}) {
}; };
} }
const resolvedContext = resolveSearchContext(input);
let safeNodes = nodeService.getNodes({ let safeNodes = nodeService.getNodes({
search: searchTerm || undefined, search: searchTerm || undefined,
limit, limit,
contextId: resolvedContext.contextId,
createdAfter: input.createdAfter, createdAfter: input.createdAfter,
createdBefore: input.createdBefore, createdBefore: input.createdBefore,
eventAfter: input.eventAfter, eventAfter: input.eventAfter,
@@ -181,7 +142,6 @@ function directNodeLookup(input = {}) {
}); });
const hadExtraFilters = Boolean( const hadExtraFilters = Boolean(
resolvedContext.contextId !== undefined ||
input.createdAfter || input.createdAfter ||
input.createdBefore || input.createdBefore ||
input.eventAfter || input.eventAfter ||
@@ -209,7 +169,6 @@ function directNodeLookup(input = {}) {
filtersApplied: { filtersApplied: {
search: searchTerm || undefined, search: searchTerm || undefined,
limit, limit,
context_name: resolvedContext.context_name,
createdAfter: input.createdAfter, createdAfter: input.createdAfter,
createdBefore: input.createdBefore, createdBefore: input.createdBefore,
eventAfter: input.eventAfter, eventAfter: input.eventAfter,
@@ -1,7 +1,6 @@
'use strict'; 'use strict';
const { query, transaction, getDb } = require('./sqlite-client'); const { query, transaction, getDb } = require('./sqlite-client');
const contextService = require('./contextService');
function parseMetadata(metadata) { function parseMetadata(metadata) {
if (!metadata) return {}; if (!metadata) return {};
@@ -54,8 +53,6 @@ function mapNodeRow(row) {
return { return {
...row, ...row,
metadata: row.metadata ? (typeof row.metadata === 'string' ? JSON.parse(row.metadata) : row.metadata) : null, 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. * Get nodes with optional filtering.
*/ */
function getNodes(filters = {}) { 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 = ` let sql = `
SELECT n.id, n.title, n.description, n.source, n.link, n.event_date, n.metadata, 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, n.created_at, n.updated_at
CASE
WHEN c.id IS NULL THEN NULL
ELSE json_object('id', c.id, 'name', c.name, 'description', c.description, 'icon', c.icon)
END as context_json
FROM nodes n FROM nodes n
LEFT JOIN contexts c ON c.id = n.context_id
WHERE 1=1 WHERE 1=1
`; `;
const params = []; 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)`; sql += ` AND (n.title LIKE ? COLLATE NOCASE OR n.description LIKE ? COLLATE NOCASE OR n.source LIKE ? COLLATE NOCASE)`;
params.push(`%${search}%`, `%${search}%`, `%${search}%`); params.push(`%${search}%`, `%${search}%`, `%${search}%`);
} }
if (contextId !== undefined) {
sql += ' AND n.context_id = ?';
params.push(contextId);
}
// Sort by search relevance or updated_at // Sort by search relevance or updated_at
if (search) { if (search) {
sql += ` ORDER BY sql += ` ORDER BY
@@ -130,13 +121,8 @@ function searchNodes(filters = {}) {
function getNodeById(id) { function getNodeById(id) {
const sql = ` const sql = `
SELECT n.id, n.title, n.description, n.source, n.link, n.event_date, n.metadata, 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, n.created_at, n.updated_at
CASE
WHEN c.id IS NULL THEN NULL
ELSE json_object('id', c.id, 'name', c.name, 'description', c.description, 'icon', c.icon)
END as context_json
FROM nodes n FROM nodes n
LEFT JOIN contexts c ON c.id = n.context_id
WHERE n.id = ? WHERE n.id = ?
`; `;
@@ -172,8 +158,7 @@ function createNode(nodeData) {
source, source,
link, link,
event_date, event_date,
metadata = {}, metadata = {}
context_id
} = nodeData; } = nodeData;
const title = sanitizeTitle(rawTitle); const title = sanitizeTitle(rawTitle);
@@ -183,13 +168,12 @@ function createNode(nodeData) {
const db = getDb(); const db = getDb();
const sourceToStore = source ?? ([title, description].filter(Boolean).join('\n\n').trim() || null); const sourceToStore = source ?? ([title, description].filter(Boolean).join('\n\n').trim() || null);
const effectiveContextId = context_id ?? null;
const chunkStatus = getChunkStatusForSource(sourceToStore); const chunkStatus = getChunkStatusForSource(sourceToStore);
const nodeId = transaction(() => { const nodeId = transaction(() => {
const stmt = db.prepare(` const stmt = db.prepare(`
INSERT INTO nodes (title, description, source, link, event_date, metadata, chunk_status, context_id, created_at, updated_at) INSERT INTO nodes (title, description, source, link, event_date, metadata, chunk_status, created_at, updated_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
`); `);
const result = stmt.run( const result = stmt.run(
@@ -200,7 +184,6 @@ function createNode(nodeData) {
event_date ?? null, event_date ?? null,
JSON.stringify(canonicalMetadata), JSON.stringify(canonicalMetadata),
chunkStatus, chunkStatus,
effectiveContextId ?? null,
now, now,
now now
); );
@@ -259,10 +242,6 @@ function updateNode(id, updates, options = {}) {
setFields.push('chunk_status = ?'); setFields.push('chunk_status = ?');
params.push(getChunkStatusForSource(normalizedSource)); 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) { if (mergedMetadata !== undefined) {
setFields.push('metadata = ?'); setFields.push('metadata = ?');
params.push(JSON.stringify(mergedMetadata)); params.push(JSON.stringify(mergedMetadata));
@@ -304,7 +283,7 @@ function getNodeCount() {
/** /**
* Get knowledge graph context overview. * Get knowledge graph context overview.
* Returns stats, contexts, hub nodes, and recent activity. * Returns stats, hub nodes, and recent activity.
*/ */
function getContext() { function getContext() {
const nodeCount = query('SELECT COUNT(*) as count FROM nodes')[0].count; 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 LEFT JOIN edges e ON n.id = e.from_node_id OR n.id = e.to_node_id
GROUP BY n.id GROUP BY n.id
ORDER BY edge_count DESC ORDER BY edge_count DESC
LIMIT 5 LIMIT 10
`); `);
return { return {
stats: { nodeCount, edgeCount, dimensionCount: 0, contextCount: contextService.listContexts().length }, stats: { nodeCount, edgeCount, dimensionCount: 0 },
contexts: contextService.listContexts(),
recentNodes, recentNodes,
hubNodes hubNodes
}; };
@@ -3,7 +3,6 @@
const { getDb, query } = require('./sqlite-client'); const { getDb, query } = require('./sqlite-client');
const nodeService = require('./nodeService'); const nodeService = require('./nodeService');
const edgeService = require('./edgeService'); const edgeService = require('./edgeService');
const contextService = require('./contextService');
const LOW_SIGNAL_PATTERNS = [ 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, /^(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 = {}) { function retrieveQueryContext(input = {}) {
const queryText = normalizeWhitespace(input.query || ''); const queryText = normalizeWhitespace(input.query || '');
const focusedNodeId = input.focused_node_id ?? null; 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 limit = Math.min(Math.max(input.limit || 6, 1), 12);
const shouldRetrieve = shouldRetrieveForQuery(queryText); const shouldRetrieve = shouldRetrieveForQuery(queryText);
@@ -486,14 +484,11 @@ function retrieveQueryContext(input = {}) {
mode: 'skip', mode: 'skip',
reason: 'Query is too lightweight or conversational to justify retrieval.', reason: 'Query is too lightweight or conversational to justify retrieval.',
focused_node_id: focusedNodeId, focused_node_id: focusedNodeId,
active_context_id: requestedActiveContextId,
nodes: [], nodes: [],
chunks: [], chunks: [],
}; };
} }
const activeContext = requestedActiveContextId ? contextService.getContextById(requestedActiveContextId) : null;
const activeContextId = activeContext ? activeContext.id : null;
const focusedRequest = isFocusedSourceRequest(queryText); const focusedRequest = isFocusedSourceRequest(queryText);
const directNodeRetrieval = isDirectNodeRetrievalQuery(queryText); const directNodeRetrieval = isDirectNodeRetrievalQuery(queryText);
const nodesById = new Map(); 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) { if (!strongRecallMatch) {
const rankedSeedNodes = rankRetrievedNodes(Array.from(nodesById.values())).slice(0, Math.max(3, limit)); const rankedSeedNodes = rankRetrievedNodes(Array.from(nodesById.values())).slice(0, Math.max(3, limit));
rankedSeedNodes.slice(0, 3).forEach((seed) => { 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.' ? '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.', : 'Substantive query: search the graph directly, then pull additional supporting context if helpful.',
focused_node_id: focusedNodeId, focused_node_id: focusedNodeId,
active_context_id: activeContextId,
nodes: finalNodes, nodes: finalNodes,
chunks, chunks,
}; };
@@ -32,7 +32,7 @@ function getExistingColumnNames(db, tableName) {
} }
function validateExistingRahSchema(db) { function validateExistingRahSchema(db) {
const requiredTables = ['contexts', 'nodes', 'edges', 'chunks']; const requiredTables = ['nodes', 'edges', 'chunks'];
const missingTables = requiredTables.filter( const missingTables = requiredTables.filter(
tableName => !db.prepare("SELECT 1 FROM sqlite_master WHERE type='table' AND name = ?").get(tableName) tableName => !db.prepare("SELECT 1 FROM sqlite_master WHERE type='table' AND name = ?").get(tableName)
); );
@@ -46,16 +46,14 @@ function validateExistingRahSchema(db) {
const requiredNodeColumns = [ const requiredNodeColumns = [
'title', 'description', 'source', 'link', 'event_date', 'metadata', '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' '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 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 requiredChunkColumns = ['node_id', 'chunk_idx', 'text', 'embedding_type', 'metadata', 'created_at'];
const schemaChecks = [ const schemaChecks = [
['nodes', requiredNodeColumns], ['nodes', requiredNodeColumns],
['contexts', requiredContextColumns],
['edges', requiredEdgeColumns], ['edges', requiredEdgeColumns],
['chunks', requiredChunkColumns], ['chunks', requiredChunkColumns],
]; ];
@@ -104,15 +102,6 @@ function initDatabase() {
function ensureCoreSchema(db) { function ensureCoreSchema(db) {
db.exec(` 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 ( CREATE TABLE IF NOT EXISTS nodes (
id INTEGER PRIMARY KEY, id INTEGER PRIMARY KEY,
title TEXT, title TEXT,
@@ -126,13 +115,8 @@ function ensureCoreSchema(db) {
embedding BLOB, embedding BLOB,
embedding_updated_at TEXT, embedding_updated_at TEXT,
embedding_text TEXT, embedding_text TEXT,
chunk_status TEXT DEFAULT 'not_chunked', chunk_status TEXT DEFAULT 'not_chunked'
context_id INTEGER,
FOREIGN KEY (context_id) REFERENCES contexts(id) ON DELETE SET NULL
); );
CREATE UNIQUE INDEX IF NOT EXISTS idx_contexts_name_normalized
ON contexts(LOWER(TRIM(name)));
`); `);
ensureEdgesTableSchema(db); ensureEdgesTableSchema(db);
+1 -1
View File
@@ -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. 1. Node quality: duplicates, vague descriptions, missing dates, weak titles.
2. Edge quality: missing links, weak explanations, wrong directionality. 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. 4. Skill quality: trigger clarity, overlap, dead/unused skills.
## Output Format ## Output Format
@@ -3,7 +3,7 @@ name: Calibration
description: "Run periodic check-ins to recalibrate goals, projects, preferences, and graph structure." 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_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." 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 # Calibration
@@ -14,7 +14,7 @@ Re-anchor the graph to the user's current state.
## Check-in Sequence ## 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. 2. Ask what changed: goals, priorities, constraints, beliefs, preferences.
3. Identify stale nodes and missing nodes. 3. Identify stale nodes and missing nodes.
4. Propose precise updates: update existing vs create new. 4. Propose precise updates: update existing vs create new.
@@ -13,7 +13,7 @@ description: "Use for graph read, write, connect, classify, or traverse operatio
4. Search before create to avoid duplicates. 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. 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). 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. 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. 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. - `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. - For idea capture from chat, the `source` should usually be the raw user thought, not a compressed assistant summary.
- `link`: external source URL only. - `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: - `metadata`: use the canonical node metadata contract when metadata is needed:
- `type` - `type`
- `state` (`processed` or `not_processed`) - `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 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) 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`. 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`.
@@ -1,13 +1,13 @@
--- ---
name: Node Context Enrichment 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 # 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 ## Goal
@@ -17,24 +17,19 @@ Replace weak descriptions with a single clean natural description that captures:
2. Why it is in Brad's graph 2. Why it is in Brad's graph
3. Status in Brad's workflow 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 ## Workflow
1. Load the node and inspect title, description, source, link, metadata, context, and nearby edges. 1. Load the node and inspect title, description, source, link, metadata, and nearby edges.
2. Search for adjacent context before rewriting: 2. Search for adjacent graph context before rewriting:
- the node's primary context and its anchor node when present
- recently connected project or belief nodes - recently connected project or belief nodes
- related nodes with overlapping titles, creators, or neighboring context - related nodes with overlapping titles, creators, or neighboring structure
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:. 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: 5. Suggest 1-3 high-signal edges when obvious.
- keep the current context when it is clearly the primary scope 6. Update the node once the description is strong enough to be useful.
- suggest clearing context when it is weak or misleading 7. After the update, tell the user what changed and ask whether they want to refine the important framing:
- 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:
- what it is - what it is
- why it belongs in the graph - why it belongs in the graph
- status / current relevance / workflow position - status / current relevance / workflow position
@@ -52,7 +47,7 @@ Every rewritten description must naturally cover:
2. Why 2. Why
- why Brad saved it - why Brad saved it
- what project, belief, question, or theme it connects to - 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 3. Status
- queued, in progress, processed, not yet reviewed, saved for later, etc. - queued, in progress, processed, not yet reviewed, saved for later, etc.
- if unknown, say naturally that it has not been reviewed yet - 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. 3. Enrich each node individually. Do not reuse boilerplate "why" text across unrelated nodes.
4. Return a compact summary of: 4. Return a compact summary of:
- nodes updated - nodes updated
- context assignments to review - edge suggestions not yet created
- edge suggestions not yet created
## Quality Bar ## Quality Bar
- No filler phrases like `insightful for understanding`, `relevant to`, or `important for`. - No filler phrases like `insightful for understanding`, `relevant to`, or `important for`.
- No generic summaries that only restate the topic. - 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. - Prefer one compact 3-sentence description over bloated prose.
## Output Pattern ## Output Pattern
@@ -86,6 +80,6 @@ Use batch enrichment when cleaning up many nodes with the same failure mode.
For each node: For each node:
- New description - New description
- Context change: keep / change / clear - Framing note: what graph context influenced the rewrite, if any
- Edge suggestions: source -> target with explicit explanation - 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
@@ -7,7 +7,7 @@ description: "Use for new-user setup, empty or near-empty graphs, or major reset
## Your Job ## 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. 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. - **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. 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 ## Explain the System First
Before asking anything, orient the user. Be direct, not salesy: 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: 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. - **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. - **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. - **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: 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 ## 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: 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. - **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. - **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: - **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 - 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. Do your best to build the graph as useful context emerges.
- Add nodes when the user mentions concrete things worth keeping. - 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. - 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. - 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 - 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" - Every description must be concrete: what it IS and why it matters to them, not what it "explores" or "discusses"
- Contexts are optional and should only be used for an obvious existing match; otherwise leave them empty
- Every edge needs an explicit explanation sentence - Every edge needs an explicit explanation sentence
## Propose Before Writing ## Propose Before Writing
When there is enough context, summarize the proposed structure before touching the database: 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. 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: 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]." > "After this starter pass, the best next move will be [add a source / connect these nodes / capture another active project]."
+28 -208
View File
@@ -44,15 +44,13 @@ let logger = (message) => console.log(`[mcp] ${message}`);
const instructions = [ const instructions = [
'RA-H is a personal knowledge graph — local-first, vendor-neutral.', '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 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.', '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.', '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.', '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.', '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?', 'Only suggest saving durable knowledge when it is unusually valuable. Keep the ask brief, for example: Add "X" as a node?',
'Never write via rah_write_context unless the user has explicitly confirmed yes.',
'Do not create edges autonomously. Surface likely edge candidates briefly, then call edge-write tools only after the user explicitly confirms.', '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?', 'Every edge needs an explanation: why does this connection exist?',
'All data stays local on this device; nothing leaves 127.0.0.1.', '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(), source: z.string().max(50000).optional(),
link: z.string().url().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".'), 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.'), 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() chunk: z.string().max(50000).optional()
}; };
@@ -93,7 +90,10 @@ const addNodeOutputSchema = {
const searchNodesInputSchema = { const searchNodesInputSchema = {
query: z.string().min(1).max(400), query: z.string().min(1).max(400),
limit: z.number().min(1).max(50).optional(), 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 = { const searchNodesOutputSchema = {
@@ -113,7 +113,6 @@ const searchNodesOutputSchema = {
const retrieveQueryContextInputSchema = { const retrieveQueryContextInputSchema = {
query: z.string().min(1).max(800), query: z.string().min(1).max(800),
focused_node_id: z.number().int().positive().nullable().optional(), 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() limit: z.number().min(1).max(12).optional()
}; };
@@ -123,14 +122,13 @@ const retrieveQueryContextOutputSchema = {
mode: z.enum(['skip', 'focused', 'query']), mode: z.enum(['skip', 'focused', 'query']),
reason: z.string(), reason: z.string(),
focused_node_id: z.number().nullable(), focused_node_id: z.number().nullable(),
active_context_id: z.number().nullable(),
nodes: z.array(z.object({ nodes: z.array(z.object({
id: z.number(), id: z.number(),
title: z.string(), title: z.string(),
description: z.string().nullable(), description: z.string().nullable(),
link: z.string().nullable(), link: z.string().nullable(),
updated_at: z.string(), updated_at: z.string(),
kind: z.enum(['focused', 'query_match', 'context_hint', 'neighbor']), kind: z.enum(['focused', 'query_match', 'neighbor']),
reason: z.string(), reason: z.string(),
seed_node_id: z.number().optional() 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 // rah_update_node schemas
const updateNodeInputSchema = { const updateNodeInputSchema = {
id: z.number().int().positive().describe('The ID of the node to update'), 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.'), 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.'), source: z.string().optional().describe('Canonical source text for embedding.'),
link: z.string().optional().describe('New link'), 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.') 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') }).describe('Fields to update')
}; };
@@ -378,17 +327,16 @@ mcpServer.registerTool(
'rah_add_node', 'rah_add_node',
{ {
title: 'Add RA-H 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, inputSchema: addNodeInputSchema,
outputSchema: addNodeOutputSchema outputSchema: addNodeOutputSchema
}, },
async ({ title, content, source, link, description, context_name, metadata, chunk }) => { async ({ title, content, source, link, description, metadata, chunk }) => {
const payload = { const payload = {
title: title.trim(), title: title.trim(),
source: source?.trim() || content?.trim() || chunk?.trim() || undefined, source: source?.trim() || content?.trim() || chunk?.trim() || undefined,
link: link?.trim() || undefined, link: link?.trim() || undefined,
description: description?.trim() || undefined, description: description?.trim() || undefined,
context_name: context_name?.trim() || undefined,
metadata: metadata || {} metadata: metadata || {}
}; };
@@ -415,17 +363,20 @@ mcpServer.registerTool(
'rah_search_nodes', 'rah_search_nodes',
{ {
title: 'Search RA-H 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, inputSchema: searchNodesInputSchema,
outputSchema: searchNodesOutputSchema outputSchema: searchNodesOutputSchema
}, },
async ({ query, limit = 10, context_name }) => { async ({ query, limit = 10, createdAfter, createdBefore, eventAfter, eventBefore }) => {
const result = await callRaHApi('/api/nodes/direct-search', { const result = await callRaHApi('/api/nodes/direct-search', {
method: 'POST', method: 'POST',
body: JSON.stringify({ body: JSON.stringify({
query: query.trim(), query: query.trim(),
limit: Math.min(Math.max(limit, 1), 50), 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, inputSchema: retrieveQueryContextInputSchema,
outputSchema: retrieveQueryContextOutputSchema 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', { const result = await callRaHApi('/api/retrieval/query-context', {
method: 'POST', method: 'POST',
body: JSON.stringify({ body: JSON.stringify({
query, query,
focused_node_id: focused_node_id ?? null, focused_node_id: focused_node_id ?? null,
active_context_id: active_context_id ?? null,
limit 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( mcpServer.registerTool(
'rah_update_node', 'rah_update_node',
{ {
title: 'Update RA-H 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, inputSchema: updateNodeInputSchema,
outputSchema: updateNodeOutputSchema outputSchema: updateNodeOutputSchema
}, },
@@ -584,10 +451,6 @@ mcpServer.registerTool(
} }
delete mappedUpdates.chunk; 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}`, { const result = await callRaHApi(`/api/nodes/${id}`, {
method: 'PUT', method: 'PUT',
body: JSON.stringify(mappedUpdates) body: JSON.stringify(mappedUpdates)
@@ -871,80 +734,37 @@ mcpServer.registerTool(
'rah_get_context', 'rah_get_context',
{ {
title: 'Get RA-H 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: {}, inputSchema: {},
outputSchema: { 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() })), 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()) guides: z.array(z.string())
} }
}, },
async () => { 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 => ({ 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 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 guideResult = await callRaHApi('/api/guides', { method: 'GET' });
const guides = Array.isArray(guideResult.data) ? guideResult.data.map(g => g.name) : []; 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 { try {
const countResult = await callRaHApi('/api/nodes?limit=1', { method: 'GET' }); const countResult = await callRaHApi('/api/nodes?limit=1', { method: 'GET' });
if (countResult.total !== undefined) stats.nodeCount = countResult.total; if (countResult.total !== undefined) stats.nodeCount = countResult.total;
} catch { /* use defaults */ } } 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 { return {
content: [{ type: 'text', text: `Knowledge graph: ${stats.contextCount} contexts, ${hubNodes.length} hub nodes for graph grounding, ${guides.length} guides available.` }], content: [{ type: 'text', text: `Knowledge graph: ${stats.nodeCount} nodes, ${stats.edgeCount} edges, ${hubNodes.length} hub nodes, ${guides.length} guides available.` }],
structuredContent: { stats, hubNodes, contexts, guides } structuredContent: { stats, hubNodes, 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
}
}; };
} }
); );
+31 -227
View File
@@ -11,15 +11,13 @@ const packageJson = require('../../package.json');
const instructions = [ const instructions = [
'RA-H is a personal knowledge graph — local-first, vendor-neutral.', '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 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.', '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.', '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.', '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.', '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?', 'Only suggest saving durable knowledge when it is unusually valuable. Keep the ask brief, for example: Add "X" as a node?',
'Never write via rah_write_context unless the user has explicitly confirmed yes.',
'Do not create edges autonomously. Surface likely edge candidates briefly, then call edge-write tools only after the user explicitly confirms.', '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?', 'Every edge needs an explanation: why does this connection exist?',
'All data stays local on this device; nothing leaves 127.0.0.1.', '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(), source: z.string().max(50000).optional(),
link: z.string().url().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".'), 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.'), 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() chunk: z.string().max(50000).optional()
}; };
@@ -59,7 +56,10 @@ const addNodeOutputSchema = {
const searchNodesInputSchema = { const searchNodesInputSchema = {
query: z.string().min(1).max(400), query: z.string().min(1).max(400),
limit: z.number().min(1).max(50).optional(), 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 = { const searchNodesOutputSchema = {
@@ -79,7 +79,6 @@ const searchNodesOutputSchema = {
const retrieveQueryContextInputSchema = { const retrieveQueryContextInputSchema = {
query: z.string().min(1).max(800), query: z.string().min(1).max(800),
focused_node_id: z.number().int().positive().nullable().optional(), 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() limit: z.number().min(1).max(12).optional()
}; };
@@ -89,14 +88,13 @@ const retrieveQueryContextOutputSchema = {
mode: z.enum(['skip', 'focused', 'query']), mode: z.enum(['skip', 'focused', 'query']),
reason: z.string(), reason: z.string(),
focused_node_id: z.number().nullable(), focused_node_id: z.number().nullable(),
active_context_id: z.number().nullable(),
nodes: z.array(z.object({ nodes: z.array(z.object({
id: z.number(), id: z.number(),
title: z.string(), title: z.string(),
description: z.string().nullable(), description: z.string().nullable(),
link: z.string().nullable(), link: z.string().nullable(),
updated_at: z.string(), updated_at: z.string(),
kind: z.enum(['focused', 'query_match', 'context_hint', 'neighbor']), kind: z.enum(['focused', 'query_match', 'neighbor']),
reason: z.string(), reason: z.string(),
seed_node_id: z.number().optional() 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 // rah_update_node schemas
const updateNodeInputSchema = { const updateNodeInputSchema = {
id: z.number().int().positive().describe('The ID of the node to update'), 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.'), 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.'), source: z.string().optional().describe('Canonical source text for embedding.'),
link: z.string().optional().describe('New link'), 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.') 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') }).describe('Fields to update')
}; };
@@ -323,7 +272,7 @@ async function resolveBaseUrl() {
if (!candidate) return false; if (!candidate) return false;
const normalized = String(candidate).replace(/\/+$/, ''); const normalized = String(candidate).replace(/\/+$/, '');
try { try {
const response = await fetch(`${normalized}/api/contexts`, { const response = await fetch(`${normalized}/api/nodes?limit=1`, {
method: 'GET', method: 'GET',
signal: AbortSignal.timeout(1500) signal: AbortSignal.timeout(1500)
}); });
@@ -373,17 +322,16 @@ server.registerTool(
'rah_add_node', 'rah_add_node',
{ {
title: 'Add RA-H 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, inputSchema: addNodeInputSchema,
outputSchema: addNodeOutputSchema outputSchema: addNodeOutputSchema
}, },
async ({ title, content, source, link, description, context_name, metadata, chunk }) => { async ({ title, content, source, link, description, metadata, chunk }) => {
const payload = { const payload = {
title: title.trim(), title: title.trim(),
source: source?.trim() || content?.trim() || chunk?.trim() || undefined, source: source?.trim() || content?.trim() || chunk?.trim() || undefined,
link: link?.trim() || undefined, link: link?.trim() || undefined,
description: description?.trim() || undefined, description: description?.trim() || undefined,
context_name: context_name?.trim() || undefined,
metadata: metadata || {} metadata: metadata || {}
}; };
@@ -410,17 +358,20 @@ server.registerTool(
'rah_search_nodes', 'rah_search_nodes',
{ {
title: 'Search RA-H 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, inputSchema: searchNodesInputSchema,
outputSchema: searchNodesOutputSchema outputSchema: searchNodesOutputSchema
}, },
async ({ query, limit = 10, context_name }) => { async ({ query, limit = 10, createdAfter, createdBefore, eventAfter, eventBefore }) => {
const result = await callRaHApi('/api/nodes/direct-search', { const result = await callRaHApi('/api/nodes/direct-search', {
method: 'POST', method: 'POST',
body: JSON.stringify({ body: JSON.stringify({
query: query.trim(), query: query.trim(),
limit: Math.min(Math.max(limit, 1), 50), 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, inputSchema: retrieveQueryContextInputSchema,
outputSchema: retrieveQueryContextOutputSchema 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', { const result = await callRaHApi('/api/retrieval/query-context', {
method: 'POST', method: 'POST',
body: JSON.stringify({ body: JSON.stringify({
query, query,
focused_node_id: focused_node_id ?? null, focused_node_id: focused_node_id ?? null,
active_context_id: active_context_id ?? null,
limit 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( server.registerTool(
'rah_update_node', 'rah_update_node',
{ {
title: 'Update RA-H 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, inputSchema: updateNodeInputSchema,
outputSchema: updateNodeOutputSchema outputSchema: updateNodeOutputSchema
}, },
@@ -581,10 +447,6 @@ server.registerTool(
} }
delete mappedUpdates.chunk; 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}`, { const result = await callRaHApi(`/api/nodes/${id}`, {
method: 'PUT', method: 'PUT',
body: JSON.stringify(mappedUpdates) body: JSON.stringify(mappedUpdates)
@@ -867,8 +729,7 @@ server.registerTool(
const getContextOutputSchema = { const getContextOutputSchema = {
stats: z.object({ stats: z.object({
nodeCount: z.number(), nodeCount: z.number(),
edgeCount: z.number(), edgeCount: z.number()
contextCount: z.number().optional()
}), }),
hubNodes: z.array(z.object({ hubNodes: z.array(z.object({
id: z.number(), id: z.number(),
@@ -876,13 +737,6 @@ const getContextOutputSchema = {
description: z.string().nullable(), description: z.string().nullable(),
edgeCount: z.number() 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()) guides: z.array(z.string())
}; };
@@ -890,13 +744,12 @@ server.registerTool(
'rah_get_context', 'rah_get_context',
{ {
title: 'Get RA-H 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: {}, inputSchema: {},
outputSchema: getContextOutputSchema outputSchema: getContextOutputSchema
}, },
async () => { async () => {
// Fetch hub nodes (top 5 most-connected) const hubResult = await callRaHApi('/api/nodes?sortBy=edges&limit=10', { method: 'GET' });
const hubResult = await callRaHApi('/api/nodes?sortBy=edges&limit=5', { method: 'GET' });
const hubNodes = Array.isArray(hubResult.data) ? hubResult.data.map(n => ({ const hubNodes = Array.isArray(hubResult.data) ? hubResult.data.map(n => ({
id: n.id, id: n.id,
title: n.title, title: n.title,
@@ -904,28 +757,15 @@ server.registerTool(
edgeCount: n.edge_count ?? 0 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 // Fetch guides
const guideResult = await callRaHApi('/api/guides', { method: 'GET' }); const guideResult = await callRaHApi('/api/guides', { method: 'GET' });
const guides = Array.isArray(guideResult.data) ? guideResult.data.map(g => g.name) : []; const guides = Array.isArray(guideResult.data) ? guideResult.data.map(g => g.name) : [];
// Get counts
const nodeCount = hubNodes.length > 0 ? undefined : 0;
const stats = { const stats = {
nodeCount: nodeCount ?? hubNodes.reduce((_, n) => 0, 0), nodeCount: 0,
edgeCount: 0, edgeCount: 0
contextCount: contexts.length
}; };
// Try to get actual counts from a stats endpoint or compute
try { try {
const countResult = await callRaHApi('/api/nodes?limit=1', { method: 'GET' }); const countResult = await callRaHApi('/api/nodes?limit=1', { method: 'GET' });
if (countResult.total !== undefined) { if (countResult.total !== undefined) {
@@ -933,62 +773,26 @@ server.registerTool(
} }
} catch { /* use defaults */ } } 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 { return {
content: [{ type: 'text', text: summary }], content: [{ type: 'text', text: summary }],
structuredContent: { structuredContent: {
stats, stats,
hubNodes, hubNodes,
contexts,
guides 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() { async function main() {
const transport = new StdioServerTransport(); const transport = new StdioServerTransport();
await server.connect(transport); await server.connect(transport);
+41 -48
View File
@@ -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) **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 ## 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 ## Tech Stack
- **Frontend:** Next.js 15, TypeScript, Tailwind CSS - **Frontend:** Next.js 15, TypeScript, Tailwind CSS
- **Database:** SQLite + sqlite-vec (vector search) - **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 - **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 - **Version:** current internal product contract as of April 2026
- Node/Edge CRUD with optional contexts - **Platforms:**
- Full-text and semantic search - Mac app (download at [ra-h.app/download](https://ra-h.app/download))
- MCP server with graph and skill tools - Open source self-hosted (BYO API keys)
- Skills system (shared instructions for internal + external agents) - **License:** MIT (open source version)
- PDF extraction
- Graph visualization (Map view)
- BYO API keys
## What's NOT Included ## Two Ways to Use RA-H
- Private-app-only built-in assistant experience | Version | Best For | Get It |
- Voice features |---------|----------|--------|
- Auth/subscription system | **Mac App** | Most users. One-click install, auto-updates, optional subscription features | [ra-h.app/download](https://ra-h.app/download) |
- Desktop packaging | **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` ## Key Features
- 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`
## MCP Integration - **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
RA-OS is designed to be the knowledge backend for your AI workflows: - **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
```json - **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
"mcpServers": { - **Skills:** markdown procedures that shape graph work and agent behavior
"ra-h": { - **Extraction tools:** website, YouTube, and PDF ingestion paths
"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`
## Documentation ## Documentation
| Doc | Description | | Doc | Description |
|-----|-------------| |-----|-------------|
| [Architecture](./1_architecture.md) | Single-agent runtime, tools, and system design |
| [Schema](./2_schema.md) | Database schema, node/edge structure | | [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 | | [UI](./6_ui.md) | Component structure, panels, views |
| [Voice](./7_voice.md) | Voice interface (STT/TTS) |
| [MCP](./8_mcp.md) | External agent connector setup | | [MCP](./8_mcp.md) | External agent connector setup |
| [Full Local](./10_full-local.md) | Supported local path and community patterns | | [Open Source](./9_open-source.md) | Contributor and sync guide |
| [Troubleshooting](./TROUBLESHOOTING.md) | Common issues and fixes |
+16 -28
View File
@@ -11,15 +11,6 @@
- `metadata` - `metadata`
- `chunk_status` - `chunk_status`
- `event_date` - `event_date`
- `context_id` nullable FK to `contexts.id`
- `created_at`
- `updated_at`
### `contexts`
- `id`
- `name`
- `description`
- `icon`
- `created_at` - `created_at`
- `updated_at` - `updated_at`
@@ -51,38 +42,35 @@
- `chunks_fts` indexes chunk text. - `chunks_fts` indexes chunk text.
- `vec_nodes` stores node-level vectors. - `vec_nodes` stores node-level vectors.
- `vec_chunks` stores chunk-level vectors. - `vec_chunks` stores chunk-level vectors.
- Full-text search and vector search are separate surfaces:
Full-text search and vector search are separate surfaces: - FTS uses `nodes_fts` / `chunks_fts`
- FTS uses `nodes_fts` / `chunks_fts` - semantic/vector retrieval uses `vec_nodes` / `vec_chunks`
- semantic/vector retrieval uses `vec_nodes` / `vec_chunks` - retrieval can combine them, but they should not be described as the same thing
- retrieval may combine them, but docs should not blur them into one surface
## Embedding Lifecycle ## Embedding Lifecycle
- `nodes.source` is the canonical long-form field for chunking and chunk embeddings. - `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 - 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 - Standalone MCP can write `nodes.source`, but it does not directly create `chunks` or vector rows. The app later processes those pending nodes.
- node-level embeddings and chunk embeddings are separate runtime surfaces - Deleting a node must remove dependent chunk rows and must not leave stale node/chunk search or vector state behind.
- integrity/degraded-mode behavior matters enough that docs should describe these surfaces honestly - 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 ## Important Constraints
- `dimensions` and `node_dimensions` are no longer canonical tables. - `dimensions` and `node_dimensions` are no longer canonical tables.
- New installs should never create them. - New installs should never create them.
- Existing installs migrate by snapshotting old dimension data, then dropping the legacy tables. - 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 ## Common Queries
Nodes in a context:
```sql
SELECT *
FROM nodes
WHERE context_id = ?
ORDER BY updated_at DESC;
```
Most connected nodes: Most connected nodes:
```sql ```sql
+53 -44
View File
@@ -1,56 +1,65 @@
# Tools & Skills # 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 ### Read
- `getContext`
| Tool | Description | - `queryNodes`
|------|-------------| - `retrieveQueryContext`
| `getContext` | Graph overview for orientation | - `getNodesById`
| `queryNodes` | Direct node lookup by title, description, or source | - `queryEdge`
| `retrieveQueryContext` | Broader current-turn retrieval when graph grounding helps | - `searchContentEmbeddings`
| `getNodesById` | Fetch full nodes by ID | - `sqliteQuery`
| `queryEdge` | Inspect existing edges | - `webSearch`
| `queryContexts` | List/search contexts | - `think`
| `searchContentEmbeddings` | Search source chunks/transcripts |
| `sqliteQuery` | Read-only SQL (`SELECT`, `WITH`, `PRAGMA`) |
### Write ### Write
- `createNode`
| Tool | Description | - `updateNode`
|------|-------------| - `deleteNode`
| `createNode` | Create a node after duplicate/update checks | - `createEdge`
| `updateNode` | Update a node while preserving context by default | - `updateEdge`
| `writeContext` | Save one confirmed durable context node |
| `createEdge` | Create a confirmed edge |
| `updateEdge` | Correct an edge after explicit confirmation |
### Skills ### Skills
- `listSkills`
- `readSkill`
- `writeSkill`
- `deleteSkill`
| Tool | Description | ### Extraction
|------|-------------| - `websiteExtract`
| `listSkills` | List available skills | - `youtubeExtract`
| `readSkill` | Read one skill | - `paperExtract`
| `writeSkill` | Create or update a skill |
| `deleteSkill` | Delete a skill |
## Behavior Rules ## Important Behavior Rules
- search before creating - Search before creating.
- use `queryNodes` first for specific-node intent - Use `queryNodes` first when the user is clearly looking for a specific existing node.
- use `retrieveQueryContext` only when broader grounding would help - Use `retrieveQueryContext` when the current turn would benefit from broader graph grounding.
- leave context blank by default - `createEdge` and `updateEdge` are confirmation-gated.
- if context is intentionally provided, prefer `context_name` - node creation quality should come from `title`, `description`, `source`, `metadata`, and explicit edges, not taxonomy.
- `writeContext`, `createEdge`, and `updateEdge` are confirmation-gated
- judge graph quality by node quality and explicit edges, not taxonomy completeness
## 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` - `db-operations`
- `create-skill` - `create-skill`
- `audit` - `audit`
@@ -64,21 +73,21 @@ Storage:
- live skills: `~/Library/Application Support/RA-H/skills/` - live skills: `~/Library/Application Support/RA-H/skills/`
- bundled defaults: `src/config/skills/` - bundled defaults: `src/config/skills/`
## API Routes ## API Surfaces
| Route | Method | Purpose | | Route | Method | Purpose |
|-------|--------|---------| |-------|--------|---------|
| `/api/skills` | GET | List skills | | `/api/skills` | GET | List skills |
| `/api/skills/[name]` | GET/PUT/DELETE | Skill CRUD | | `/api/skills/[name]` | GET/PUT/DELETE | Skill CRUD |
| `/api/guides` | GET | Compatibility alias to skills | | `/api/guides` | GET | Legacy compatibility alias to skills |
| `/api/guides/[name]` | GET/PUT/DELETE | Compatibility alias to skills | | `/api/guides/[name]` | GET/PUT/DELETE | Legacy compatibility alias to skills |
## Key Files ## Key Files
| File | Purpose | | File | Purpose |
|------|---------| |------|---------|
| `apps/mcp-server-standalone/` | Standalone MCP server |
| `src/tools/infrastructure/registry.ts` | Live tool registry | | `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/config/skills/*.md` | Bundled default skills |
| `src/components/panes/SkillsPane.tsx` | Skills pane UI | | `apps/mcp-server-standalone/skills/*.md` | MCP bundled default skills |
+4 -5
View File
@@ -11,7 +11,6 @@ RA-H OS follows the current pane model:
## Main Views ## Main Views
- `Feed` for recent and sortable node browsing - `Feed` for recent and sortable node browsing
- `Contexts` for optional context browsing
- `Map` for graph structure - `Map` for graph structure
- `Table` for dense inspection - `Table` for dense inspection
- `Skills` for editable agent instructions - `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. - The app no longer exposes a dimensions pane.
- Feed and table filtering are not dimension-based. - Feed and table filtering are not dimension-based.
- Persisted pane layout should only hydrate valid pane types: `views`, `node`, `contexts`, `map`, `table`, `skills`. - Persisted pane layout should only hydrate valid pane types: `views`, `node`, `map`, `table`, `skills`.
- Contexts are shown as a secondary organizational aid, not as a hard requirement for capture. - The app does not expose a separate organizing pane or category filter surface.
## Focus And Capture ## 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. - 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.
+39 -150
View File
@@ -1,116 +1,41 @@
# MCP Surface # 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: - the app MCP surface talks to the running app/API
- search your existing graph - the standalone MCP surface talks directly to an existing SQLite DB file
- ground a broader task in relevant graph context - standalone MCP can read and write nodes/edges without the app running, but it does not own chunking or embedding
- create or update nodes - if standalone MCP writes `nodes.source` while the app is closed, the app later processes that node through startup recovery
- 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`
## Core MCP Contract ## Core MCP Contract
- `queryNodes` is the primary tool for direct node retrieval when the user is trying to find a specific existing node. - `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. - `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. - `getContext` returns graph orientation: stats, hubs, and skills.
- `createNode`, `updateNode`, and `queryNodes` leave context blank by default. - `createEdge` is a post-confirmation execution tool. Agents should propose likely edges first and only write them after the user explicitly confirms.
- If context is intentionally provided, prefer `context_name`. - `queryNodes` searches title, description, and source.
- `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.
- `dimensions` are removed from the MCP contract. - `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 ## Main Tools
Read: Read:
- `retrieveQueryContext` - `retrieveQueryContext`
- `getContext` - `getContext`
- `queryNodes` - `queryNodes`
- `queryContexts`
- `getNodesById` - `getNodesById`
- `queryEdge` - `queryEdge`
- `listSkills` - `listSkills`
@@ -119,7 +44,6 @@ Read:
- `sqliteQuery` - `sqliteQuery`
Write: Write:
- `writeContext`
- `createNode` - `createNode`
- `updateNode` - `updateNode`
- `createEdge` - `createEdge`
@@ -127,61 +51,26 @@ Write:
- `writeSkill` - `writeSkill`
- `deleteSkill` - `deleteSkill`
## 6. How The Agent Should Behave With RA-H ## Tool Behavior
- always search before creating - Always search before creating.
- use `queryNodes` first for specific-node intent - If the user is trying to find a specific existing node, use `queryNodes` first.
- use `retrieveQueryContext` when broader graph grounding would help - If the user is asking a broader question that would benefit from graph context, use `retrieveQueryContext`.
- keep context optional by default - 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.
- use `context_name` only when context is intentionally provided - If the user explicitly asked to save or update something and the target artifact is clear, the agent can write after duplicate/update checks.
- do not assume the server will infer a best-fit context - If the agent is only suggesting a save, it should propose the node first and wait for confirmation.
- if the user explicitly asked to save or update something and the target artifact is clear, the agent can write after duplicate/update checks - When obvious relationships appear, propose candidate edges briefly rather than writing them automatically.
- if the agent is only suggesting a save, it should propose the node first and wait for confirmation - Judge graph quality by node quality and edges, not taxonomy completeness.
- when obvious relationships appear, propose candidate edges briefly rather than writing them automatically - Keep writeback prompts terse and selective. The goal is not to ask constantly whether every useful sentence should be saved.
- keep writeback prompts terse and selective - 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: ## Memory-File Rule
- write node data first
- app-owned pipeline later creates chunks, FTS rows, and vectors
## 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: Current rule:
- `CLAUDE.md` is an assistant-native memory/instruction file for Claude Code - MCP tools, server instructions, skills, and docs should be enough for the base contract
- `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
- optional reinforcement can still improve consistency - optional reinforcement can still improve consistency
- do not create contradictory instruction files - avoid contradictory instruction files across `CLAUDE.md`, `AGENTS.md`, and other client-specific memory surfaces
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
+17 -34
View File
@@ -1,47 +1,22 @@
# Open Source Surface # 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, - no runtime `dimensions` model,
- optional soft `contexts`,
- no automatic context assignment on write,
- node quality driven by title, description, source, metadata, and edges. - node quality driven by title, description, source, metadata, and edges.
## What RA-H OS Includes It should also explain the open-source-specific reality clearly:
- no private-app-only promises
- local SQLite graph - a practical standalone MCP install path
- local UI - pinned package versions for external-agent setup
- standalone MCP server - clear verification and troubleshooting steps
- shared skills system - honest support boundaries for fully local or community setups
- 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
## Important App Routes ## Important App Routes
- `app/api/nodes/` - `app/api/nodes/`
- `app/api/contexts/`
- `app/api/edges/` - `app/api/edges/`
- `app/api/rah/chat/` - `app/api/rah/chat/`
- extraction routes - extraction routes
@@ -52,3 +27,11 @@ Experimental / user-owned:
Main `ra-h` ships first. 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. `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
+2 -2
View File
@@ -24,7 +24,7 @@ npm run backup
``` ```
- Creates timestamped backup in `/backups/` folder - Creates timestamped backup in `/backups/` folder
- Example: `rah_backup_20250902_102846.sql` - 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 ### Restore from Backup
```bash ```bash
@@ -59,7 +59,7 @@ node scripts/helpers/delete-helper.js "helper-id"
- All nodes (42,000+ knowledge items) - All nodes (42,000+ knowledge items)
- Content chunks and embeddings - Content chunks and embeddings
- Node connections/edges - Node connections/edges
- Contexts, metadata, and migration snapshots - Metadata, logs, chats, voice usage, and migration snapshots
- Database schema and indexes - Database schema and indexes
## Notes ## Notes
+82 -238
View File
@@ -110,7 +110,7 @@ VALUES (
SQL SQL
fi 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 if ! has_table nodes; then
"$SQLITE_BIN" "$DB_PATH" <<'SQL' "$SQLITE_BIN" "$DB_PATH" <<'SQL'
@@ -132,20 +132,6 @@ CREATE TABLE nodes (
SQL SQL
fi 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 if ! has_table chunks; then
"$SQLITE_BIN" "$DB_PATH" <<'SQL' "$SQLITE_BIN" "$DB_PATH" <<'SQL'
CREATE TABLE chunks ( CREATE TABLE chunks (
@@ -172,7 +158,6 @@ CREATE TABLE edges (
source TEXT, source TEXT,
created_at TEXT, created_at TEXT,
context TEXT, context TEXT,
explanation TEXT,
FOREIGN KEY (from_node_id) REFERENCES nodes(id) ON DELETE CASCADE, FOREIGN KEY (from_node_id) REFERENCES nodes(id) ON DELETE CASCADE,
FOREIGN KEY (to_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 SQL
fi 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" <<SQL
PRAGMA foreign_keys=OFF;
BEGIN TRANSACTION;
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,
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,
${FROM_EXPR},
${TO_EXPR},
${SOURCE_EXPR},
COALESCE(${CREATED_AT_EXPR}, CURRENT_TIMESTAMP),
${CONTEXT_EXPR},
${EXPLANATION_EXPR}
FROM edges_legacy_migration
WHERE ${FROM_EXPR} IS NOT NULL
AND ${TO_EXPR} IS NOT NULL;
DROP TABLE edges_legacy_migration;
COMMIT;
PRAGMA foreign_keys=ON;
SQL
fi
if ! has_col edges explanation; then
echo "Adding edges.explanation"
"$SQLITE_BIN" "$DB_PATH" "ALTER TABLE edges ADD COLUMN explanation TEXT;"
if has_col edges context; then
"$SQLITE_BIN" "$DB_PATH" <<'SQL'
UPDATE edges
SET explanation = CASE
WHEN json_valid(context) THEN json_extract(context, '$.explanation')
ELSE explanation
END
WHERE explanation IS NULL
AND context IS NOT NULL;
SQL
fi
fi
"$SQLITE_BIN" "$DB_PATH" <<'SQL'
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);
SQL
fi
echo "Dropping legacy episodic/semantic memory tables if they exist..." echo "Dropping legacy episodic/semantic memory tables if they exist..."
if has_table chats; then if has_table chats; then
NEEDS_CHAT_REWRITE=0 if "$SQLITE_BIN" "$DB_PATH" -json "PRAGMA table_info(chats);" | grep -q 'focused_memory_id'; then
if has_col chats focused_memory_id; then echo " Removing legacy chats.focused_memory_id column"
NEEDS_CHAT_REWRITE=1 "$SQLITE_BIN" "$DB_PATH" <<'SQL'
fi
for required_col in chat_type helper_name agent_type delegation_id user_message assistant_message thread_id focused_node_id created_at metadata; do
if ! has_col chats "$required_col"; then
NEEDS_CHAT_REWRITE=1
break
fi
done
if [ "$NEEDS_CHAT_REWRITE" = "1" ]; then
echo " Migrating legacy chats table to canonical schema"
CHAT_TYPE_EXPR="NULL"
if has_col chats chat_type; then
CHAT_TYPE_EXPR="chat_type"
fi
HELPER_NAME_EXPR="NULL"
if has_col chats helper_name; then
HELPER_NAME_EXPR="helper_name"
elif has_col chats title; then
HELPER_NAME_EXPR="title"
fi
AGENT_TYPE_EXPR="'orchestrator'"
if has_col chats agent_type; then
AGENT_TYPE_EXPR="COALESCE(agent_type, 'orchestrator')"
fi
DELEGATION_ID_EXPR="NULL"
if has_col chats delegation_id; then
DELEGATION_ID_EXPR="delegation_id"
fi
USER_MESSAGE_EXPR="NULL"
if has_col chats user_message; then
USER_MESSAGE_EXPR="user_message"
fi
ASSISTANT_MESSAGE_EXPR="NULL"
if has_col chats assistant_message; then
ASSISTANT_MESSAGE_EXPR="assistant_message"
fi
THREAD_ID_EXPR="NULL"
if has_col chats thread_id; then
THREAD_ID_EXPR="thread_id"
fi
FOCUSED_NODE_ID_EXPR="NULL"
if has_col chats focused_node_id; then
FOCUSED_NODE_ID_EXPR="focused_node_id"
fi
CREATED_AT_CHAT_EXPR="CURRENT_TIMESTAMP"
if has_col chats created_at; then
CREATED_AT_CHAT_EXPR="created_at"
fi
METADATA_CHAT_EXPR="NULL"
if has_col chats metadata; then
METADATA_CHAT_EXPR="metadata"
fi
"$SQLITE_BIN" "$DB_PATH" <<SQL
PRAGMA foreign_keys=OFF; PRAGMA foreign_keys=OFF;
BEGIN TRANSACTION; BEGIN TRANSACTION;
DROP INDEX IF EXISTS idx_chats_thread;
ALTER TABLE chats RENAME TO chats_legacy_cleanup; ALTER TABLE chats RENAME TO chats_legacy_cleanup;
CREATE TABLE chats ( CREATE TABLE chats (
id INTEGER PRIMARY KEY, id INTEGER PRIMARY KEY,
@@ -381,17 +194,9 @@ INSERT INTO chats (
user_message, assistant_message, thread_id, focused_node_id, user_message, assistant_message, thread_id, focused_node_id,
created_at, metadata created_at, metadata
) )
SELECT id, SELECT id, chat_type, helper_name, agent_type, delegation_id,
${CHAT_TYPE_EXPR}, user_message, assistant_message, thread_id, focused_node_id,
${HELPER_NAME_EXPR}, created_at, metadata
${AGENT_TYPE_EXPR},
${DELEGATION_ID_EXPR},
${USER_MESSAGE_EXPR},
${ASSISTANT_MESSAGE_EXPR},
${THREAD_ID_EXPR},
${FOCUSED_NODE_ID_EXPR},
COALESCE(${CREATED_AT_CHAT_EXPR}, CURRENT_TIMESTAMP),
${METADATA_CHAT_EXPR}
FROM chats_legacy_cleanup; FROM chats_legacy_cleanup;
DROP TABLE chats_legacy_cleanup; DROP TABLE chats_legacy_cleanup;
CREATE INDEX IF NOT EXISTS idx_chats_thread ON chats(thread_id); CREATE INDEX IF NOT EXISTS idx_chats_thread ON chats(thread_id);
@@ -465,44 +270,8 @@ if has_table nodes; then
echo "Adding nodes.source" echo "Adding nodes.source"
"$SQLITE_BIN" "$DB_PATH" "ALTER TABLE nodes ADD COLUMN source TEXT;" "$SQLITE_BIN" "$DB_PATH" "ALTER TABLE nodes ADD COLUMN source TEXT;"
fi fi
if ! has_col nodes chunk_status; then
echo "Adding nodes.chunk_status"
"$SQLITE_BIN" "$DB_PATH" "ALTER TABLE nodes ADD COLUMN chunk_status TEXT DEFAULT 'not_chunked';"
fi
if ! has_col nodes context_id; then
echo "Adding nodes.context_id"
"$SQLITE_BIN" "$DB_PATH" "ALTER TABLE nodes ADD COLUMN context_id INTEGER REFERENCES contexts(id) ON DELETE SET NULL;"
fi
fi fi
if has_table contexts; then
if ! has_col contexts description; then
echo "Adding contexts.description"
"$SQLITE_BIN" "$DB_PATH" "ALTER TABLE contexts ADD COLUMN description TEXT NOT NULL DEFAULT '';"
fi
if ! has_col contexts icon; then
echo "Adding contexts.icon"
"$SQLITE_BIN" "$DB_PATH" "ALTER TABLE contexts ADD COLUMN icon TEXT;"
fi
if ! has_col contexts created_at; then
echo "Adding contexts.created_at"
"$SQLITE_BIN" "$DB_PATH" "ALTER TABLE contexts ADD COLUMN created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP;"
fi
if ! has_col contexts updated_at; then
echo "Adding contexts.updated_at"
"$SQLITE_BIN" "$DB_PATH" "ALTER TABLE contexts ADD COLUMN updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP;"
fi
"$SQLITE_BIN" "$DB_PATH" <<'SQL'
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)));
SQL
fi
"$SQLITE_BIN" "$DB_PATH" "CREATE INDEX IF NOT EXISTS idx_nodes_context_id ON nodes(context_id);"
# --- Additive migrations (do first) --- # --- Additive migrations (do first) ---
# Add event_date column (2026-02-15) # Add event_date column (2026-02-15)
@@ -602,6 +371,18 @@ if has_table edges && has_col edges user_feedback; then
"$SQLITE_BIN" "$DB_PATH" "ALTER TABLE edges DROP COLUMN user_feedback;" "$SQLITE_BIN" "$DB_PATH" "ALTER TABLE edges DROP COLUMN user_feedback;"
fi fi
# Add explanation column to edges if missing
if has_table edges && ! has_col edges explanation; then
echo "Adding edges.explanation"
"$SQLITE_BIN" "$DB_PATH" "ALTER TABLE edges ADD COLUMN explanation TEXT;"
"$SQLITE_BIN" "$DB_PATH" <<'SQL'
UPDATE edges
SET explanation = json_extract(context, '$.explanation')
WHERE explanation IS NULL
AND json_extract(context, '$.explanation') IS NOT NULL;
SQL
fi
if has_table chunks; then if has_table chunks; then
if ! has_col chunks embedding_type; then if ! has_col chunks embedding_type; then
echo "Adding chunks.embedding_type" echo "Adding chunks.embedding_type"
@@ -669,6 +450,69 @@ SELECT n.id,
FROM nodes n; FROM nodes n;
SQL SQL
echo "Ensuring FTS tables and triggers exist..."
"$SQLITE_BIN" "$DB_PATH" <<'SQL'
DROP TRIGGER IF EXISTS nodes_fts_ai;
DROP TRIGGER IF EXISTS nodes_fts_ad;
DROP TRIGGER IF EXISTS nodes_fts_au;
DROP TRIGGER IF EXISTS chunks_fts_ai;
DROP TRIGGER IF EXISTS chunks_fts_ad;
DROP TRIGGER IF EXISTS chunks_fts_au;
DROP TABLE IF EXISTS nodes_fts;
DROP TABLE IF EXISTS chunks_fts;
CREATE VIRTUAL TABLE nodes_fts USING fts5(
title,
source,
description,
content='nodes',
content_rowid='id'
);
CREATE VIRTUAL TABLE chunks_fts USING fts5(
text,
content='chunks',
content_rowid='id'
);
INSERT INTO nodes_fts(nodes_fts) VALUES('rebuild');
INSERT INTO chunks_fts(chunks_fts) VALUES('rebuild');
CREATE TRIGGER nodes_fts_ai AFTER INSERT ON nodes BEGIN
INSERT INTO nodes_fts(rowid, title, source, description)
VALUES (new.id, new.title, new.source, new.description);
END;
CREATE TRIGGER nodes_fts_ad AFTER DELETE ON nodes BEGIN
INSERT INTO nodes_fts(nodes_fts, rowid, title, source, description)
VALUES('delete', old.id, old.title, old.source, old.description);
END;
CREATE TRIGGER nodes_fts_au AFTER UPDATE OF title, source, description ON nodes BEGIN
INSERT INTO nodes_fts(nodes_fts, rowid, title, source, description)
VALUES('delete', old.id, old.title, old.source, old.description);
INSERT INTO nodes_fts(rowid, title, source, description)
VALUES (new.id, new.title, new.source, new.description);
END;
CREATE TRIGGER chunks_fts_ai AFTER INSERT ON chunks BEGIN
INSERT INTO chunks_fts(rowid, text)
VALUES (new.id, new.text);
END;
CREATE TRIGGER chunks_fts_ad AFTER DELETE ON chunks BEGIN
INSERT INTO chunks_fts(chunks_fts, rowid, text)
VALUES('delete', old.id, old.text);
END;
CREATE TRIGGER chunks_fts_au AFTER UPDATE OF text ON chunks BEGIN
INSERT INTO chunks_fts(chunks_fts, rowid, text)
VALUES('delete', old.id, old.text);
INSERT INTO chunks_fts(rowid, text)
VALUES (new.id, new.text);
END;
SQL
echo "Ensuring logs table and triggers exist (migrating from memory if needed)..." echo "Ensuring logs table and triggers exist (migrating from memory if needed)..."
# migrate memory -> logs if needed # migrate memory -> logs if needed
+4 -19
View File
@@ -113,15 +113,6 @@ CREATE TABLE agents (
prompts TEXT 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 ( CREATE TABLE nodes (
id INTEGER PRIMARY KEY, id INTEGER PRIMARY KEY,
title TEXT, title TEXT,
@@ -135,9 +126,7 @@ CREATE TABLE nodes (
embedding BLOB, embedding BLOB,
embedding_updated_at TEXT, embedding_updated_at TEXT,
embedding_text TEXT, embedding_text TEXT,
chunk_status TEXT DEFAULT 'not_chunked', chunk_status TEXT DEFAULT 'not_chunked'
context_id INTEGER,
FOREIGN KEY (context_id) REFERENCES contexts(id) ON DELETE SET NULL
); );
CREATE TABLE edges ( CREATE TABLE edges (
@@ -157,8 +146,7 @@ CREATE TABLE chunks (
node_id INTEGER NOT NULL, node_id INTEGER NOT NULL,
chunk_idx INTEGER, chunk_idx INTEGER,
text TEXT NOT NULL, text TEXT NOT NULL,
embedding BLOB, embedding_type TEXT DEFAULT 'text-embedding-3-small',
embedding_type TEXT DEFAULT 'openai',
metadata TEXT, metadata TEXT,
created_at TEXT DEFAULT CURRENT_TIMESTAMP, created_at TEXT DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (node_id) REFERENCES nodes(id) ON DELETE CASCADE 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 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 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 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, context_id FROM source.nodes;
INSERT INTO edges SELECT id, from_node_id, to_node_id, source, created_at, context, explanation FROM source.edges; 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 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 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 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; 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_from ON edges(from_node_id);
CREATE INDEX idx_edges_to ON edges(to_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_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_node_id ON chunks(node_id);
CREATE INDEX idx_chunks_by_node 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); CREATE INDEX idx_chunks_by_node_idx ON chunks(node_id, chunk_idx);
-35
View File
@@ -153,16 +153,6 @@ function ensureCoreSchema(db) {
FOREIGN KEY (focused_node_id) REFERENCES nodes(id) ON DELETE SET NULL 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 ( CREATE TABLE IF NOT EXISTS logs (
id INTEGER PRIMARY KEY, id INTEGER PRIMARY KEY,
ts TEXT NOT NULL DEFAULT (CURRENT_TIMESTAMP), ts TEXT NOT NULL DEFAULT (CURRENT_TIMESTAMP),
@@ -408,31 +398,6 @@ function ensureCoreSchema(db) {
if (!hasNodeCol('chunk_status')) { if (!hasNodeCol('chunk_status')) {
db.exec("ALTER TABLE nodes ADD COLUMN chunk_status TEXT DEFAULT 'not_chunked';"); 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')) { if (hasNodeCol('content')) {
db.exec(` db.exec(`
-102
View File
@@ -12,7 +12,6 @@ import { openExternalUrl, shouldOpenExternally } from '@/utils/openExternalUrl';
import { normalizeNodeLink } from '@/utils/nodeLink'; import { normalizeNodeLink } from '@/utils/nodeLink';
import NodeSearchModal from './edges/NodeSearchModal'; import NodeSearchModal from './edges/NodeSearchModal';
import { getNodeProcessedState, parseNodeMetadata } from '@/services/nodes/metadata'; import { getNodeProcessedState, parseNodeMetadata } from '@/services/nodes/metadata';
import type { ContextSummary } from '@/types/database';
interface FocusPanelProps { interface FocusPanelProps {
openTabs: number[]; openTabs: number[];
@@ -68,9 +67,6 @@ export default function FocusPanel({
const [sourceEditMode, setSourceEditMode] = useState(false); const [sourceEditMode, setSourceEditMode] = useState(false);
const [sourceEditValue, setSourceEditValue] = useState(''); const [sourceEditValue, setSourceEditValue] = useState('');
const [sourceSaving, setSourceSaving] = useState(false); const [sourceSaving, setSourceSaving] = useState(false);
const [availableContexts, setAvailableContexts] = useState<ContextSummary[]>([]);
const [contextSaving, setContextSaving] = useState(false);
const [contextMenuOpen, setContextMenuOpen] = useState(false);
const [hoveredSection, setHoveredSection] = useState<HoverSection>(null); const [hoveredSection, setHoveredSection] = useState<HoverSection>(null);
const titleInputRef = useRef<HTMLInputElement>(null); const titleInputRef = useRef<HTMLInputElement>(null);
@@ -79,7 +75,6 @@ export default function FocusPanel({
const sourceTextareaRef = useRef<HTMLTextAreaElement>(null); const sourceTextareaRef = useRef<HTMLTextAreaElement>(null);
const skipTitleBlurRef = useRef(false); const skipTitleBlurRef = useRef(false);
const skipLinkBlurRef = useRef(false); const skipLinkBlurRef = useRef(false);
const contextMenuRef = useRef<HTMLDivElement>(null);
const currentNode = activeTab !== null ? nodesData[activeTab] : undefined; const currentNode = activeTab !== null ? nodesData[activeTab] : undefined;
const normalizedCurrentLink = normalizeNodeLink(currentNode?.link || null); const normalizedCurrentLink = normalizeNodeLink(currentNode?.link || null);
@@ -105,34 +100,6 @@ export default function FocusPanel({
if (sourceEditMode) sourceTextareaRef.current?.focus(); if (sourceEditMode) sourceTextareaRef.current?.focus();
}, [sourceEditMode]); }, [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(() => { useEffect(() => {
openTabs.forEach((tabId) => { openTabs.forEach((tabId) => {
if (!nodesData[tabId] && !loadingNodes.has(tabId)) { if (!nodesData[tabId] && !loadingNodes.has(tabId)) {
@@ -165,7 +132,6 @@ export default function FocusPanel({
setDescEditValue(''); setDescEditValue('');
setSourceEditMode(false); setSourceEditMode(false);
setSourceEditValue(''); setSourceEditValue('');
setContextMenuOpen(false);
setEdgeSearchOpen(false); setEdgeSearchOpen(false);
setEdgeEditingId(null); setEdgeEditingId(null);
setEdgeEditingValue(''); 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) => { const embedContent = async (nodeId: number) => {
setEmbeddingNode(nodeId); setEmbeddingNode(nodeId);
try { try {
@@ -1192,60 +1144,6 @@ export default function FocusPanel({
</div> </div>
</div> </div>
<div className="prop-row">
<div className="prop-label">context</div>
<div className="prop-value context-prop-value">
<div className="context-select-shell" ref={contextMenuRef}>
<div className="context-inline-row">
<span className={currentNode.context ? 'context-select-name' : 'context-select-empty'}>
{currentNode.context?.name || 'No context'}
</span>
<button
type="button"
className="context-inline-button"
disabled={contextSaving}
onClick={(event) => {
event.stopPropagation();
setContextMenuOpen((prev) => !prev);
}}
title={currentNode.context ? 'Change context' : 'Add context'}
>
<Plus size={12} />
</button>
</div>
{contextMenuOpen ? (
<div className="context-select-menu">
<button
type="button"
className={`context-option ${currentNode.context_id == null ? 'active' : ''}`}
onClick={(event) => {
event.stopPropagation();
void saveContext('');
}}
>
No context
</button>
{availableContexts.map((context) => (
<button
key={context.id}
type="button"
className={`context-option ${currentNode.context_id === context.id ? 'active' : ''}`}
onClick={(event) => {
event.stopPropagation();
void saveContext(String(context.id));
}}
>
<span>{context.name}</span>
<span className="context-option-count">{context.count}</span>
</button>
))}
</div>
) : null}
</div>
</div>
</div>
{/* Connections */} {/* Connections */}
<div className="prop-row prop-row-top"> <div className="prop-row prop-row-top">
<div className="prop-label">edge</div> <div className="prop-label">edge</div>
+106
View File
@@ -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 (
<div
style={{
position: 'fixed',
right: 20,
bottom: 20,
zIndex: 100,
}}
>
<button
onClick={onOpen}
title="Open chat"
style={{
width: 48,
height: 48,
borderRadius: '50%',
border: 'none',
background: 'var(--rah-accent-green)',
color: 'var(--rah-text-inverse)',
cursor: 'pointer',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
boxShadow: 'var(--rah-shadow-floating)',
}}
>
<MessageSquare size={20} />
</button>
</div>
);
}
return (
<div
style={{
flex: 1,
minWidth: 0,
height: '100%',
display: 'flex',
flexDirection: 'column',
background: 'var(--rah-bg-surface)',
borderRadius: 10,
overflow: 'hidden',
}}
>
<div
style={{
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
padding: '10px 12px',
borderBottom: '1px solid var(--rah-border)',
}}
>
<span style={{ color: 'var(--rah-text-soft)', fontSize: 12, fontWeight: 500, letterSpacing: '0.05em' }}>
Chat
</span>
<button
onClick={onClose}
title="Close chat"
style={{
width: 28,
height: 28,
borderRadius: 6,
border: '1px solid var(--rah-border-strong)',
background: 'var(--rah-bg-surface)',
color: 'var(--rah-text-muted)',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
cursor: 'pointer',
}}
>
<X size={14} />
</button>
</div>
<div
style={{
flex: 1,
minHeight: 0,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
padding: 24,
color: 'var(--rah-text-muted)',
fontSize: 13,
textAlign: 'center',
}}
>
Chat UI is not included in this open-source parity slice. The graph, MCP, retrieval, and skill surfaces remain available.
</div>
</div>
);
}
-78
View File
@@ -7,7 +7,6 @@ import {
RefreshCw, RefreshCw,
LayoutList, LayoutList,
Map, Map,
Folder,
ChevronDown, ChevronDown,
ChevronRight, ChevronRight,
Table2, Table2,
@@ -21,7 +20,6 @@ import {
import type { PaneType } from '../panes/types'; import type { PaneType } from '../panes/types';
import type { FocusedSkill } from '@/types/skills'; import type { FocusedSkill } from '@/types/skills';
import type { Theme } from '@/hooks/useTheme'; import type { Theme } from '@/hooks/useTheme';
import type { ContextSummary } from '@/types/database';
interface LeftToolbarProps { interface LeftToolbarProps {
onSearchClick: () => void; onSearchClick: () => void;
@@ -38,8 +36,6 @@ interface LeftToolbarProps {
focusedSkill?: FocusedSkill | null; focusedSkill?: FocusedSkill | null;
theme: Theme; theme: Theme;
onThemeToggle: () => void; onThemeToggle: () => void;
contexts?: ContextSummary[];
onContextQuickSelect?: (contextId: number, contextName: string) => void;
} }
const NAV_WIDTH_COLLAPSED = 50; const NAV_WIDTH_COLLAPSED = 50;
@@ -124,10 +120,7 @@ export default function LeftToolbar({
focusedSkill, focusedSkill,
theme, theme,
onThemeToggle, onThemeToggle,
contexts = [],
onContextQuickSelect,
}: LeftToolbarProps) { }: LeftToolbarProps) {
const [contextsExpanded, setContextsExpanded] = useState(false);
const [paneSelectorOpen, setPaneSelectorOpen] = useState(false); const [paneSelectorOpen, setPaneSelectorOpen] = useState(false);
const renderPaneGlyph = (count: 1 | 2 | 3, active: boolean) => ( const renderPaneGlyph = (count: 1 | 2 | 3, active: boolean) => (
@@ -302,77 +295,6 @@ export default function LeftToolbar({
activeTone="green" activeTone="green"
/> />
))} ))}
<div style={{ display: 'flex', flexDirection: 'column', gap: '4px', marginTop: '10px' }}>
<div style={{ display: 'flex', alignItems: 'center', gap: '4px' }}>
<div style={{ flex: 1, minWidth: 0 }}>
<NavButton
icon={Folder}
label="Contexts"
expanded={isExpanded}
active={activeTabType === 'contexts' || openTabTypes.has('contexts')}
onClick={() => onPaneTypeClick('contexts')}
activeTone="green"
/>
</div>
{isExpanded ? (
<button
type="button"
onClick={() => setContextsExpanded((prev) => !prev)}
style={{
width: '28px',
height: '36px',
borderRadius: '8px',
border: 'none',
background: contextsExpanded ? 'var(--rah-bg-hover)' : 'transparent',
color: contextsExpanded ? 'var(--rah-text-active)' : 'var(--rah-text-muted)',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
cursor: 'pointer',
flexShrink: 0,
}}
title="Toggle context list"
>
{contextsExpanded ? <ChevronDown size={16} /> : <ChevronRight size={16} />}
</button>
) : null}
</div>
{isExpanded && contextsExpanded && contexts.length > 0 ? (
<div style={{ marginLeft: '14px', paddingLeft: '12px', borderLeft: '1px solid var(--rah-border)', display: 'flex', flexDirection: 'column', gap: '2px' }}>
{contexts.map((context) => (
<button
key={context.id}
type="button"
onClick={() => onContextQuickSelect?.(context.id, context.name)}
style={{
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
gap: '8px',
width: '100%',
minHeight: '28px',
border: 'none',
background: 'transparent',
color: 'var(--rah-text-secondary)',
cursor: 'pointer',
padding: '0 8px',
borderRadius: '8px',
textAlign: 'left',
}}
>
<span style={{ fontSize: '12px', whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis' }}>
{context.name}
</span>
<span style={{ fontSize: '10px', color: 'var(--rah-text-muted)', flexShrink: 0 }}>
{context.count}
</span>
</button>
))}
</div>
) : null}
</div>
</div> </div>
</div> </div>
</div> </div>
File diff suppressed because it is too large Load Diff
-124
View File
@@ -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<ContextSummary[]>([]);
const [loading, setLoading] = useState(true);
const [toolbarHost, setToolbarHost] = useState<HTMLDivElement | null>(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 (
<div style={{ height: '100%', display: 'flex', flexDirection: 'column', background: 'transparent', overflow: 'hidden' }}>
<PaneHeader
slot={slot}
onCollapse={onCollapse}
onSwapPanes={onSwapPanes}
tabBar={tabBar}
toolbarHostRef={setToolbarHost}
/>
<div style={{ flex: 1, minHeight: 0, overflow: 'auto', padding: '12px' }}>
<div style={{ maxWidth: '980px', margin: '0 auto', display: 'flex', flexDirection: 'column', gap: '8px' }}>
{loading ? (
<div style={emptyStateStyle}>Loading contexts...</div>
) : contexts.length === 0 ? (
<div style={emptyStateStyle}>No contexts yet. That is optional.</div>
) : (
contexts.map((context) => (
<button
key={context.id}
type="button"
onClick={() => onContextSelect?.(context.id, context.name)}
style={rowStyle}
>
<div style={iconWrapStyle}>
<Folder size={17} />
</div>
<div style={{ minWidth: 0, flex: 1, textAlign: 'left' }}>
<div style={{ display: 'flex', alignItems: 'center', gap: '10px', minWidth: 0, flexWrap: 'wrap' }}>
<span style={{ fontSize: '14px', fontWeight: 600, color: 'var(--rah-text-primary)' }}>{context.name}</span>
<span style={countStyle}>{context.count}</span>
</div>
{context.description ? (
<div style={{ marginTop: '4px', fontSize: '12px', color: 'var(--rah-text-secondary)', lineHeight: 1.45 }}>
{context.description}
</div>
) : null}
</div>
</button>
))
)}
</div>
</div>
</div>
);
}
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)',
};
+9 -1
View File
@@ -6,6 +6,7 @@ import PaneHeader from './PaneHeader';
import type { BasePaneProps } from './types'; import type { BasePaneProps } from './types';
import SkillCard from '@/components/skills/SkillCard'; import SkillCard from '@/components/skills/SkillCard';
import SkillMarkdown from '@/components/skills/SkillMarkdown'; import SkillMarkdown from '@/components/skills/SkillMarkdown';
import type { FocusedSkill } from '@/types/skills';
interface SkillMeta { interface SkillMeta {
name: string; name: string;
@@ -17,12 +18,19 @@ interface Skill extends SkillMeta {
content: string; content: string;
} }
interface SkillsPaneProps extends BasePaneProps {
focusedSkill?: FocusedSkill | null;
onFocusSkill?: (skill: FocusedSkill | null) => void;
autoOpenSkillName?: string | null;
onAutoOpenHandled?: () => void;
}
export default function SkillsPane({ export default function SkillsPane({
slot, slot,
onCollapse, onCollapse,
onSwapPanes, onSwapPanes,
tabBar, tabBar,
}: BasePaneProps) { }: SkillsPaneProps) {
const [skills, setSkills] = useState<SkillMeta[]>([]); const [skills, setSkills] = useState<SkillMeta[]>([]);
const [selectedSkill, setSelectedSkill] = useState<Skill | null>(null); const [selectedSkill, setSelectedSkill] = useState<Skill | null>(null);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
+1 -3
View File
@@ -1,12 +1,11 @@
"use client"; "use client";
import { useEffect, useRef, useState, useCallback } from 'react'; 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'; import type { SlotTab, PaneType, SlotId } from './types';
const TAB_TYPE_ICONS: Record<PaneType, typeof LayoutList> = { const TAB_TYPE_ICONS: Record<PaneType, typeof LayoutList> = {
views: LayoutList, views: LayoutList,
contexts: PanelsTopLeft,
map: Map, map: Map,
node: FileText, node: FileText,
table: Table2, table: Table2,
@@ -15,7 +14,6 @@ const TAB_TYPE_ICONS: Record<PaneType, typeof LayoutList> = {
const TAB_TYPE_LABELS: Record<PaneType, string> = { const TAB_TYPE_LABELS: Record<PaneType, string> = {
views: 'Feed', views: 'Feed',
contexts: 'Contexts',
map: 'Map', map: 'Map',
node: 'Node', node: 'Node',
table: 'Table', table: 'Table',
-9
View File
@@ -12,9 +12,6 @@ export interface ViewsPaneProps extends BasePaneProps {
refreshToken?: number; refreshToken?: number;
pendingNodes?: PendingNode[]; pendingNodes?: PendingNode[];
onDismissPending?: (id: string) => void; onDismissPending?: (id: string) => void;
externalContextFilterId?: number | null;
onContextFilterSelect?: (contextId: number | null, contextName?: string | null) => void;
onClearExternalContextFilter?: () => void;
} }
export default function ViewsPane({ export default function ViewsPane({
@@ -29,9 +26,6 @@ export default function ViewsPane({
refreshToken, refreshToken,
pendingNodes, pendingNodes,
onDismissPending, onDismissPending,
externalContextFilterId,
onContextFilterSelect,
onClearExternalContextFilter,
}: ViewsPaneProps) { }: ViewsPaneProps) {
const [toolbarHost, setToolbarHost] = useState<HTMLDivElement | null>(null); const [toolbarHost, setToolbarHost] = useState<HTMLDivElement | null>(null);
@@ -61,9 +55,6 @@ export default function ViewsPane({
refreshToken={refreshToken} refreshToken={refreshToken}
pendingNodes={pendingNodes} pendingNodes={pendingNodes}
onDismissPending={onDismissPending} onDismissPending={onDismissPending}
externalContextFilterId={externalContextFilterId}
onContextFilterSelect={onContextFilterSelect}
onClearExternalContextFilter={onClearExternalContextFilter}
toolbarHost={toolbarHost} toolbarHost={toolbarHost}
/> />
</div> </div>
-1
View File
@@ -1,5 +1,4 @@
export { default as NodePane } from './NodePane'; export { default as NodePane } from './NodePane';
export { default as ContextsPane } from './ContextsPane';
export { default as MapPane } from './MapPane'; export { default as MapPane } from './MapPane';
export { default as ViewsPane } from './ViewsPane'; export { default as ViewsPane } from './ViewsPane';
export { default as TablePane } from './TablePane'; export { default as TablePane } from './TablePane';
+4 -14
View File
@@ -4,7 +4,7 @@ import type { FocusedSkill } from '@/types/skills';
export type SlotId = 'A' | 'B' | 'C'; export type SlotId = 'A' | 'B' | 'C';
// The pane types // 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 // A single tab within a slot
export interface SlotTab { export interface SlotTab {
@@ -32,7 +32,6 @@ export function getActiveTab(state: SlotState): SlotTab | undefined {
// Actions panes can emit to the layout // Actions panes can emit to the layout
export type PaneAction = export type PaneAction =
| { type: 'open-node'; nodeId: number; targetSlot?: SlotId } | { 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: 'switch-pane-type'; paneType: PaneType }
| { type: 'close-pane' }; | { type: 'close-pane' };
@@ -69,13 +68,13 @@ export interface HighlightedPassage {
export interface ChatPanelProps { export interface ChatPanelProps {
isOpen: boolean; isOpen: boolean;
isSoloPane?: boolean;
slot?: SlotId;
onClose: () => void; onClose: () => void;
onOpen: () => void; onOpen: () => void;
onSwapPanes?: (source: SlotId, target: SlotId) => void;
openTabsData: Node[]; openTabsData: Node[];
activeTabId: number | null; activeTabId: number | null;
activeContextId?: number | null;
activeContextName?: string | null;
onClearContext?: () => void;
focusedSkill?: FocusedSkill | null; focusedSkill?: FocusedSkill | null;
onClearFocusedSkill?: () => void; onClearFocusedSkill?: () => void;
onNodeClick?: (nodeId: number) => void; onNodeClick?: (nodeId: number) => void;
@@ -99,14 +98,6 @@ export interface ViewsPaneProps extends BasePaneProps {
onNodeClick: (nodeId: number) => void; onNodeClick: (nodeId: number) => void;
onNodeOpenInOtherPane?: (nodeId: number) => void; onNodeOpenInOtherPane?: (nodeId: number) => void;
refreshToken?: number; 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 // TablePane specific props
@@ -128,7 +119,6 @@ export interface PaneHeaderProps {
// Labels for pane types // Labels for pane types
export const PANE_LABELS: Record<PaneType, string> = { export const PANE_LABELS: Record<PaneType, string> = {
node: 'Nodes', node: 'Nodes',
contexts: 'Contexts',
map: 'Map', map: 'Map',
views: 'Feed', views: 'Feed',
table: 'Table', table: 'Table',
-198
View File
@@ -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<NodeWithMetrics[]>([]);
const [capsule, setCapsule] = useState<CapsuleData | null>(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 (
<div style={containerStyle}>
<p style={descStyle}>
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.
</p>
<div style={capsuleHeaderStyle}>
<div>
<div style={labelStyle}>Context Capsule</div>
<div style={subLabelStyle}>Always injected into the system prompt. Max 200 words total.</div>
</div>
<button
onClick={handleReset}
disabled={resetting}
style={resetButtonStyle}
onMouseEnter={(e) => { e.currentTarget.style.background = 'var(--settings-button-hover-bg)'; }}
onMouseLeave={(e) => { e.currentTarget.style.background = 'transparent'; }}
>
{resetting ? 'Resetting...' : 'Reset Capsule'}
</button>
</div>
{loadingCapsule ? (
<div style={mutedStyle}>Loading capsule...</div>
) : capsule ? (
<div style={capsuleGridStyle}>
<CapsuleSection
title="User"
value={capsule.userProfile}
footer={capsule.lastUpdatedAt ? `Updated ${new Date(capsule.lastUpdatedAt).toLocaleString()}` : 'Never updated'}
/>
<CapsuleSection title="Agent" value={capsule.agentProfile} />
</div>
) : (
<div style={mutedStyle}>Capsule unavailable.</div>
)}
<div style={{ ...labelStyle, marginTop: 28 }}>Hub Nodes</div>
<div style={subLabelStyle}>
Your 5 most-connected nodes remain the raw graph grounding and are included in every conversation.
</div>
{loadingNodes ? (
<div style={mutedStyle}>Loading hub nodes...</div>
) : nodes.length === 0 ? (
<div style={mutedStyle}>No connected nodes yet.</div>
) : (
<div style={{ display: 'flex', flexDirection: 'column', gap: 6, marginTop: 12 }}>
{nodes.map((node) => (
<div key={node.id} style={nodeCardStyle}>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
<span style={nodeTitleStyle}>{node.title || 'Untitled'}</span>
<span style={edgeCountStyle}>{node.edge_count ?? 0}</span>
</div>
{node.description && (
<div style={nodeDescriptionStyle}>{node.description}</div>
)}
{node.context?.name && (
<div style={{ display: 'flex', gap: 4, marginTop: 8, flexWrap: 'wrap' }}>
<span style={contextTagStyle}>{node.context.name}</span>
</div>
)}
</div>
))}
</div>
)}
</div>
);
}
function CapsuleSection({
title,
value,
footer,
}: {
title: string;
value: string;
footer?: string;
}) {
return (
<div style={cardStyle}>
<div style={labelStyle}>{title}</div>
<div style={capsuleBodyStyle}>{value}</div>
{footer && <div style={capsuleFooterStyle}>{footer}</div>}
</div>
);
}
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)',
};
+1 -18
View File
@@ -325,24 +325,7 @@ export default function DatabaseViewer() {
</div> </div>
</td> </td>
<td style={{ padding: '12px 16px', verticalAlign: 'top', width: '18%' }}> <td style={{ padding: '12px 16px', verticalAlign: 'top', width: '18%' }}>
{node.context?.name ? ( <span style={{ fontSize: '11px', color: '#555' }}></span>
<span
style={{
display: 'inline-flex',
alignItems: 'center',
padding: '3px 10px',
borderRadius: '999px',
background: '#142817',
color: '#c4f5d2',
fontSize: '11px',
border: '1px solid #1f3b23',
}}
>
{node.context.name}
</span>
) : (
<span style={{ fontSize: '11px', color: '#555' }}>Unscoped</span>
)}
</td> </td>
<td style={{ padding: '12px 16px', verticalAlign: 'top', width: '10%' }}> <td style={{ padding: '12px 16px', verticalAlign: 'top', width: '10%' }}>
<div style={{ fontWeight: 600, color: '#e2e8f0' }}>{node.edge_count ?? 0}</div> <div style={{ fontWeight: 600, color: '#e2e8f0' }}>{node.edge_count ?? 0}</div>
+35 -33
View File
@@ -4,9 +4,8 @@ import { useEffect, useState, type CSSProperties } from 'react';
import { createPortal } from 'react-dom'; import { createPortal } from 'react-dom';
import ApiKeysViewer from './ApiKeysViewer'; import ApiKeysViewer from './ApiKeysViewer';
import ExternalAgentsPanel from './ExternalAgentsPanel'; import ExternalAgentsPanel from './ExternalAgentsPanel';
import ContextViewer from './ContextViewer';
export type SettingsTab = 'apikeys' | 'context' | 'agents'; export type SettingsTab = 'apikeys' | 'agents';
interface SettingsModalProps { interface SettingsModalProps {
isOpen: boolean; isOpen: boolean;
@@ -15,10 +14,10 @@ interface SettingsModalProps {
} }
const DEFAULT_TAB: SettingsTab = 'apikeys'; const DEFAULT_TAB: SettingsTab = 'apikeys';
const TAB_ORDER: SettingsTab[] = ['apikeys', 'context', 'agents']; const TAB_ORDER: SettingsTab[] = ['apikeys', 'agents'];
const TAB_LABELS: Record<SettingsTab, string> = { const TAB_LABELS: Record<SettingsTab, string> = {
apikeys: 'API Keys', apikeys: 'API Keys',
context: 'Context',
agents: 'Agents', agents: 'Agents',
}; };
@@ -31,11 +30,9 @@ export default function SettingsModal({ isOpen, onClose, initialTab }: SettingsM
useEffect(() => { useEffect(() => {
if (!isOpen) return; if (!isOpen) return;
const handleEscape = (e: KeyboardEvent) => { const handleEscape = (e: KeyboardEvent) => {
if (e.key === 'Escape') onClose(); if (e.key === 'Escape') onClose();
}; };
window.addEventListener('keydown', handleEscape); window.addEventListener('keydown', handleEscape);
return () => window.removeEventListener('keydown', handleEscape); return () => window.removeEventListener('keydown', handleEscape);
}, [isOpen, onClose]); }, [isOpen, onClose]);
@@ -50,6 +47,7 @@ export default function SettingsModal({ isOpen, onClose, initialTab }: SettingsM
return createPortal( return createPortal(
<div style={backdropStyle} onClick={onClose}> <div style={backdropStyle} onClick={onClose}>
<div style={modalStyle} onClick={(e) => e.stopPropagation()}> <div style={modalStyle} onClick={(e) => e.stopPropagation()}>
{/* Sidebar */}
<div style={sidebarStyle}> <div style={sidebarStyle}>
<div style={logoStyle}>Settings</div> <div style={logoStyle}>Settings</div>
@@ -70,36 +68,27 @@ export default function SettingsModal({ isOpen, onClose, initialTab }: SettingsM
))} ))}
</nav> </nav>
<div style={footerStyle}> <div style={userSectionStyle}>
<div style={footerLabelStyle}>Local Mode</div> <div style={userLabelStyle}>Local Mode</div>
<p style={footerTextStyle}> <div style={userEmailStyle}>Bring your own API keys for descriptions, embeddings, and agent workflows.</div>
This build runs entirely on your machine. Add your API key here to unlock descriptions,
embeddings, and agent-assisted workflows.
</p>
</div> </div>
</div> </div>
{/* Content */}
<div style={contentStyle}> <div style={contentStyle}>
<div style={headerStyle}> <div style={headerStyle}>
<h2 style={titleStyle}>{TAB_LABELS[activeTab]}</h2> <h2 style={titleStyle}>{TAB_LABELS[activeTab]}</h2>
<button <button
onClick={onClose} onClick={onClose}
style={closeBtnStyle} style={closeBtnStyle}
onMouseEnter={(e) => { onMouseEnter={(e) => { e.currentTarget.style.color = 'var(--settings-text)'; }}
e.currentTarget.style.color = 'var(--settings-text)'; onMouseLeave={(e) => { e.currentTarget.style.color = 'var(--settings-muted)'; }}
}}
onMouseLeave={(e) => {
e.currentTarget.style.color = 'var(--settings-muted)';
}}
title="Close (ESC)"
> >
× ×
</button> </button>
</div> </div>
<div style={contentAreaStyle}> <div style={contentAreaStyle}>
{activeTab === 'apikeys' && <ApiKeysViewer />} {activeTab === 'apikeys' && <ApiKeysViewer />}
{activeTab === 'context' && <ContextViewer />}
{activeTab === 'agents' && <ExternalAgentsPanel />} {activeTab === 'agents' && <ExternalAgentsPanel />}
</div> </div>
</div> </div>
@@ -109,6 +98,7 @@ export default function SettingsModal({ isOpen, onClose, initialTab }: SettingsM
); );
} }
// Styles
const backdropStyle: CSSProperties = { const backdropStyle: CSSProperties = {
position: 'fixed', position: 'fixed',
inset: 0, inset: 0,
@@ -133,7 +123,7 @@ const modalStyle: CSSProperties = {
}; };
const sidebarStyle: CSSProperties = { const sidebarStyle: CSSProperties = {
width: '220px', width: '200px',
background: 'var(--settings-sidebar-bg)', background: 'var(--settings-sidebar-bg)',
borderRight: '1px solid var(--settings-border)', borderRight: '1px solid var(--settings-border)',
display: 'flex', display: 'flex',
@@ -146,6 +136,7 @@ const logoStyle: CSSProperties = {
fontSize: '15px', fontSize: '15px',
fontWeight: 600, fontWeight: 600,
color: 'var(--settings-text)', color: 'var(--settings-text)',
letterSpacing: '-0.01em',
}; };
const navStyle: CSSProperties = { const navStyle: CSSProperties = {
@@ -164,8 +155,7 @@ const navItemStyle: CSSProperties = {
borderRadius: '8px', borderRadius: '8px',
}; };
const footerStyle: CSSProperties = { const userSectionStyle: CSSProperties = {
marginTop: 'auto',
padding: '16px 20px', padding: '16px 20px',
borderTop: '1px solid var(--settings-border)', borderTop: '1px solid var(--settings-border)',
display: 'flex', display: 'flex',
@@ -173,19 +163,31 @@ const footerStyle: CSSProperties = {
gap: '8px', gap: '8px',
}; };
const footerLabelStyle: CSSProperties = { const userLabelStyle: CSSProperties = {
fontSize: '10px', fontSize: '10px',
fontWeight: 600, fontWeight: 500,
letterSpacing: '0.06em',
textTransform: 'uppercase', textTransform: 'uppercase',
letterSpacing: '0.05em',
color: 'var(--settings-muted)', color: 'var(--settings-muted)',
}; };
const footerTextStyle: CSSProperties = { const userEmailStyle: CSSProperties = {
fontSize: '12px', fontSize: '12px',
color: 'var(--settings-subtext)', color: 'var(--settings-subtext)',
margin: 0, wordBreak: 'break-all',
lineHeight: 1.5, };
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 = { const contentStyle: CSSProperties = {
@@ -211,13 +213,13 @@ const titleStyle: CSSProperties = {
}; };
const closeBtnStyle: CSSProperties = { const closeBtnStyle: CSSProperties = {
background: 'transparent', background: 'none',
border: 'none', border: 'none',
color: 'var(--settings-muted)', color: 'var(--settings-muted)',
fontSize: '20px',
cursor: 'pointer', cursor: 'pointer',
fontSize: '24px',
lineHeight: 1,
padding: '4px 8px', padding: '4px 8px',
lineHeight: 1,
transition: 'color 0.15s ease', transition: 'color 0.15s ease',
}; };
+1 -24
View File
@@ -322,30 +322,7 @@ export default function DatabaseTableView({ onNodeClick, refreshToken = 0, toolb
{node.id} {node.id}
</span> </span>
<span style={{ minWidth: 0 }}> <span style={{ minWidth: 0, fontSize: '11px', color: 'var(--rah-text-muted)' }}></span>
{node.context?.name ? (
<span
style={{
display: 'inline-flex',
alignItems: 'center',
maxWidth: '100%',
padding: '3px 8px',
borderRadius: '999px',
border: '1px solid var(--rah-border)',
background: 'var(--rah-bg-surface)',
color: 'var(--rah-text-soft)',
fontSize: '11px',
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
}}
>
{node.context.name}
</span>
) : (
<span style={{ fontSize: '11px', color: 'var(--rah-text-muted)' }}></span>
)}
</span>
<span style={{ fontSize: '12px', color: node.edge_count ? 'var(--rah-text-soft)' : 'var(--rah-text-muted)' }}> <span style={{ fontSize: '12px', color: node.edge_count ? 'var(--rah-text-soft)' : 'var(--rah-text-muted)' }}>
{node.edge_count ?? 0} {node.edge_count ?? 0}
-21
View File
@@ -133,27 +133,6 @@ export default function GridView({ nodes, onNodeClick }: GridViewProps) {
</div> </div>
)} )}
{/* Footer with Context */}
{node.context?.name && (
<div style={{
display: 'flex',
gap: '4px',
flexWrap: 'wrap',
marginTop: 'auto'
}}>
<span
style={{
padding: '2px 6px',
background: '#1a1a1a',
borderRadius: '3px',
fontSize: '10px',
color: '#888'
}}
>
{node.context.name}
</span>
</div>
)}
</button> </button>
); );
})} })}
-15
View File
@@ -146,21 +146,6 @@ export default function ListView({ nodes, onNodeClick }: ListViewProps) {
}}> }}>
{processedState} {processedState}
</span> </span>
{node.context?.name && (
<span
style={{
padding: '2px 8px',
background: 'var(--rah-bg-active)',
border: '1px solid var(--rah-border-strong)',
borderRadius: '8px',
fontSize: '11px',
color: 'var(--rah-text-base)'
}}
>
{node.context.name}
</span>
)}
{/* Date */} {/* Date */}
<span style={{ <span style={{
fontSize: '11px', fontSize: '11px',
+8 -150
View File
@@ -2,8 +2,8 @@
import { useEffect, useState, useRef, useCallback } from 'react'; import { useEffect, useState, useRef, useCallback } from 'react';
import { createPortal } from 'react-dom'; import { createPortal } from 'react-dom';
import { Filter, ChevronDown, X, ArrowUpDown, GripVertical, Inbox, Check } from 'lucide-react'; import { ChevronDown, X, ArrowUpDown, GripVertical, Inbox, Check } from 'lucide-react';
import type { ContextSummary, Node } from '@/types/database'; import type { Node } from '@/types/database';
import { getNodeIcon } from '@/utils/nodeIcons'; import { getNodeIcon } from '@/utils/nodeIcons';
import { usePersistentState } from '@/hooks/usePersistentState'; import { usePersistentState } from '@/hooks/usePersistentState';
import type { PendingNode } from '@/components/layout/ThreePanelLayout'; import type { PendingNode } from '@/components/layout/ThreePanelLayout';
@@ -29,9 +29,6 @@ interface ViewsOverlayProps {
refreshToken?: number; refreshToken?: number;
pendingNodes?: PendingNode[]; pendingNodes?: PendingNode[];
onDismissPending?: (id: string) => void; onDismissPending?: (id: string) => void;
externalContextFilterId?: number | null;
onContextFilterSelect?: (contextId: number | null, contextName?: string | null) => void;
onClearExternalContextFilter?: () => void;
toolbarHost?: HTMLDivElement | null; toolbarHost?: HTMLDivElement | null;
} }
@@ -216,15 +213,9 @@ export default function ViewsOverlay({
refreshToken = 0, refreshToken = 0,
pendingNodes, pendingNodes,
onDismissPending, onDismissPending,
externalContextFilterId = null,
onContextFilterSelect,
onClearExternalContextFilter,
toolbarHost, toolbarHost,
}: ViewsOverlayProps) { }: ViewsOverlayProps) {
const [contexts, setContexts] = useState<ContextSummary[]>([]);
const [contextsLoading, setContextsLoading] = useState(true);
// Sort order (persisted) // Sort order (persisted)
const [sortOrder, setSortOrder] = usePersistentState<SortOrder>('ui.feedSortOrder', 'updated'); const [sortOrder, setSortOrder] = usePersistentState<SortOrder>('ui.feedSortOrder', 'updated');
@@ -245,22 +236,6 @@ export default function ViewsOverlay({
? 'not_processed' ? 'not_processed'
: 'all'; : '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[]) => { const applyProcessedFilter = useCallback((nodes: Node[]) => {
if (processedFilter === 'all') return nodes; if (processedFilter === 'all') return nodes;
return nodes.filter((node) => getNodeProcessedState(node.metadata) === processedFilter); 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' const apiSort = sortOrder === 'custom' || sortOrder === 'processed' || sortOrder === 'not_processed'
? 'updated' ? 'updated'
: sortOrder; : 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(); const data = await response.json();
if (!response.ok || !data.success) { if (!response.ok || !data.success) {
throw new Error(data.error || 'Failed to fetch nodes'); throw new Error(data.error || 'Failed to fetch nodes');
@@ -301,45 +276,28 @@ export default function ViewsOverlay({
} finally { } finally {
setFilteredNodesLoading(false); setFilteredNodesLoading(false);
} }
}, [sortOrder, customOrder, applyProcessedFilter, externalContextFilterId]); }, [sortOrder, customOrder, applyProcessedFilter]);
// Fetch contexts on mount // Fetch nodes on mount and when sort/refreshToken change
useEffect(() => {
fetchContexts();
}, [fetchContexts]);
// Fetch nodes on mount and when sort/refreshToken/context filter change
useEffect(() => { useEffect(() => {
if (refreshToken > 0) { if (refreshToken > 0) {
console.log('🔄 Feed refreshing due to SSE event (refreshToken:', refreshToken, ')'); console.log('🔄 Feed refreshing due to SSE event (refreshToken:', refreshToken, ')');
} }
fetchAllNodes(); fetchAllNodes();
}, [fetchAllNodes, refreshToken, sortOrder, externalContextFilterId]); }, [fetchAllNodes, refreshToken, sortOrder]);
// Refresh contexts when data changes
useEffect(() => {
if (refreshToken > 0) {
fetchContexts();
}
}, [refreshToken, fetchContexts]);
// Close dropdowns on outside click // Close dropdowns on outside click
const contextPickerRef = useRef<HTMLDivElement>(null);
const sortDropdownRef = useRef<HTMLDivElement>(null); const sortDropdownRef = useRef<HTMLDivElement>(null);
const [showContextPicker, setShowContextPicker] = useState(false);
useEffect(() => { useEffect(() => {
const handleClickOutside = (e: MouseEvent) => { 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)) { if (showSortDropdown && sortDropdownRef.current && !sortDropdownRef.current.contains(e.target as HTMLElement)) {
setShowSortDropdown(false); setShowSortDropdown(false);
} }
}; };
document.addEventListener('mousedown', handleClickOutside); document.addEventListener('mousedown', handleClickOutside);
return () => document.removeEventListener('mousedown', handleClickOutside); return () => document.removeEventListener('mousedown', handleClickOutside);
}, [showContextPicker, showSortDropdown]); }, [showSortDropdown]);
// Reorder handlers // Reorder handlers
const handleReorderDrop = useCallback((dropIdx: number) => { const handleReorderDrop = useCallback((dropIdx: number) => {
@@ -615,107 +573,7 @@ export default function ViewsOverlay({
gap: '10px', gap: '10px',
flexWrap: 'wrap' flexWrap: 'wrap'
}}> }}>
<div style={{ display: 'flex', alignItems: 'center', gap: '6px', flexWrap: 'wrap', flex: 1 }}> <div style={{ display: 'flex', alignItems: 'center', gap: '6px', flexWrap: 'wrap', flex: 1 }} />
{externalContextFilterId ? (
<div
style={{
display: 'flex',
alignItems: 'center',
gap: '5px',
padding: '3px 8px',
background: 'rgba(34, 197, 94, 0.06)',
border: '1px solid rgba(34, 197, 94, 0.12)',
borderRadius: '5px',
fontSize: '11px',
color: '#5a9'
}}
>
{contexts.find((context) => context.id === externalContextFilterId)?.name ?? 'Context'}
<button
onClick={() => onClearExternalContextFilter?.()}
style={{
background: 'transparent',
border: 'none',
color: '#5a9',
cursor: 'pointer',
padding: '0',
display: 'flex',
alignItems: 'center'
}}
>
<X size={11} />
</button>
</div>
) : null}
<div style={{ position: 'relative' }} ref={contextPickerRef}>
<button
onClick={() => setShowContextPicker(!showContextPicker)}
title="Context filter"
style={{
display: 'flex',
alignItems: 'center',
gap: '4px',
padding: '4px 8px',
background: externalContextFilterId ? 'rgba(34, 197, 94, 0.06)' : 'transparent',
border: '1px solid var(--rah-border)',
borderRadius: '5px',
color: 'var(--rah-text-soft)',
fontSize: '11px',
cursor: 'pointer',
transition: 'all 0.15s ease'
}}
>
<Filter size={11} />
Context
</button>
{showContextPicker && (
<div style={{
position: 'absolute',
top: '100%',
left: 0,
marginTop: '4px',
background: 'var(--rah-bg-panel)',
border: '1px solid var(--rah-border)',
borderRadius: '10px',
padding: '6px',
minWidth: '220px',
maxHeight: '320px',
overflowY: 'auto',
zIndex: 1000,
boxShadow: '0 8px 24px rgba(0,0,0,0.4)'
}}>
<button
onClick={() => {
onClearExternalContextFilter?.();
setShowContextPicker(false);
}}
style={pickerRowStyle}
>
All contexts
</button>
{contextsLoading ? (
<div style={{ padding: '12px', color: 'var(--rah-text-muted)', fontSize: '12px', textAlign: 'center' }}>
Loading contexts...
</div>
) : contexts.map((context) => (
<button
key={context.id}
onClick={() => {
onContextFilterSelect?.(context.id, context.name);
setShowContextPicker(false);
}}
style={pickerRowStyle}
>
<span>{context.name}</span>
<span style={pickerCountStyle}>{context.count}</span>
</button>
))}
</div>
)}
</div>
</div>
{/* Sort dropdown */} {/* Sort dropdown */}
<div style={{ position: 'relative' }} ref={sortDropdownRef}> <div style={{ position: 'relative' }} ref={sortDropdownRef}>
+1 -1
View File
@@ -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. 1. Node quality: duplicates, vague descriptions, missing dates, weak titles.
2. Edge quality: missing links, weak explanations, wrong directionality. 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. 4. Skill quality: trigger clarity, overlap, dead/unused skills.
## Output Format ## Output Format
+2 -2
View File
@@ -3,7 +3,7 @@ name: Calibration
description: "Run periodic check-ins to recalibrate goals, projects, preferences, and graph structure." 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_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." 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 # Calibration
@@ -14,7 +14,7 @@ Re-anchor the graph to the user's current state.
## Check-in Sequence ## 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. 2. Ask what changed: goals, priorities, constraints, beliefs, preferences.
3. Identify stale nodes and missing nodes. 3. Identify stale nodes and missing nodes.
4. Propose precise updates: update existing vs create new. 4. Propose precise updates: update existing vs create new.
+2 -5
View File
@@ -13,7 +13,7 @@ description: "Use for graph read, write, connect, classify, or traverse operatio
4. Search before create to avoid duplicates. 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. 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). 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. 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. 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. - `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. - For idea capture from chat, the `source` should usually be the raw user thought, not a compressed assistant summary.
- `link`: external source URL only. - `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: - `metadata`: use the canonical node metadata contract when metadata is needed:
- `type` - `type`
- `state` (`processed` or `not_processed`) - `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 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) 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`. 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`.
+14 -17
View File
@@ -1,13 +1,13 @@
--- ---
name: Node Context Enrichment 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 # 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 ## Goal
@@ -17,21 +17,19 @@ Replace weak descriptions with a single clean natural description that captures:
2. Why it is in Brad's graph 2. Why it is in Brad's graph
3. Status in Brad's workflow 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 ## Workflow
1. Load the node and inspect title, description, source, link, metadata, context, and nearby edges. 1. Load the node and inspect title, description, source, link, metadata, and nearby edges.
2. Search for adjacent context before rewriting: 2. Search for adjacent graph context before rewriting:
- the node's primary context and its anchor node when present
- recently connected project or belief nodes - recently connected project or belief nodes
- related nodes with overlapping titles, creators, or source themes - 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:. 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. 5. Suggest 1-3 high-signal edges when obvious.
6. Suggest 1-3 high-signal edges when obvious. 6. Update the node once the description is strong enough to be useful.
7. 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:
8. After the update, tell the user what changed and ask whether they want to refine the important framing:
- what it is - what it is
- why it belongs in the graph - why it belongs in the graph
- status / current relevance / workflow position - status / current relevance / workflow position
@@ -49,7 +47,7 @@ Every rewritten description must naturally cover:
2. Why 2. Why
- why Brad saved it - why Brad saved it
- what project, belief, question, or theme it connects to - 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 3. Status
- queued, in progress, processed, not yet reviewed, saved for later, etc. - queued, in progress, processed, not yet reviewed, saved for later, etc.
- if unknown, say naturally that it has not been reviewed yet - 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. 3. Enrich each node individually. Do not reuse boilerplate "why" text across unrelated nodes.
4. Return a compact summary of: 4. Return a compact summary of:
- nodes updated - nodes updated
- contexts to review
- edge suggestions not yet created - edge suggestions not yet created
## Quality Bar ## Quality Bar
- No filler phrases like `insightful for understanding`, `relevant to`, or `important for`. - No filler phrases like `insightful for understanding`, `relevant to`, or `important for`.
- No generic summaries that only restate the topic. - 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. - Prefer one compact 3-sentence description over bloated prose.
## Output Pattern ## Output Pattern
@@ -83,6 +80,6 @@ Use batch enrichment when cleaning up many nodes with the same failure mode.
For each node: For each node:
- New description - New description
- Context recommendation: keep / clear / set - Framing note: what graph context influenced the rewrite, if any
- Edge suggestions: source -> target with explicit explanation - 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
+4 -34
View File
@@ -7,7 +7,7 @@ description: "Use for new-user setup, empty or near-empty graphs, or major reset
## Your Job ## 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. 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. - **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. 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 ## Explain the System First
Before asking anything, orient the user. Be direct, not salesy: 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: 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. - **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. - **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. - **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: 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 ## 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: 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. - **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. - **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: - **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 - 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. Do your best to build the graph as useful context emerges.
- Add nodes when the user mentions concrete things worth keeping. - 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. - 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. - 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. 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 - 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" - 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 - Every edge needs an explicit explanation sentence, and agent-driven edge creation should only happen after confirmation
## Propose Before Writing ## Propose Before Writing
When there is enough context, summarize the proposed structure before touching the database: 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. 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: 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]." > "After this starter pass, the best next move will be [add a source / connect these nodes / capture another active project]."
+7 -11
View File
@@ -15,7 +15,6 @@ export interface QuickAddInput {
rawInput: string; rawInput: string;
mode?: QuickAddMode; mode?: QuickAddMode;
description?: string; description?: string;
contextId?: number | null;
} }
export interface QuickAddResult { export interface QuickAddResult {
@@ -200,7 +199,7 @@ function isCreateNodeResponse(value: unknown): value is CreateNodeResponse {
return true; return true;
} }
async function handleExtractionQuickAdd(type: ExtractionQuickAddType, url: string, task: string, contextId?: number | null): Promise<string> { async function handleExtractionQuickAdd(type: ExtractionQuickAddType, url: string, task: string): Promise<string> {
const { toolName, execute } = EXTRACTION_TOOL_MAP[type]; const { toolName, execute } = EXTRACTION_TOOL_MAP[type];
if (!execute) { if (!execute) {
throw new Error(`Tool ${toolName} does not have an execute function`); 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, 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<string> { async function handleNoteQuickAdd(rawInput: string, task: string, userDescription?: string): Promise<string> {
const content = rawInput.trim(); const content = rawInput.trim();
if (!content) { if (!content) {
throw new Error('Input is required to create a note'); 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<string, unknown> = { const nodePayload: Record<string, unknown> = {
title, title,
source: content, source: content,
context_id: contextId,
metadata: { metadata: {
type: 'note', type: 'note',
state: 'not_processed', 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<string> { async function handleChatTranscriptQuickAdd(rawInput: string, task: string): Promise<string> {
const transcript = rawInput.trim(); const transcript = rawInput.trim();
if (!transcript) { if (!transcript) {
throw new Error('Input is required to import a chat 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, title,
description: nodeDescription, description: nodeDescription,
source: transcript, source: transcript,
context_id: contextId,
metadata, metadata,
}), }),
}); });
@@ -451,7 +447,7 @@ async function handleChatTranscriptQuickAdd(rawInput: string, task: string, cont
}); });
} }
export async function enqueueQuickAdd({ rawInput, mode, description, contextId }: QuickAddInput): Promise<QuickAddResult> { export async function enqueueQuickAdd({ rawInput, mode, description }: QuickAddInput): Promise<QuickAddResult> {
const inputType = detectInputType(rawInput, mode); const inputType = detectInputType(rawInput, mode);
const task = buildTaskPrompt(inputType, rawInput); const task = buildTaskPrompt(inputType, rawInput);
const id = `qa_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`; 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 { try {
let summary: string; let summary: string;
if (inputType === 'note') { if (inputType === 'note') {
summary = await handleNoteQuickAdd(rawInput, task, description, contextId); summary = await handleNoteQuickAdd(rawInput, task, description);
} else if (inputType === 'chat') { } else if (inputType === 'chat') {
summary = await handleChatTranscriptQuickAdd(rawInput, task, contextId); summary = await handleChatTranscriptQuickAdd(rawInput, task);
} else { } else {
summary = await handleExtractionQuickAdd(inputType as ExtractionQuickAddType, rawInput, task, contextId); summary = await handleExtractionQuickAdd(inputType as ExtractionQuickAddType, rawInput, task);
} }
console.log(`[QuickAdd] Completed: ${task}`); console.log(`[QuickAdd] Completed: ${task}`);
-7
View File
@@ -123,13 +123,6 @@ export function summarizeToolExecution(toolName: string, args: any, result: any)
return 'No edges found.'; 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) { if (result.data?.formatted_display) {
return ensureString(result.data.formatted_display) || fallback; return ensureString(result.data.formatted_display) || fallback;
} }
+24
View File
@@ -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<string, string> = {}
): Record<string, string> {
return { ...headers };
}
export function applyRequestSupabaseAuth(_request: NextRequest): () => void {
return () => {};
}
+5 -139
View File
@@ -8,23 +8,6 @@ export interface AutoContextSummary {
edgeCount: number; 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 { function truncate(value: string | null | undefined, maxChars: number): string {
const trimmed = (value || '').trim(); const trimmed = (value || '').trim();
if (trimmed.length <= maxChars) return trimmed; 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); return fetchAutoContextRows(limit);
} }
export function getContextSummaries(limit = 12): PromptContextSummary[] { export function buildHubNodesBlock(limit = 10): string | null {
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 {
const summaries = getHubNodes(limit); const summaries = getHubNodes(limit);
if (summaries.length === 0) { if (summaries.length === 0) {
return null; return null;
@@ -210,10 +76,10 @@ export function buildHubNodesBlock(limit = 5): string | null {
return lines.join('\n'); return lines.join('\n');
} }
export function getAutoContextSummaries(limit = 5): AutoContextSummary[] { export function getAutoContextSummaries(limit = 10): AutoContextSummary[] {
return getHubNodes(limit); return getHubNodes(limit);
} }
export function buildAutoContextBlock(limit = 5): string | null { export function buildAutoContextBlock(limit = 10): string | null {
return buildContextsBlock(limit); return buildHubNodesBlock(limit);
} }
-1
View File
@@ -463,7 +463,6 @@ export class ChunkService {
return result.rows; return result.rows;
} catch (error) { } 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); console.warn('[ChunkSearch] FTS chunk search failed, falling back to LIKE:', error);
return []; return [];
} }
-237
View File
@@ -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<ContextSummary[]> {
const sqlite = getSQLiteClient();
const rows = sqlite.query<ContextSummary>(`
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<ContextSummary | null> {
const sqlite = getSQLiteClient();
const row = sqlite.query<ContextSummary>(`
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<Context | null> {
const normalized = assertContextName(name);
const sqlite = getSQLiteClient();
const row = sqlite.query<ContextRow>(`
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<Context> {
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<Context> {
const sqlite = getSQLiteClient();
const existing = sqlite.query<ContextRow>(`
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<Node[]> {
return nodeService.getNodes({ contextId: id, limit: 500 });
}
async resolveContextId(input: { context_id?: number | null; context_name?: string | null }): Promise<number | null | undefined> {
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();
-1
View File
@@ -4,7 +4,6 @@ import type { DatabaseIntegrityReport } from './sqlite-client';
export { nodeService, NodeService } from './nodes'; export { nodeService, NodeService } from './nodes';
export { chunkService, ChunkService } from './chunks'; export { chunkService, ChunkService } from './chunks';
export { edgeService, EdgeService } from './edges'; export { edgeService, EdgeService } from './edges';
export { contextService, ContextService } from './contextService';
// export { HelperService } from './helpers'; // Removed - migrated to JSON-based service // export { HelperService } from './helpers'; // Removed - migrated to JSON-based service
// Types // Types
+26 -95
View File
@@ -2,12 +2,10 @@ import { getSQLiteClient } from './sqlite-client';
import { Node, NodeFilters } from '@/types/database'; import { Node, NodeFilters } from '@/types/database';
import { eventBroadcaster } from '../events'; import { eventBroadcaster } from '../events';
import { EmbeddingService } from '@/services/embeddings'; import { EmbeddingService } from '@/services/embeddings';
import { scoreNodeSearchMatch } from './searchRanking'; import { getHighSignalSearchTerms, scoreNodeSearchMatch } from './searchRanking';
import { buildCanonicalNodeMetadata, mergeNodeMetadata } from '@/services/nodes/metadata'; import { buildCanonicalNodeMetadata, mergeNodeMetadata } from '@/services/nodes/metadata';
type NodeRow = Node & { type NodeRow = Node;
context_json: string | null;
};
type NodeSearchRow = NodeRow & { rank?: number; similarity?: number }; type NodeSearchRow = NodeRow & { rank?: number; similarity?: number };
function sanitizeFtsQuery(input: string): string { function sanitizeFtsQuery(input: string): string {
@@ -21,35 +19,7 @@ function sanitizeFtsQuery(input: string): string {
} }
function extractRelaxedSearchTerms(query: string): string[] { function extractRelaxedSearchTerms(query: string): string[] {
const stopWords = new Set([ return getHighSignalSearchTerms(query).slice(0, 8);
'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<string>();
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);
} }
function reciprocalRankFuse<T extends { id: number }>( function reciprocalRankFuse<T extends { id: number }>(
@@ -90,7 +60,6 @@ export class NodeService {
eventAfter, eventAfter,
eventBefore, eventBefore,
chunkStatus, chunkStatus,
contextId,
} = filters; } = filters;
if (search?.trim()) { if (search?.trim()) {
@@ -112,8 +81,6 @@ export class NodeService {
if (eventAfter) { query += ` AND n.event_date >= ?`; params.push(eventAfter); } if (eventAfter) { query += ` AND n.event_date >= ?`; params.push(eventAfter); }
if (eventBefore) { query += ` AND n.event_date < ?`; params.push(eventBefore); } if (eventBefore) { query += ` AND n.event_date < ?`; params.push(eventBefore); }
if (chunkStatus) { query += ` AND n.chunk_status = ?`; params.push(chunkStatus); } 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); const result = sqlite.query<{ total: number }>(query, params);
return result.rows[0]?.total ?? 0; return result.rows[0]?.total ?? 0;
} }
@@ -131,7 +98,6 @@ export class NodeService {
eventAfter, eventAfter,
eventBefore, eventBefore,
chunkStatus, chunkStatus,
contextId,
} = filters; } = filters;
if (search?.trim()) { if (search?.trim()) {
@@ -143,14 +109,9 @@ export class NodeService {
let query = ` let query = `
SELECT n.id, n.title, n.description, n.source, n.link, n.event_date, n.metadata, 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.chunk_status, n.embedding_updated_at, n.embedding_text,
n.created_at, n.updated_at, n.context_id, n.created_at, n.updated_at,
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,
(SELECT COUNT(*) FROM edges WHERE from_node_id = n.id OR to_node_id = n.id) as edge_count (SELECT COUNT(*) FROM edges WHERE from_node_id = n.id OR to_node_id = n.id) as edge_count
FROM nodes n FROM nodes n
LEFT JOIN contexts c ON c.id = n.context_id
WHERE 1=1 WHERE 1=1
`; `;
const params: any[] = []; const params: any[] = [];
@@ -182,11 +143,6 @@ export class NodeService {
query += ` AND n.chunk_status = ?`; query += ` AND n.chunk_status = ?`;
params.push(chunkStatus); params.push(chunkStatus);
} }
if (contextId !== undefined) {
query += ` AND n.context_id = ?`;
params.push(contextId);
}
// Sorting logic // Sorting logic
if (search) { if (search) {
// For search queries, prioritize by relevance: exact title → starts with → contains in title → description → source // For search queries, prioritize by relevance: exact title → starts with → contains in title → description → source
@@ -243,13 +199,8 @@ export class NodeService {
const query = ` const query = `
SELECT n.id, n.title, n.description, n.source, n.link, n.event_date, n.metadata, 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.chunk_status, n.embedding_updated_at, n.embedding_text,
n.created_at, n.updated_at, n.context_id, n.created_at, n.updated_at
CASE
WHEN c.id IS NULL THEN NULL
ELSE json_object('id', c.id, 'name', c.name, 'description', c.description, 'icon', c.icon)
END as context_json
FROM nodes n FROM nodes n
LEFT JOIN contexts c ON c.id = n.context_id
WHERE n.id = ? WHERE n.id = ?
`; `;
const result = sqlite.query<NodeRow>(query, [id]); const result = sqlite.query<NodeRow>(query, [id]);
@@ -275,7 +226,6 @@ export class NodeService {
event_date, event_date,
chunk_status, chunk_status,
metadata = {}, metadata = {},
context_id,
} = nodeData; } = nodeData;
const canonicalMetadata = buildCanonicalNodeMetadata({ metadata }); const canonicalMetadata = buildCanonicalNodeMetadata({ metadata });
const now = new Date().toISOString(); const now = new Date().toISOString();
@@ -284,8 +234,8 @@ export class NodeService {
const nodeId = sqlite.transaction(() => { const nodeId = sqlite.transaction(() => {
// Insert node using prepare/run for lastInsertRowid access // Insert node using prepare/run for lastInsertRowid access
const nodeResult = sqlite.prepare(` const nodeResult = sqlite.prepare(`
INSERT INTO nodes (title, description, source, link, event_date, metadata, chunk_status, context_id, created_at, updated_at) INSERT INTO nodes (title, description, source, link, event_date, metadata, chunk_status, created_at, updated_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
`).run( `).run(
title, title,
description ?? null, description ?? null,
@@ -294,7 +244,6 @@ export class NodeService {
event_date ?? null, event_date ?? null,
JSON.stringify(canonicalMetadata), JSON.stringify(canonicalMetadata),
chunk_status ?? null, chunk_status ?? null,
context_id ?? null,
now, now,
now now
); );
@@ -353,10 +302,6 @@ export class NodeService {
if (source !== undefined) { setFields.push('source = ?'); params.push(source); } if (source !== undefined) { setFields.push('source = ?'); params.push(source); }
if (link !== undefined) { setFields.push('link = ?'); params.push(link); } if (link !== undefined) { setFields.push('link = ?'); params.push(link); }
if (event_date !== undefined) { setFields.push('event_date = ?'); params.push(event_date); } 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')) { if (Object.prototype.hasOwnProperty.call(updates, 'chunk_status')) {
setFields.push('chunk_status = ?'); setFields.push('chunk_status = ?');
params.push(updates.chunk_status ?? null); params.push(updates.chunk_status ?? null);
@@ -419,11 +364,9 @@ export class NodeService {
} }
private mapNodeRow(row: NodeRow): Node { private mapNodeRow(row: NodeRow): Node {
const { context_json, ...baseRow } = row;
return { return {
...baseRow, ...row,
metadata: baseRow.metadata ? (typeof baseRow.metadata === 'string' ? JSON.parse(baseRow.metadata) : baseRow.metadata) : null, metadata: row.metadata ? (typeof row.metadata === 'string' ? JSON.parse(row.metadata) : row.metadata) : null,
context: context_json ? JSON.parse(context_json) : null,
}; };
} }
@@ -433,7 +376,6 @@ export class NodeService {
createdBefore, createdBefore,
eventAfter, eventAfter,
eventBefore, eventBefore,
contextId,
} = filters; } = filters;
const clauses: string[] = []; const clauses: string[] = [];
@@ -443,8 +385,6 @@ export class NodeService {
if (createdBefore) { clauses.push(`${alias}.created_at < ?`); params.push(createdBefore); } if (createdBefore) { clauses.push(`${alias}.created_at < ?`); params.push(createdBefore); }
if (eventAfter) { clauses.push(`${alias}.event_date >= ?`); params.push(eventAfter); } if (eventAfter) { clauses.push(`${alias}.event_date >= ?`); params.push(eventAfter); }
if (eventBefore) { clauses.push(`${alias}.event_date < ?`); params.push(eventBefore); } if (eventBefore) { clauses.push(`${alias}.event_date < ?`); params.push(eventBefore); }
if (contextId !== undefined) { clauses.push(`${alias}.context_id = ?`); params.push(contextId); }
return { clauses, params }; return { clauses, params };
} }
@@ -517,7 +457,8 @@ export class NodeService {
return Number(result.rows[0]?.total ?? 0); return Number(result.rows[0]?.total ?? 0);
} catch (error) { } 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[] { ): NodeSearchRow[] {
const ftsQuery = sanitizeFtsQuery(search); const ftsQuery = sanitizeFtsQuery(search);
if (!ftsQuery) return []; if (!ftsQuery) return [];
if (!sqlite.canUseFtsTable('nodes')) return []; if (!sqlite.canUseFtsTable('nodes')) return [];
const { clauses, params } = this.buildNodeFilterClauses(filters); 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, 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.chunk_status, n.embedding_updated_at, n.embedding_text,
n.created_at, n.updated_at, n.context_id, n.created_at, n.updated_at,
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,
fm.rank fm.rank
FROM fts_matches fm FROM fts_matches fm
JOIN nodes n ON n.id = fm.rowid JOIN nodes n ON n.id = fm.rowid
LEFT JOIN contexts c ON c.id = n.context_id
${whereClauses} ${whereClauses}
ORDER BY fm.rank ORDER BY fm.rank
LIMIT ? LIMIT ?
@@ -577,7 +514,8 @@ export class NodeService {
return result.rows; return result.rows;
} catch (error) { } 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 []; return [];
} }
} }
@@ -593,13 +531,8 @@ export class NodeService {
let query = ` let query = `
SELECT n.id, n.title, n.description, n.source, n.link, n.event_date, n.metadata, 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.chunk_status, n.embedding_updated_at, n.embedding_text,
n.created_at, n.updated_at, n.context_id, n.created_at, n.updated_at
CASE
WHEN c.id IS NULL THEN NULL
ELSE json_object('id', c.id, 'name', c.name, 'description', c.description, 'icon', c.icon)
END as context_json
FROM nodes n FROM nodes n
LEFT JOIN contexts c ON c.id = n.context_id
WHERE 1=1 WHERE 1=1
`; `;
const queryParams = [...params]; const queryParams = [...params];
@@ -641,13 +574,8 @@ export class NodeService {
let query = ` let query = `
SELECT n.id, n.title, n.description, n.source, n.link, n.event_date, n.metadata, 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.chunk_status, n.embedding_updated_at, n.embedding_text,
n.created_at, n.updated_at, n.context_id, n.created_at, n.updated_at
CASE
WHEN c.id IS NULL THEN NULL
ELSE json_object('id', c.id, 'name', c.name, 'description', c.description, 'icon', c.icon)
END as context_json
FROM nodes n FROM nodes n
LEFT JOIN contexts c ON c.id = n.context_id
WHERE 1=1 WHERE 1=1
`; `;
const queryParams = [...params]; 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, 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.chunk_status, n.embedding_updated_at, n.embedding_text,
n.created_at, n.updated_at, n.context_id, n.created_at, n.updated_at,
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,
(1.0 / (1.0 + vm.distance)) AS similarity (1.0 / (1.0 + vm.distance)) AS similarity
FROM vector_matches vm FROM vector_matches vm
JOIN nodes n ON n.id = vm.node_id JOIN nodes n ON n.id = vm.node_id
LEFT JOIN contexts c ON c.id = n.context_id
${whereClauses} ${whereClauses}
ORDER BY vm.distance ORDER BY vm.distance
LIMIT ? LIMIT ?
@@ -769,6 +692,14 @@ export class NodeService {
return updatedNodes; return updatedNodes;
} }
async getAllDimensions(): Promise<string[]> {
return [];
}
async getDimensionStats(): Promise<{dimension: string, count: number}[]> {
return [];
}
} }
// Export singleton instance // Export singleton instance
File diff suppressed because it is too large Load Diff
+1
View File
@@ -16,6 +16,7 @@ export interface DatabaseEvent {
| 'AGENT_DELEGATION_CREATED' | 'AGENT_DELEGATION_CREATED'
| 'AGENT_DELEGATION_UPDATED' | 'AGENT_DELEGATION_UPDATED'
| 'GUIDE_UPDATED' | 'GUIDE_UPDATED'
| 'SKILL_UPDATED'
| 'QUICK_ADD_COMPLETED' | 'QUICK_ADD_COMPLETED'
| 'QUICK_ADD_FAILED' | 'QUICK_ADD_FAILED'
| 'CONNECTION_ESTABLISHED'; | 'CONNECTION_ESTABLISHED';
@@ -1,4 +1,3 @@
import { contextService } from '@/services/database/contextService';
import { nodeService } from '@/services/database/nodes'; import { nodeService } from '@/services/database/nodes';
import { countHighSignalQueryTermMatches, getHighSignalSearchTerms, scoreNodeSearchMatch } from '@/services/database/searchRanking'; import { countHighSignalQueryTermMatches, getHighSignalSearchTerms, scoreNodeSearchMatch } from '@/services/database/searchRanking';
import type { Node } from '@/types/database'; import type { Node } from '@/types/database';
@@ -6,8 +5,6 @@ import type { Node } from '@/types/database';
export interface DirectNodeLookupInput { export interface DirectNodeLookupInput {
search?: string; search?: string;
limit?: number; limit?: number;
context_name?: string;
contextId?: number;
createdAfter?: string; createdAfter?: string;
createdBefore?: string; createdBefore?: string;
eventAfter?: string; eventAfter?: string;
@@ -20,7 +17,6 @@ export interface DirectNodeLookupResult {
filtersApplied: { filtersApplied: {
search?: string; search?: string;
limit: number; limit: number;
context_name?: string;
createdAfter?: string; createdAfter?: string;
createdBefore?: string; createdBefore?: string;
eventAfter?: 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 { function hasStrongAnchorMatch(nodes: Node[], searchTerm: string): boolean {
if (!searchTerm || nodes.length === 0) return false; if (!searchTerm || nodes.length === 0) return false;
const highSignalTerms = getHighSignalSearchTerms(searchTerm); const highSignalTerms = getHighSignalSearchTerms(searchTerm);
@@ -87,11 +48,9 @@ export async function directNodeLookup(input: DirectNodeLookupInput): Promise<Di
}; };
} }
const resolvedContext = await resolveSearchContext(input);
const effectiveFilters = { const effectiveFilters = {
search: searchTerm, search: searchTerm,
limit, limit,
contextId: resolvedContext.contextId,
searchMode: 'standard' as const, searchMode: 'standard' as const,
createdAfter: input.createdAfter, createdAfter: input.createdAfter,
createdBefore: input.createdBefore, createdBefore: input.createdBefore,
@@ -102,7 +61,6 @@ export async function directNodeLookup(input: DirectNodeLookupInput): Promise<Di
let safeNodes = await nodeService.getNodes(effectiveFilters); let safeNodes = await nodeService.getNodes(effectiveFilters);
const hadExtraFilters = Boolean( const hadExtraFilters = Boolean(
effectiveFilters.contextId !== undefined ||
effectiveFilters.createdAfter || effectiveFilters.createdAfter ||
effectiveFilters.createdBefore || effectiveFilters.createdBefore ||
effectiveFilters.eventAfter || effectiveFilters.eventAfter ||
@@ -130,7 +88,6 @@ export async function directNodeLookup(input: DirectNodeLookupInput): Promise<Di
filtersApplied: { filtersApplied: {
search: searchTerm, search: searchTerm,
limit, limit,
context_name: resolvedContext.context_name,
createdAfter: input.createdAfter, createdAfter: input.createdAfter,
createdBefore: input.createdBefore, createdBefore: input.createdBefore,
eventAfter: input.eventAfter, eventAfter: input.eventAfter,
+9 -33
View File
@@ -1,5 +1,4 @@
import { chunkService } from '@/services/database/chunks'; import { chunkService } from '@/services/database/chunks';
import { contextService } from '@/services/database/contextService';
import { edgeService } from '@/services/database/edges'; import { edgeService } from '@/services/database/edges';
import { nodeService } from '@/services/database/nodes'; import { nodeService } from '@/services/database/nodes';
import { countHighSignalQueryTermMatches, scoreNodeSearchMatch } from '@/services/database/searchRanking'; import { countHighSignalQueryTermMatches, scoreNodeSearchMatch } from '@/services/database/searchRanking';
@@ -33,7 +32,6 @@ export interface QueryContextResult {
mode: 'skip' | 'focused' | 'query'; mode: 'skip' | 'focused' | 'query';
reason: string; reason: string;
focused_node_id: number | null; focused_node_id: number | null;
active_context_id: number | null;
nodes: RetrievedContextNode[]; nodes: RetrievedContextNode[];
chunks: RetrievedContextChunk[]; chunks: RetrievedContextChunk[];
} }
@@ -41,7 +39,6 @@ export interface QueryContextResult {
export interface RetrieveQueryContextInput { export interface RetrieveQueryContextInput {
query: string; query: string;
focused_node_id?: number | null; focused_node_id?: number | null;
active_context_id?: number | null;
limit?: number; limit?: number;
} }
@@ -104,7 +101,7 @@ function truncateText(value: string | null | undefined, maxLength = 180): string
function queryTermCount(query: string): number { function queryTermCount(query: string): number {
return normalizeWhitespace(query) return normalizeWhitespace(query)
.split(' ') .split(' ')
.map((term) => term.trim()) .map(term => term.trim())
.filter(Boolean) .filter(Boolean)
.length; .length;
} }
@@ -161,7 +158,7 @@ function extractHighSignalTerms(query: string): string[] {
.toLowerCase() .toLowerCase()
.replace(/[^a-z0-9\s]+/g, ' ') .replace(/[^a-z0-9\s]+/g, ' ')
.split(/\s+/) .split(/\s+/)
.map((term) => singularizeTerm(term.trim())) .map(term => singularizeTerm(term.trim()))
.filter(Boolean); .filter(Boolean);
const seen = new Set<string>(); const seen = new Set<string>();
@@ -184,8 +181,7 @@ function isLikelyUserNoteRecallQuery(query: string): boolean {
const explicitRecall = USER_RECALL_PATTERN.test(normalized); const explicitRecall = USER_RECALL_PATTERN.test(normalized);
const firstPersonRecall = FIRST_PERSON_PATTERN.test(normalized) && /\bwhat\b/i.test(normalized); const firstPersonRecall = FIRST_PERSON_PATTERN.test(normalized) && /\bwhat\b/i.test(normalized);
const explicitLookup = LOOKUP_PATTERN.test(normalized) const explicitLookup = LOOKUP_PATTERN.test(normalized) && (FIRST_PERSON_PATTERN.test(normalized) || /\b(created|saved|wrote|added)\b/i.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 firstPersonShareRecall = FIRST_PERSON_SHARE_PATTERN.test(normalized);
const noteHint = NOTE_HINT_PATTERN.test(normalized); const noteHint = NOTE_HINT_PATTERN.test(normalized);
const recentHint = RECENT_REFERENCE_PATTERN.test(normalized); const recentHint = RECENT_REFERENCE_PATTERN.test(normalized);
@@ -211,8 +207,8 @@ function buildRecallSearchVariants(query: string): string[] {
const terms = extractHighSignalTerms(query); const terms = extractHighSignalTerms(query);
if (terms.length === 0) return []; if (terms.length === 0) return [];
const topicalTerms = 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 noteTerms = terms.filter(term => NOTE_TERMS.has(term));
const phraseVariants = extractRecallPhraseVariants(query); const phraseVariants = extractRecallPhraseVariants(query);
const variants: string[] = []; const variants: string[] = [];
@@ -281,8 +277,8 @@ function scoreRecallMatch(node: Node, query: string): number {
if (normalizedSource.includes(normalizedPhrase)) score += 900; if (normalizedSource.includes(normalizedPhrase)) score += 900;
} }
const titleTermMatches = terms.filter((term) => normalizedTitle.includes(term)).length; const titleTermMatches = terms.filter(term => normalizedTitle.includes(term)).length;
const totalTermMatches = terms.filter((term) => combined.includes(term)).length; const totalTermMatches = terms.filter(term => combined.includes(term)).length;
score += titleTermMatches * 250; score += titleTermMatches * 250;
score += totalTermMatches * 120; score += totalTermMatches * 120;
@@ -298,7 +294,7 @@ function scoreRecallMatch(node: Node, query: string): number {
} }
function hasStrongRecallMatch(nodes: Node[], query: string): boolean { 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 { function isLikelyUserAuthoredNote(node: Node): boolean {
@@ -345,7 +341,7 @@ export function isFocusedSourceRequest(query: string): boolean {
export function shouldRetrieveForQuery(query: string): boolean { export function shouldRetrieveForQuery(query: string): boolean {
const trimmed = normalizeWhitespace(query); const trimmed = normalizeWhitespace(query);
if (!trimmed) return false; 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 (isFocusedSourceRequest(trimmed)) return true;
if (SOURCE_DETAIL_PATTERN.test(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<QueryContextResult> { export async function retrieveQueryContext(input: RetrieveQueryContextInput): Promise<QueryContextResult> {
const query = normalizeWhitespace(input.query || ''); const query = normalizeWhitespace(input.query || '');
const focusedNodeId = input.focused_node_id ?? null; 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 limit = Math.min(Math.max(input.limit ?? 6, 1), 12);
const shouldRetrieve = shouldRetrieveForQuery(query); const shouldRetrieve = shouldRetrieveForQuery(query);
@@ -424,16 +419,11 @@ export async function retrieveQueryContext(input: RetrieveQueryContextInput): Pr
mode: 'skip', mode: 'skip',
reason: 'Query is too lightweight or conversational to justify retrieval.', reason: 'Query is too lightweight or conversational to justify retrieval.',
focused_node_id: focusedNodeId, focused_node_id: focusedNodeId,
active_context_id: requestedActiveContextId,
nodes: [], nodes: [],
chunks: [], chunks: [],
}; };
} }
const activeContextId = requestedActiveContextId
? (await contextService.getContextById(requestedActiveContextId))?.id ?? null
: null;
const focusedRequest = isFocusedSourceRequest(query); const focusedRequest = isFocusedSourceRequest(query);
const nodesById = new Map<number, RetrievedContextNode>(); const nodesById = new Map<number, RetrievedContextNode>();
@@ -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) { if (!strongRecallMatch) {
const rankedSeedNodes = rankRetrievedNodes(Array.from(nodesById.values())).slice(0, Math.max(3, limit)); const rankedSeedNodes = rankRetrievedNodes(Array.from(nodesById.values())).slice(0, Math.max(3, limit));
for (const seed of rankedSeedNodes.slice(0, 3)) { 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.' ? '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.', : 'Substantive query: search the graph directly, then pull additional supporting context if helpful.',
focused_node_id: focusedNodeId, focused_node_id: focusedNodeId,
active_context_id: activeContextId,
nodes: finalNodes, nodes: finalNodes,
chunks: chunks.map((chunk) => { chunks: chunks.map((chunk) => {
const owner = finalNodes.find((node) => node.id === chunk.node_id) || directQueryMatches.find((node) => node.id === chunk.node_id); const owner = finalNodes.find((node) => node.id === chunk.node_id) || directQueryMatches.find((node) => node.id === chunk.node_id);
+4 -9
View File
@@ -19,7 +19,6 @@ interface NodeRecord {
title: string; title: string;
source: string | null; source: string | null;
description: string | null; description: string | null;
context_name: string | null;
embedding?: Buffer | null; embedding?: Buffer | null;
embedding_updated_at?: string | null; embedding_updated_at?: string | null;
embedding_text?: string | null; embedding_text?: string | null;
@@ -57,7 +56,6 @@ export class NodeEmbedder {
Title: ${node.title} Title: ${node.title}
Source: ${node.source || 'No source'} Source: ${node.source || 'No source'}
Context: ${node.context_name || 'none'}
Focus on the main concepts, key relationships, and practical implications.`; 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.title,
node.source || '', node.source || '',
node.description, node.description,
node.context_name null
); );
// Add AI analysis if source exists // Add AI analysis if source exists
@@ -173,29 +171,26 @@ Focus on the main concepts, key relationships, and practical implications.`;
if (nodeId) { if (nodeId) {
// Single node // Single node
query = ` 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 n.embedding, n.embedding_updated_at
FROM nodes n FROM nodes n
LEFT JOIN contexts c ON c.id = n.context_id
WHERE n.id = ? WHERE n.id = ?
`; `;
params = [nodeId]; params = [nodeId];
} else if (forceReEmbed) { } else if (forceReEmbed) {
// All nodes // All nodes
query = ` 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 n.embedding, n.embedding_updated_at
FROM nodes n FROM nodes n
LEFT JOIN contexts c ON c.id = n.context_id
ORDER BY n.id ORDER BY n.id
`; `;
} else { } else {
// Only nodes without embeddings // Only nodes without embeddings
query = ` 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 n.embedding, n.embedding_updated_at
FROM nodes n 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 WHERE n.embedding IS NULL OR n.embedding_updated_at IS NULL
ORDER BY n.id ORDER BY n.id
`; `;
+7 -5
View File
@@ -2,6 +2,7 @@ import { tool } from 'ai';
import { z } from 'zod'; import { z } from 'zod';
import { getInternalApiBaseUrl } from '@/services/runtime/apiBase'; import { getInternalApiBaseUrl } from '@/services/runtime/apiBase';
import { formatNodeForChat } from '../infrastructure/nodeFormatter'; import { formatNodeForChat } from '../infrastructure/nodeFormatter';
import { getInternalAuthHeaders } from '@/services/auth/internalAuth';
function extractTextFromMessageContent(content: unknown): string { function extractTextFromMessageContent(content: unknown): string {
if (typeof content === 'string') { if (typeof content === 'string') {
@@ -56,14 +57,13 @@ function inferSourceFromContext(params: { title: string; description?: string; s
} }
export const createNodeTool = tool({ 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({ inputSchema: z.object({
title: z.string().describe('The title of the node'), 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:.'), 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.'), 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'), 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.'), 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.') 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(), }).passthrough(),
execute: async (params, context) => { execute: async (params, context) => {
@@ -74,7 +74,7 @@ export const createNodeTool = tool({
// Call the nodes API endpoint // Call the nodes API endpoint
const response = await fetch(`${getInternalApiBaseUrl()}/api/nodes`, { const response = await fetch(`${getInternalApiBaseUrl()}/api/nodes`, {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json' }, headers: getInternalAuthHeaders({ 'Content-Type': 'application/json' }),
body: JSON.stringify({ ...params, source: canonicalSource ?? params.source }) body: JSON.stringify({ ...params, source: canonicalSource ?? params.source })
}); });
@@ -88,6 +88,7 @@ export const createNodeTool = tool({
}; };
} }
// Format the created node for chat display
const formattedDisplay = formatNodeForChat({ const formattedDisplay = formatNodeForChat({
id: result.data.id, id: result.data.id,
title: result.data.title, title: result.data.title,
@@ -97,9 +98,9 @@ export const createNodeTool = tool({
success: true, success: true,
data: { data: {
...result.data, ...result.data,
formatted_display: formattedDisplay, formatted_display: formattedDisplay
}, },
message: `Created: ${formattedDisplay}` message: `Created node ${formattedDisplay}`
}; };
} catch (error) { } catch (error) {
return { return {
@@ -111,4 +112,5 @@ export const createNodeTool = tool({
} }
}); });
// Legacy export for backwards compatibility
export const createItemTool = createNodeTool; export const createItemTool = createNodeTool;
-2
View File
@@ -36,8 +36,6 @@ export const getNodesByIdTool = tool({
title: node.title, title: node.title,
link: node.link, link: node.link,
event_date: node.event_date ?? null, event_date: node.event_date ?? null,
context_id: node.context_id ?? null,
context: node.context ?? null,
chunk_status: node.chunk_status || 'unknown', chunk_status: node.chunk_status || 'unknown',
created_at: node.created_at, created_at: node.created_at,
updated_at: node.updated_at, updated_at: node.updated_at,
-104
View File
@@ -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,
},
};
}
},
});
-1
View File
@@ -84,7 +84,6 @@ export const queryEdgeTool = tool({
id: connection.connected_node.id, id: connection.connected_node.id,
title: connection.connected_node.title, title: connection.connected_node.title,
description: truncateText(connection.connected_node.description, 140), description: truncateText(connection.connected_node.description, 140),
context: connection.connected_node.context ?? null,
formatted_display: formattedNode formatted_display: formattedNode
} }
}; };
+1 -6
View File
@@ -4,8 +4,6 @@ import { formatNodeForChat } from '../infrastructure/nodeFormatter';
import { directNodeLookup } from '@/services/retrieval/directNodeLookup'; import { directNodeLookup } from '@/services/retrieval/directNodeLookup';
type QueryNodeFilters = { type QueryNodeFilters = {
contextId?: number;
context_name?: string;
search?: string; search?: string;
limit?: number; limit?: number;
createdAfter?: string; createdAfter?: string;
@@ -15,10 +13,9 @@ type QueryNodeFilters = {
}; };
export const queryNodesTool = tool({ 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({ inputSchema: z.object({
filters: 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(), 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'), 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.'), 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({ const result = await directNodeLookup({
search: filters.search, search: filters.search,
limit: filters.limit, limit: filters.limit,
context_name: filters.context_name,
contextId: filters.contextId,
createdAfter: filters.createdAfter, createdAfter: filters.createdAfter,
createdBefore: filters.createdBefore, createdBefore: filters.createdBefore,
eventAfter: filters.eventAfter, eventAfter: filters.eventAfter,
+1 -3
View File
@@ -7,15 +7,13 @@ export const retrieveQueryContextTool = tool({
inputSchema: z.object({ inputSchema: z.object({
query: z.string().min(1).describe('The raw user query for this turn.'), 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.'), 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.'), 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 { try {
const result = await retrieveQueryContext({ const result = await retrieveQueryContext({
query, query,
focused_node_id: focused_node_id ?? null, focused_node_id: focused_node_id ?? null,
active_context_id: active_context_id ?? null,
limit, limit,
}); });
+3 -4
View File
@@ -1,9 +1,10 @@
import { tool } from 'ai'; import { tool } from 'ai';
import { z } from 'zod'; import { z } from 'zod';
import { getInternalApiBaseUrl } from '@/services/runtime/apiBase'; import { getInternalApiBaseUrl } from '@/services/runtime/apiBase';
import { getInternalAuthHeaders } from '@/services/auth/internalAuth';
export const updateNodeTool = tool({ 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({ inputSchema: z.object({
id: z.number().describe('The ID of the node to update'), id: z.number().describe('The ID of the node to update'),
updates: z.object({ 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.'), 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'), 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.'), 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.') 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.') }).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,7 +29,7 @@ export const updateNodeTool = tool({
// Call the nodes API endpoint // Call the nodes API endpoint
const response = await fetch(`${getInternalApiBaseUrl()}/api/nodes/${id}`, { const response = await fetch(`${getInternalApiBaseUrl()}/api/nodes/${id}`, {
method: 'PUT', method: 'PUT',
headers: { 'Content-Type': 'application/json' }, headers: getInternalAuthHeaders({ 'Content-Type': 'application/json' }),
body: JSON.stringify(updates) body: JSON.stringify(updates)
}); });
-75
View File
@@ -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,
};
}
},
});
+4 -2
View File
@@ -38,8 +38,9 @@ export const TOOL_GROUP_ASSIGNMENTS: Record<string, string> = {
retrieveQueryContext: 'core', retrieveQueryContext: 'core',
getNodesById: 'core', getNodesById: 'core',
queryEdge: 'core', queryEdge: 'core',
queryContexts: 'core',
searchContentEmbeddings: 'core', searchContentEmbeddings: 'core',
listSkills: 'core',
readSkill: 'core',
// Orchestration: Web search and reasoning (orchestrator only) // Orchestration: Web search and reasoning (orchestrator only)
webSearch: 'orchestration', webSearch: 'orchestration',
@@ -47,7 +48,6 @@ export const TOOL_GROUP_ASSIGNMENTS: Record<string, string> = {
// Execution: Write operations and extraction (workers only) // Execution: Write operations and extraction (workers only)
createNode: 'execution', createNode: 'execution',
writeContext: 'execution',
updateNode: 'execution', updateNode: 'execution',
deleteNode: 'execution', deleteNode: 'execution',
createEdge: 'execution', createEdge: 'execution',
@@ -56,6 +56,8 @@ export const TOOL_GROUP_ASSIGNMENTS: Record<string, string> = {
youtubeExtract: 'execution', youtubeExtract: 'execution',
websiteExtract: 'execution', websiteExtract: 'execution',
paperExtract: 'execution', paperExtract: 'execution',
writeSkill: 'execution',
deleteSkill: 'execution'
}; };
/** /**
+9 -5
View File
@@ -3,10 +3,8 @@ import { queryNodesTool } from '../database/queryNodes';
import { retrieveQueryContextTool } from '../database/retrieveQueryContext'; import { retrieveQueryContextTool } from '../database/retrieveQueryContext';
import { getNodesByIdTool } from '../database/getNodesById'; import { getNodesByIdTool } from '../database/getNodesById';
import { queryEdgeTool } from '../database/queryEdge'; import { queryEdgeTool } from '../database/queryEdge';
import { queryContextsTool } from '../database/queryContexts';
import { createNodeTool } from '../database/createNode'; import { createNodeTool } from '../database/createNode';
import { updateNodeTool } from '../database/updateNode'; import { updateNodeTool } from '../database/updateNode';
import { writeContextTool } from '../database/writeContext';
import { deleteNodeTool } from '../database/deleteNode'; import { deleteNodeTool } from '../database/deleteNode';
import { createEdgeTool } from '../database/createEdge'; import { createEdgeTool } from '../database/createEdge';
import { updateEdgeTool } from '../database/updateEdge'; import { updateEdgeTool } from '../database/updateEdge';
@@ -17,17 +15,22 @@ import { youtubeExtractTool } from '../other/youtubeExtract';
import { websiteExtractTool } from '../other/websiteExtract'; import { websiteExtractTool } from '../other/websiteExtract';
import { paperExtractTool } from '../other/paperExtract'; import { paperExtractTool } from '../other/paperExtract';
import { sqliteQueryTool } from '../other/sqliteQuery'; 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'; import { logEvalToolCall } from '@/services/evals/evalsLogger';
// Read tools (graph queries) // Read tools (graph queries + skills)
const CORE_TOOLS: Record<string, any> = { const CORE_TOOLS: Record<string, any> = {
sqliteQuery: sqliteQueryTool, sqliteQuery: sqliteQueryTool,
queryNodes: queryNodesTool, queryNodes: queryNodesTool,
retrieveQueryContext: retrieveQueryContextTool, retrieveQueryContext: retrieveQueryContextTool,
getNodesById: getNodesByIdTool, getNodesById: getNodesByIdTool,
queryEdge: queryEdgeTool, queryEdge: queryEdgeTool,
queryContexts: queryContextsTool,
searchContentEmbeddings: searchContentEmbeddingsTool, searchContentEmbeddings: searchContentEmbeddingsTool,
listSkills: listSkillsTool,
readSkill: readSkillTool,
}; };
// Utility tools // Utility tools
@@ -39,7 +42,6 @@ const ORCHESTRATION_TOOLS: Record<string, any> = {
// Write tools (includes extraction) // Write tools (includes extraction)
const EXECUTION_TOOLS: Record<string, any> = { const EXECUTION_TOOLS: Record<string, any> = {
createNode: createNodeTool, createNode: createNodeTool,
writeContext: writeContextTool,
updateNode: updateNodeTool, updateNode: updateNodeTool,
deleteNode: deleteNodeTool, deleteNode: deleteNodeTool,
createEdge: createEdgeTool, createEdge: createEdgeTool,
@@ -47,6 +49,8 @@ const EXECUTION_TOOLS: Record<string, any> = {
youtubeExtract: youtubeExtractTool, youtubeExtract: youtubeExtractTool,
websiteExtract: websiteExtractTool, websiteExtract: websiteExtractTool,
paperExtract: paperExtractTool, paperExtract: paperExtractTool,
writeSkill: writeSkillTool,
deleteSkill: deleteSkillTool,
}; };
export const TOOL_SETS = { export const TOOL_SETS = {
+1 -1
View File
@@ -45,7 +45,7 @@ function isReadOnlyQuery(sql: string): boolean {
} }
export const sqliteQueryTool = tool({ 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({ inputSchema: z.object({
sql: z.string().describe('The SQL query to execute. Must be a SELECT, WITH, or PRAGMA statement.'), sql: z.string().describe('The SQL query to execute. Must be a SELECT, WITH, or PRAGMA statement.'),
+37
View File
@@ -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,
};
}
},
});
+24
View File
@@ -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: [],
};
}
},
});
+34
View File
@@ -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,
};
}
},
});
+38
View File
@@ -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,
};
}
},
});
+8 -20
View File
@@ -10,23 +10,6 @@ export interface CanonicalNodeMetadata {
[key: string]: unknown; [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 // New Node-based type system replacing rigid Item categorization
export interface Node { export interface Node {
id: number; id: number;
@@ -42,8 +25,6 @@ export interface Node {
created_at: string; created_at: string;
updated_at: string; updated_at: string;
edge_count?: number; // Derived count of edges, included in some queries edge_count?: number; // Derived count of edges, included in some queries
context_id?: number | null;
context?: Pick<Context, 'id' | 'name' | 'description' | 'icon'> | null;
// Optional embedding fields // Optional embedding fields
embedding_updated_at?: string; embedding_updated_at?: string;
@@ -96,7 +77,6 @@ export interface EdgeContext {
// New NodeFilters interface replacing rigid ItemFilters // New NodeFilters interface replacing rigid ItemFilters
export interface NodeFilters { export interface NodeFilters {
contextId?: number;
search?: string; // Text search in title/content search?: string; // Text search in title/content
searchMode?: 'standard' | 'hybrid'; // standard = FTS/LIKE, hybrid = add node-vector retrieval searchMode?: 'standard' | 'hybrid'; // standard = FTS/LIKE, hybrid = add node-vector retrieval
chunkStatus?: 'not_chunked' | 'chunking' | 'chunked' | 'error'; chunkStatus?: 'not_chunked' | 'chunking' | 'chunked' | 'error';
@@ -148,3 +128,11 @@ export interface DatabaseError {
code?: string; code?: string;
details?: any; details?: any;
} }
export interface Dimension {
name: string;
description?: string | null;
icon?: string | null;
is_priority: boolean;
updated_at: string;
}
+79 -117
View File
@@ -5,20 +5,12 @@ import type { AddressInfo } from 'node:net';
import { Client } from '@modelcontextprotocol/sdk/client/index.js'; import { Client } from '@modelcontextprotocol/sdk/client/index.js';
import { StdioClientTransport } from '@modelcontextprotocol/sdk/client/stdio.js'; import { StdioClientTransport } from '@modelcontextprotocol/sdk/client/stdio.js';
type ContextRecord = {
id: number;
name: string;
description: string | null;
icon: string | null;
};
type NodeRecord = { type NodeRecord = {
id: number; id: number;
title: string; title: string;
description: string | null; description: string | null;
source: string | null; source: string | null;
link: string | null; link: string | null;
context_id: number | null;
metadata: Record<string, unknown>; metadata: Record<string, unknown>;
updated_at: string; updated_at: string;
created_at: string; created_at: string;
@@ -31,11 +23,6 @@ type RequestLogEntry = {
body: Record<string, unknown> | null; body: Record<string, unknown> | 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 server: http.Server;
let baseUrl = ''; let baseUrl = '';
let nodes: NodeRecord[] = []; let nodes: NodeRecord[] = [];
@@ -83,19 +70,6 @@ async function handleRequest(req: http.IncomingMessage, res: http.ServerResponse
requestLog.push({ method, pathname, body }); requestLog.push({ method, pathname, body });
if (method === 'POST' && pathname === '/api/nodes') { 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 timestamp = nowIso();
const node: NodeRecord = { const node: NodeRecord = {
id: nextNodeId++, id: nextNodeId++,
@@ -103,7 +77,6 @@ async function handleRequest(req: http.IncomingMessage, res: http.ServerResponse
description: typeof body?.description === 'string' ? body.description : null, description: typeof body?.description === 'string' ? body.description : null,
source: typeof body?.source === 'string' ? body.source : null, source: typeof body?.source === 'string' ? body.source : null,
link: typeof body?.link === 'string' ? body.link : null, link: typeof body?.link === 'string' ? body.link : null,
context_id: resolvedContextId,
metadata: typeof body?.metadata === 'object' && body.metadata ? body.metadata as Record<string, unknown> : {}, metadata: typeof body?.metadata === 'object' && body.metadata ? body.metadata as Record<string, unknown> : {},
updated_at: timestamp, updated_at: timestamp,
created_at: timestamp, created_at: timestamp,
@@ -116,20 +89,20 @@ async function handleRequest(req: http.IncomingMessage, res: http.ServerResponse
if (method === 'GET' && pathname === '/api/nodes') { if (method === 'GET' && pathname === '/api/nodes') {
const search = url.searchParams.get('search')?.trim() || ''; const search = url.searchParams.get('search')?.trim() || '';
const limit = Number(url.searchParams.get('limit') || '10'); const limit = Number(url.searchParams.get('limit') || '10');
const contextIdParam = url.searchParams.get('contextId'); const sortBy = url.searchParams.get('sortBy');
const contextId = contextIdParam ? Number(contextIdParam) : undefined;
let filtered = nodes.slice(); let filtered = nodes.slice();
if (search) { if (search) {
filtered = filtered.filter((node) => matchesNode(node, search)); filtered = filtered.filter((node) => matchesNode(node, search));
} }
if (contextId !== undefined) { if (sortBy === 'edges') {
filtered = filtered.filter((node) => node.context_id === contextId); filtered.sort((a, b) => b.edge_count - a.edge_count || b.updated_at.localeCompare(a.updated_at));
} }
return sendJson(res, 200, { return sendJson(res, 200, {
success: true, success: true,
total: filtered.length, total: filtered.length,
count: filtered.length,
data: filtered.slice(0, Number.isFinite(limit) ? limit : 10), 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 }); return sendJson(res, 200, { success: true, node });
} }
if (method === 'GET' && pathname === '/api/contexts') { if (method === 'POST' && pathname === '/api/nodes/direct-search') {
return sendJson(res, 200, { const query = typeof body?.query === 'string' ? body.query : '';
success: true, const limit = typeof body?.limit === 'number' ? body.limit : 10;
data: contexts.map((context) => ({ const filtered = nodes.filter((node) => matchesNode(node, query)).slice(0, limit);
...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.' });
}
return sendJson(res, 200, { return sendJson(res, 200, {
success: true, success: true,
data: { data: {
...context, count: filtered.length,
count: nodes.filter((node) => node.context_id === context.id).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)) { if (method === 'POST' && pathname === '/api/retrieval/query-context') {
const parts = pathname.split('/');
const id = Number(parts[parts.length - 2]);
return sendJson(res, 200, { return sendJson(res, 200, {
success: true, success: true,
data: nodes data: {
.filter((node) => node.context_id === id) query: typeof body?.query === 'string' ? body.query : '',
.map((node) => ({ 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, id: node.id,
title: node.title, title: node.title,
description: node.description, description: node.description,
link: node.link, link: node.link,
context_id: node.context_id,
updated_at: node.updated_at, 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(); 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) => { await withMcpClient(async (client) => {
const result = await client.listTools(); const result = await client.listTools();
const toolNames = result.tools.map((tool) => tool.name); const toolNames = result.tools.map((tool) => tool.name);
expect(toolNames).not.toEqual(expect.arrayContaining([ expect(toolNames).not.toEqual(expect.arrayContaining([
'rah_query_dimensions', 'rah_query_contexts',
'rah_create_dimension', 'rah_write_context',
'rah_update_dimension',
'rah_delete_dimension',
'rah_get_dimension',
])); ]));
const addNodeTool = result.tools.find((tool) => tool.name === 'rah_add_node'); const addNodeTool = result.tools.find((tool) => tool.name === 'rah_add_node');
expect(addNodeTool).toBeDefined(); expect(addNodeTool).toBeDefined();
expect(addNodeTool?.inputSchema).toBeTruthy(); expect(JSON.stringify(addNodeTool?.inputSchema)).not.toContain('context_name');
expect(JSON.stringify(addNodeTool?.inputSchema)).not.toContain('dimensions'); 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) => { await withMcpClient(async (client) => {
const result = await client.callTool({ const result = await client.callTool({
name: 'rah_add_node', name: 'rah_add_node',
arguments: { arguments: {
title: 'MCP Contract Test Node', title: 'MCP Contract Test Node',
description: 'Node created through MCP to verify the post-dimensions write contract.', description: 'Node created through MCP to verify the no-context write contract.',
source: 'Concrete source text proving rah_add_node works without dimensions or automatic context assignment.', source: 'Concrete source text proving rah_add_node works without context fields.',
metadata: { source: 'mcp-contract-test' }, 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'); const createRequest = requestLog.find((entry) => entry.method === 'POST' && entry.pathname === '/api/nodes');
expect(createRequest?.body).toMatchObject({ expect(createRequest?.body).toMatchObject({
title: 'MCP Contract Test Node', title: 'MCP Contract Test Node',
description: 'Node created through MCP to verify the post-dimensions write contract.', description: 'Node created through MCP to verify the no-context write contract.',
source: 'Concrete source text proving rah_add_node works without dimensions or automatic context assignment.', source: 'Concrete source text proving rah_add_node works without context fields.',
metadata: { source: 'mcp-contract-test' }, 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(createRequest?.body).not.toHaveProperty('context_id');
expect(nodes[0]?.context_id).toBeNull();
const searchResult = await client.callTool({ const searchResult = await client.callTool({
name: 'rah_search_nodes', name: 'rah_search_nodes',
arguments: { arguments: {
query: 'contract test node', query: 'contract test node',
limit: 5, limit: 5,
createdAfter: '2026-01-01',
}, },
}); });
@@ -307,36 +290,25 @@ describe('stdio MCP server contract', () => {
id: structured.nodeId, id: structured.nodeId,
title: 'MCP Contract Test Node', title: 'MCP Contract Test Node',
}); });
expect(searchStructured.nodes[0]).not.toHaveProperty('dimensions');
});
});
it('surfaces invalid explicit contexts instead of inferring a fallback context', async () => { const searchRequest = requestLog.find((entry) => entry.method === 'POST' && entry.pathname === '/api/nodes/direct-search');
await withMcpClient(async (client) => { expect(searchRequest?.body).toMatchObject({
const result = await client.callTool({ query: 'contract test node',
name: 'rah_add_node', limit: 5,
arguments: { createdAfter: '2026-01-01',
title: 'Bad Context Node',
description: 'This should fail because the context does not exist.',
source: 'Source text for invalid context test.',
context_id: 999,
},
}); });
expect(searchRequest?.body).not.toHaveProperty('context_name');
expect(result.isError).toBe(true); expect(searchStructured.nodes[0]).not.toHaveProperty('context_id');
expect(JSON.stringify(result.content)).toContain('Context not found');
expect(nodes).toHaveLength(0);
}); });
}); });
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({ nodes.push({
id: nextNodeId++, id: nextNodeId++,
title: 'Work Hub Node', title: 'Work Hub Node',
description: 'High-signal work hub used to verify MCP context orientation.', description: 'High-signal work hub used to verify MCP context orientation.',
source: 'Hub node source', source: 'Hub node source',
link: null, link: null,
context_id: 1,
metadata: {}, metadata: {},
updated_at: nowIso(), updated_at: nowIso(),
created_at: nowIso(), created_at: nowIso(),
@@ -344,28 +316,24 @@ describe('stdio MCP server contract', () => {
}); });
await withMcpClient(async (client) => { await withMcpClient(async (client) => {
const contextsResult = await client.callTool({ const retrievalResult = await client.callTool({
name: 'rah_query_contexts', name: 'rah_retrieve_query_context',
arguments: { arguments: {
contextId: 1, query: 'help me think about work hub priorities',
includeNodes: true, focused_node_id: 1,
}, },
}); });
const contextsStructured = getStructured<{ const retrievalRequest = requestLog.find((entry) => entry.method === 'POST' && entry.pathname === '/api/retrieval/query-context');
count: number; expect(retrievalRequest?.body).toMatchObject({
contexts: Array<{ id: number; name: string; nodes?: Array<Record<string, unknown>> }>; query: 'help me think about work hub priorities',
}>(contextsResult); focused_node_id: 1,
expect(contextsStructured.count).toBe(1);
expect(contextsStructured.contexts[0]).toMatchObject({
id: 1,
name: 'Work',
}); });
expect(contextsStructured.contexts[0].nodes?.[0]).toMatchObject({ expect(retrievalRequest?.body).not.toHaveProperty('active_context_id');
title: 'Work Hub Node',
context_id: 1, const retrievalStructured = getStructured<{ focused_node_id: number | null; nodes: Array<{ title: string }> }>(retrievalResult);
}); expect(retrievalStructured.focused_node_id).toBe(1);
expect(contextsStructured.contexts[0].nodes?.[0]).not.toHaveProperty('dimensions'); expect(retrievalStructured.nodes[0]?.title).toBe('Work Hub Node');
const graphContextResult = await client.callTool({ const graphContextResult = await client.callTool({
name: 'rah_get_context', name: 'rah_get_context',
@@ -373,24 +341,18 @@ describe('stdio MCP server contract', () => {
}); });
const graphStructured = getStructured<{ const graphStructured = getStructured<{
stats: { contextCount: number }; stats: { nodeCount: number; edgeCount: number };
contexts: Array<{ id: number; name: string }>;
hubNodes: Array<{ title: string }>; hubNodes: Array<{ title: string }>;
guides: string[]; guides: string[];
}>(graphContextResult); }>(graphContextResult);
expect(graphStructured.stats.contextCount).toBe(contexts.length); expect(graphStructured.stats).toEqual({ nodeCount: 1, edgeCount: 3 });
expect(graphStructured.contexts).toEqual(
expect.arrayContaining([
expect.objectContaining({ id: 1, name: 'Work' }),
expect.objectContaining({ id: 2, name: 'Personal' }),
])
);
expect(graphStructured.hubNodes).toEqual( expect(graphStructured.hubNodes).toEqual(
expect.arrayContaining([expect.objectContaining({ title: 'Work Hub Node' })]) expect.arrayContaining([expect.objectContaining({ title: 'Work Hub Node' })])
); );
expect(graphStructured.guides).toEqual(expect.arrayContaining(['schema', 'creating-nodes'])); 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');
}); });
}); });
}); });
@@ -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<Record<string, unknown>> };
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,
});
});
});