feat: port contexts layer and MCP parity
This commit is contained in:
+119
-36
@@ -11,8 +11,9 @@ const packageJson = require('../../package.json');
|
||||
|
||||
const instructions = [
|
||||
'RA-H is a personal knowledge graph — local-first, vendor-neutral.',
|
||||
'Core concepts: nodes (knowledge units), edges (connections with explanations), dimensions (categories).',
|
||||
'Always call rah_get_context first to orient yourself — it returns hub nodes, dimensions, stats, and available guides.',
|
||||
'Core concepts: contexts (primary scopes), nodes (knowledge units), edges (connections with explanations), and dimensions (secondary metadata and filters).',
|
||||
'Always call rah_get_context first to orient yourself — it returns contexts, hub nodes, dimensions, stats, and available guides.',
|
||||
'Use contexts as the primary scope layer. Use rah_query_contexts before assigning or filtering by context when needed.',
|
||||
'When assigning dimensions, use only existing dimensions returned by rah_get_context or rah_query_dimensions.',
|
||||
'Do not invent new dimensions from node titles, concepts, or phrasing.',
|
||||
'Only call rah_create_dimension when the user explicitly instructs you to create a new dimension.',
|
||||
@@ -40,9 +41,11 @@ const addNodeInputSchema = {
|
||||
content: z.string().max(20000).optional(),
|
||||
source: z.string().max(50000).optional(),
|
||||
link: z.string().url().optional(),
|
||||
description: z.string().max(2000).optional(),
|
||||
description: z.string().max(500).optional().describe('Description of the node. Write it as natural prose, not labels or a checklist. It must still make clear what the artifact is, why it is in the graph (infer from conversation context; ask the user if needed), and its current workflow status. Max 500 characters. If the reason is unclear, say that naturally instead of inventing it. Never use filler phrases like "insightful for understanding" or "relevant to the user\'s work".'),
|
||||
context_id: z.number().int().positive().nullable().optional(),
|
||||
context_name: z.string().optional(),
|
||||
dimensions: z.array(z.string()).min(1).max(5),
|
||||
metadata: z.record(z.any()).optional(),
|
||||
metadata: z.record(z.any()).optional().describe('Optional metadata. Prefer canonical keys: type, state, captured_method, captured_by, source_metadata.'),
|
||||
chunk: z.string().max(50000).optional()
|
||||
};
|
||||
|
||||
@@ -56,7 +59,16 @@ const addNodeOutputSchema = {
|
||||
const searchNodesInputSchema = {
|
||||
query: z.string().min(1).max(400),
|
||||
limit: z.number().min(1).max(25).optional(),
|
||||
dimensions: z.array(z.string()).max(5).optional()
|
||||
dimensions: z.array(z.string()).max(5).optional(),
|
||||
contextId: z.number().int().positive().optional()
|
||||
};
|
||||
|
||||
const queryContextsInputSchema = {
|
||||
contextId: z.number().int().positive().optional(),
|
||||
name: z.string().optional(),
|
||||
search: z.string().optional(),
|
||||
limit: z.number().min(1).max(100).optional(),
|
||||
includeNodes: z.boolean().optional()
|
||||
};
|
||||
|
||||
const searchNodesOutputSchema = {
|
||||
@@ -65,7 +77,7 @@ const searchNodesOutputSchema = {
|
||||
z.object({
|
||||
id: z.number(),
|
||||
title: z.string(),
|
||||
content: z.string().nullable(),
|
||||
source: z.string().nullable(),
|
||||
description: z.string().nullable(),
|
||||
link: z.string().nullable(),
|
||||
dimensions: z.array(z.string()),
|
||||
@@ -79,10 +91,13 @@ const updateNodeInputSchema = {
|
||||
id: z.number().int().positive().describe('The ID of the node to update'),
|
||||
updates: z.object({
|
||||
title: z.string().optional().describe('New title'),
|
||||
content: z.string().optional().describe('Content to APPEND (not replace)'),
|
||||
description: z.string().max(500).optional().describe('Description of the node. Write it as natural prose, not labels or a checklist. It must still make clear what the artifact is, why it is in the graph (infer from conversation context; ask the user if needed), and its current workflow status. Max 500 characters. If the reason is unclear, say that naturally instead of inventing it. Never use filler phrases like "insightful for understanding" or "relevant to the user\'s work".'),
|
||||
content: z.string().optional().describe('Legacy alias for source. Mapped to source for backward compatibility.'),
|
||||
source: z.string().optional().describe('Canonical source text for embedding.'),
|
||||
link: z.string().optional().describe('New link'),
|
||||
context_id: z.number().int().positive().nullable().optional().describe('Primary context ID. Omit to preserve existing context; use null to clear.'),
|
||||
dimensions: z.array(z.string()).optional().describe('New dimensions (replaces existing)'),
|
||||
metadata: z.record(z.any()).optional().describe('New metadata (replaces existing)')
|
||||
metadata: z.record(z.any()).optional().describe('Metadata patch. This now merges with existing metadata. Prefer canonical keys: type, state, captured_method, captured_by, source_metadata.')
|
||||
}).describe('Fields to update')
|
||||
};
|
||||
|
||||
@@ -103,7 +118,7 @@ const getNodesOutputSchema = {
|
||||
z.object({
|
||||
id: z.number(),
|
||||
title: z.string(),
|
||||
content: z.string().nullable(),
|
||||
source: z.string().nullable(),
|
||||
link: z.string().nullable(),
|
||||
dimensions: z.array(z.string()),
|
||||
updated_at: z.string()
|
||||
@@ -217,8 +232,7 @@ const extractUrlInputSchema = {
|
||||
const extractUrlOutputSchema = {
|
||||
success: z.boolean(),
|
||||
title: z.string(),
|
||||
content: z.string(),
|
||||
chunk: z.string(),
|
||||
source: z.string(),
|
||||
metadata: z.record(z.any())
|
||||
};
|
||||
|
||||
@@ -231,7 +245,7 @@ const extractYoutubeOutputSchema = {
|
||||
success: z.boolean(),
|
||||
title: z.string(),
|
||||
channel: z.string(),
|
||||
transcript: z.string(),
|
||||
source: z.string(),
|
||||
metadata: z.record(z.any())
|
||||
};
|
||||
|
||||
@@ -243,8 +257,7 @@ const extractPdfInputSchema = {
|
||||
const extractPdfOutputSchema = {
|
||||
success: z.boolean(),
|
||||
title: z.string(),
|
||||
content: z.string(),
|
||||
chunk: z.string(),
|
||||
source: z.string(),
|
||||
metadata: z.record(z.any())
|
||||
};
|
||||
|
||||
@@ -322,11 +335,11 @@ server.registerTool(
|
||||
'rah_add_node',
|
||||
{
|
||||
title: 'Add RA-H node',
|
||||
description: 'Create a new node in the local RA-H knowledge base.',
|
||||
description: 'Create a new node in the local RA-H knowledge base. Set context explicitly when clear; otherwise RA-H will infer the best-fit context automatically on create. Use only existing dimensions; do not invent new ones.',
|
||||
inputSchema: addNodeInputSchema,
|
||||
outputSchema: addNodeOutputSchema
|
||||
},
|
||||
async ({ title, content, source, link, description, dimensions, metadata, chunk }) => {
|
||||
async ({ title, content, source, link, description, context_id, context_name, dimensions, metadata, chunk }) => {
|
||||
const normalizedDimensions = sanitizeDimensions(dimensions);
|
||||
if (normalizedDimensions.length === 0) {
|
||||
throw new Error('At least one dimension/tag is required when creating a node.');
|
||||
@@ -334,9 +347,11 @@ server.registerTool(
|
||||
|
||||
const payload = {
|
||||
title: title.trim(),
|
||||
source: source?.trim() || chunk?.trim() || content?.trim() || undefined,
|
||||
source: source?.trim() || content?.trim() || chunk?.trim() || undefined,
|
||||
link: link?.trim() || undefined,
|
||||
description: description?.trim() || undefined,
|
||||
context_id: context_id === null ? null : context_id,
|
||||
context_name: context_name?.trim() || undefined,
|
||||
dimensions: normalizedDimensions,
|
||||
metadata: metadata || {}
|
||||
};
|
||||
@@ -369,7 +384,7 @@ server.registerTool(
|
||||
inputSchema: searchNodesInputSchema,
|
||||
outputSchema: searchNodesOutputSchema
|
||||
},
|
||||
async ({ query, limit = 10, dimensions }) => {
|
||||
async ({ query, limit = 10, dimensions, contextId }) => {
|
||||
const params = new URLSearchParams();
|
||||
params.set('search', query.trim());
|
||||
params.set('limit', String(Math.min(Math.max(limit, 1), 25)));
|
||||
@@ -378,6 +393,9 @@ server.registerTool(
|
||||
if (dimensionList.length > 0) {
|
||||
params.set('dimensions', dimensionList.join(','));
|
||||
}
|
||||
if (contextId) {
|
||||
params.set('contextId', String(contextId));
|
||||
}
|
||||
|
||||
const result = await callRaHApi(`/api/nodes?${params.toString()}`, {
|
||||
method: 'GET'
|
||||
@@ -407,11 +425,59 @@ server.registerTool(
|
||||
}
|
||||
);
|
||||
|
||||
server.registerTool(
|
||||
'rah_query_contexts',
|
||||
{
|
||||
title: 'Query RA-H contexts',
|
||||
description: 'List contexts, inspect a specific context, or search contexts by name/description.',
|
||||
inputSchema: queryContextsInputSchema
|
||||
},
|
||||
async ({ contextId, name, search, limit = 50, includeNodes = false }) => {
|
||||
const normalizedName = typeof name === 'string' ? name.trim().toLowerCase() : '';
|
||||
let contexts = [];
|
||||
|
||||
if (contextId) {
|
||||
const result = await callRaHApi(`/api/contexts/${contextId}`, { method: 'GET' });
|
||||
if (result?.data) {
|
||||
contexts = [result.data];
|
||||
}
|
||||
} else {
|
||||
const result = await callRaHApi('/api/contexts', { method: 'GET' });
|
||||
const all = Array.isArray(result.data) ? result.data : [];
|
||||
contexts = all.filter((context) => {
|
||||
if (normalizedName && context.name?.trim().toLowerCase() !== normalizedName) {
|
||||
return false;
|
||||
}
|
||||
if (search) {
|
||||
const haystack = `${context.name || ''} ${context.description || ''}`.toLowerCase();
|
||||
return haystack.includes(search.trim().toLowerCase());
|
||||
}
|
||||
return true;
|
||||
}).slice(0, Math.min(Math.max(limit, 1), 100));
|
||||
}
|
||||
|
||||
const includeContextNodes = includeNodes && contexts.length === 1 && (contextId || normalizedName);
|
||||
const enriched = await Promise.all(contexts.map(async (context) => {
|
||||
if (!includeContextNodes) return context;
|
||||
const nodeResult = await callRaHApi(`/api/contexts/${context.id}/nodes`, { method: 'GET' });
|
||||
return { ...context, nodes: Array.isArray(nodeResult.data) ? nodeResult.data : [] };
|
||||
}));
|
||||
|
||||
return {
|
||||
content: [{ type: 'text', text: enriched.length === 0 ? 'No matching contexts found.' : `Found ${enriched.length} context(s).` }],
|
||||
structuredContent: {
|
||||
count: enriched.length,
|
||||
contexts: enriched
|
||||
}
|
||||
};
|
||||
}
|
||||
);
|
||||
|
||||
server.registerTool(
|
||||
'rah_update_node',
|
||||
{
|
||||
title: 'Update RA-H node',
|
||||
description: 'Update an existing node. Content is APPENDED (not replaced). Dimensions are replaced.',
|
||||
description: 'Update an existing node. Dimensions must be existing canonical dimensions; do not invent new ones.',
|
||||
inputSchema: updateNodeInputSchema,
|
||||
outputSchema: updateNodeOutputSchema
|
||||
},
|
||||
@@ -420,15 +486,15 @@ server.registerTool(
|
||||
throw new Error('At least one field must be provided in updates.');
|
||||
}
|
||||
|
||||
// Map MCP legacy fields to canonical source
|
||||
// Backward compatibility: map legacy content/chunk → source
|
||||
const mappedUpdates = { ...updates };
|
||||
if (mappedUpdates.content !== undefined) {
|
||||
mappedUpdates.source = mappedUpdates.content;
|
||||
}
|
||||
if (mappedUpdates.chunk !== undefined && mappedUpdates.source === undefined) {
|
||||
mappedUpdates.source = mappedUpdates.chunk;
|
||||
}
|
||||
delete mappedUpdates.content;
|
||||
if (mappedUpdates.content !== undefined) {
|
||||
mappedUpdates.source = mappedUpdates.content;
|
||||
delete mappedUpdates.content;
|
||||
}
|
||||
delete mappedUpdates.chunk;
|
||||
|
||||
const result = await callRaHApi(`/api/nodes/${id}`, {
|
||||
@@ -593,7 +659,7 @@ server.registerTool(
|
||||
'rah_create_dimension',
|
||||
{
|
||||
title: 'Create RA-H dimension',
|
||||
description: 'Create a new dimension/tag for organizing nodes.',
|
||||
description: 'Create a new dimension/tag for organizing nodes only when the user explicitly instructs you to do so.',
|
||||
inputSchema: createDimensionInputSchema,
|
||||
outputSchema: createDimensionOutputSchema
|
||||
},
|
||||
@@ -731,8 +797,7 @@ server.registerTool(
|
||||
structuredContent: {
|
||||
success: true,
|
||||
title: result.title || 'Untitled',
|
||||
content: result.content || '',
|
||||
chunk: result.chunk || '',
|
||||
source: result.source || '',
|
||||
metadata: result.metadata || {}
|
||||
}
|
||||
};
|
||||
@@ -760,7 +825,7 @@ server.registerTool(
|
||||
success: true,
|
||||
title: result.title || 'Untitled',
|
||||
channel: result.channel || 'Unknown',
|
||||
transcript: result.transcript || '',
|
||||
source: result.source || '',
|
||||
metadata: result.metadata || {}
|
||||
}
|
||||
};
|
||||
@@ -787,8 +852,7 @@ server.registerTool(
|
||||
structuredContent: {
|
||||
success: true,
|
||||
title: result.title || 'Untitled PDF',
|
||||
content: result.content || '',
|
||||
chunk: result.chunk || '',
|
||||
source: result.source || '',
|
||||
metadata: result.metadata || {}
|
||||
}
|
||||
};
|
||||
@@ -797,10 +861,11 @@ server.registerTool(
|
||||
|
||||
// rah_get_context — orientation tool for external agents
|
||||
const getContextOutputSchema = {
|
||||
schema: z.object({
|
||||
stats: z.object({
|
||||
nodeCount: z.number(),
|
||||
edgeCount: z.number(),
|
||||
dimensionCount: z.number()
|
||||
dimensionCount: z.number(),
|
||||
contextCount: z.number().optional()
|
||||
}),
|
||||
hubNodes: z.array(z.object({
|
||||
id: z.number(),
|
||||
@@ -808,6 +873,13 @@ const getContextOutputSchema = {
|
||||
description: z.string().nullable(),
|
||||
edgeCount: z.number()
|
||||
})),
|
||||
contexts: z.array(z.object({
|
||||
id: z.number(),
|
||||
name: z.string(),
|
||||
description: z.string().nullable(),
|
||||
icon: z.string().nullable().optional(),
|
||||
count: z.number()
|
||||
})).optional(),
|
||||
dimensions: z.array(z.object({
|
||||
name: z.string(),
|
||||
nodeCount: z.number(),
|
||||
@@ -820,7 +892,7 @@ server.registerTool(
|
||||
'rah_get_context',
|
||||
{
|
||||
title: 'Get RA-H context',
|
||||
description: 'Get orientation context: hub nodes, dimensions, stats, and available guides. Call this first.',
|
||||
description: 'Get orientation context: contexts, hub nodes, dimensions, stats, and available guides. Call this first.',
|
||||
inputSchema: {},
|
||||
outputSchema: getContextOutputSchema
|
||||
},
|
||||
@@ -842,6 +914,15 @@ server.registerTool(
|
||||
description: d.description ?? null
|
||||
})) : [];
|
||||
|
||||
const contextResult = await callRaHApi('/api/contexts', { method: 'GET' });
|
||||
const contexts = Array.isArray(contextResult.data) ? contextResult.data.map(c => ({
|
||||
id: c.id,
|
||||
name: c.name,
|
||||
description: c.description ?? null,
|
||||
icon: c.icon ?? null,
|
||||
count: c.count ?? 0
|
||||
})) : [];
|
||||
|
||||
// Fetch guides
|
||||
const guideResult = await callRaHApi('/api/guides', { method: 'GET' });
|
||||
const guides = Array.isArray(guideResult.data) ? guideResult.data.map(g => g.name) : [];
|
||||
@@ -851,7 +932,8 @@ server.registerTool(
|
||||
const stats = {
|
||||
nodeCount: nodeCount ?? hubNodes.reduce((_, n) => 0, 0),
|
||||
edgeCount: 0,
|
||||
dimensionCount: dimensions.length
|
||||
dimensionCount: dimensions.length,
|
||||
contextCount: contexts.length
|
||||
};
|
||||
|
||||
// Try to get actual counts from a stats endpoint or compute
|
||||
@@ -862,13 +944,14 @@ server.registerTool(
|
||||
}
|
||||
} catch { /* use defaults */ }
|
||||
|
||||
const summary = `Knowledge graph: ${stats.dimensionCount} dimensions, ${hubNodes.length} hub nodes. ${guides.length} guides available.`;
|
||||
const summary = `Knowledge graph: ${stats.contextCount} contexts, ${stats.dimensionCount} dimensions, ${hubNodes.length} hub nodes. ${guides.length} guides available.`;
|
||||
|
||||
return {
|
||||
content: [{ type: 'text', text: summary }],
|
||||
structuredContent: {
|
||||
schema: stats,
|
||||
stats,
|
||||
hubNodes,
|
||||
contexts,
|
||||
dimensions,
|
||||
guides
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user