feat: port contexts layer and MCP parity

This commit is contained in:
“BeeRad”
2026-04-10 19:41:26 +10:00
parent a8c5506fb7
commit 35f9ecf89c
62 changed files with 4206 additions and 1224 deletions
+3 -2
View File
@@ -41,7 +41,7 @@ Restart Claude. Done.
## What to Expect
Once connected, Claude will:
- **Call `getContext` first** to orient itself (stats, hub nodes, dimensions, skills)
- **Call `getContext` first** to orient itself (stats, contexts, hub nodes, dimensions, skills)
- **Proactively capture knowledge** — when a new insight, decision, person, or reference surfaces, it proposes a specific node (title, dimensions, description) so you can approve with minimal friction
- **Read skills for complex tasks** — skills are editable and shared across internal + external agents
- **Search before creating** to avoid duplicates
@@ -50,9 +50,10 @@ Once connected, Claude will:
| Tool | Description |
|------|-------------|
| `getContext` | Get graph overview — stats, hub nodes, dimensions, recent activity |
| `getContext` | Get graph overview — stats, contexts, hub nodes, dimensions, recent activity |
| `createNode` | Create a new node |
| `queryNodes` | Search nodes by keyword |
| `queryContexts` | List or inspect contexts |
| `getNodesById` | Load nodes by ID (includes chunk + metadata) |
| `updateNode` | Update an existing node |
| `createEdge` | Create connection between nodes |
+117 -43
View File
@@ -31,13 +31,14 @@ const { StdioServerTransport } = require('@modelcontextprotocol/sdk/server/stdio
const { initDatabase, getDatabasePath, closeDatabase, getDb, query } = require('./services/sqlite-client');
const nodeService = require('./services/nodeService');
const edgeService = require('./services/edgeService');
const contextService = require('./services/contextService');
const dimensionService = require('./services/dimensionService');
const skillService = require('./services/skillService');
// Server info
const serverInfo = {
name: 'ra-h-standalone',
version: '1.8.0'
version: '1.10.1'
};
function buildInstructions() {
@@ -58,7 +59,7 @@ function buildInstructions() {
return `Today's date: ${now}. RA-H is the user's personal knowledge graph — local SQLite, fully on-device.
## Quick start
1. Call getContext for orientation (stats, hubs, dimensions).
1. Call getContext for orientation (stats, contexts, dimensions, anchors/hubs).
2. For simple tasks, tool descriptions have everything you need.
3. For complex tasks, call readSkill("db-operations").
@@ -69,6 +70,7 @@ Always search before creating to avoid duplicates.
Use only existing dimensions returned by getContext or queryDimensions.
Do not invent new dimensions from node titles, concepts, or phrasing.
Only call createDimension when the user explicitly instructs you to create a new dimension.
Use contexts as the primary scope layer. Query contexts before assigning when needed.
## Available skills
${skillIndex}
@@ -83,15 +85,18 @@ const addNodeInputSchema = {
content: z.string().max(20000).optional().describe('Legacy content field; mapped to source'),
source: z.string().max(50000).optional().describe('Full source text'),
link: z.string().url().optional().describe('Source URL'),
description: z.string().min(24).max(280).describe('REQUIRED. One-sentence summary: WHAT this is (explicit, concrete) + WHY it matters. No weak verbs (discusses, explores, examines). Example: "Podcast — Lex Fridman interviews Sam Altman on AGI timelines. First public comments since board drama."'),
description: z.string().optional().describe('Strongly recommended. Write the description as natural prose, not labels or a checklist. It should make clear what the artifact is and any surrounding context available. RA-H will accept whatever description is provided and will not block the write.'),
context_id: z.number().int().positive().nullable().optional().describe('Optional primary context ID.'),
context_name: z.string().optional().describe('Optional convenience context name.'),
dimensions: z.array(z.string()).min(1).max(5).describe('1-5 existing categories. Call queryDimensions first to use existing ones. Do not invent new dimensions.'),
metadata: z.record(z.any()).optional().describe('Additional metadata'),
metadata: z.record(z.any()).optional().describe('Optional metadata. Prefer canonical keys: type, state, captured_method, captured_by, source_metadata.'),
chunk: z.string().max(50000).optional().describe('Full source text')
};
const searchNodesInputSchema = {
query: z.string().min(1).max(400).describe('Search query'),
limit: z.number().min(1).max(25).optional().describe('Max results (default 10)'),
contextId: z.number().int().positive().optional().describe('Optional primary context filter.'),
dimensions: z.array(z.string()).max(5).optional().describe('Filter by dimensions'),
created_after: z.string().optional().describe('ISO date (YYYY-MM-DD). Only return nodes created on or after this date.'),
created_before: z.string().optional().describe('ISO date (YYYY-MM-DD). Only return nodes created before this date.'),
@@ -107,9 +112,11 @@ const updateNodeInputSchema = {
id: z.number().int().positive().describe('Node ID'),
updates: z.object({
title: z.string().optional().describe('New title'),
description: z.string().min(24).max(280).describe('REQUIRED. Explicitly state WHAT this is (podcast, conversation summary, user insight, etc.) + WHY it matters for context grounding. No vague verbs like "discusses/explores/examines".'),
description: z.string().optional().describe('Recommended replacement description. Keep it as natural prose that says what this artifact is and any surrounding context available. RA-H will accept whatever description is provided and will not block the write.'),
content: z.string().optional().describe('Content to APPEND'),
source: z.string().optional().describe('Canonical source content for embedding'),
link: z.string().optional().describe('New link'),
context_id: z.number().int().positive().nullable().optional().describe('Primary context ID. Omit to preserve existing context; use null to clear it.'),
dimensions: z.array(z.string()).optional().describe('New dimensions (replaces existing)'),
metadata: z.record(z.any()).optional().describe('New metadata')
}).describe('Fields to update')
@@ -131,6 +138,14 @@ const queryEdgesInputSchema = {
limit: z.number().min(1).max(50).optional().describe('Max edges (default 25)')
};
const queryContextsInputSchema = {
contextId: z.number().int().positive().optional().describe('Exact context ID lookup'),
name: z.string().optional().describe('Exact context name lookup'),
search: z.string().optional().describe('Case-insensitive search across context names and descriptions'),
limit: z.number().min(1).max(100).optional().describe('Maximum number of contexts to return'),
includeNodes: z.boolean().optional().describe('Include nodes for an exact single-context lookup')
};
const listDimensionsInputSchema = {};
const createDimensionInputSchema = {
@@ -192,26 +207,6 @@ function sanitizeDimensions(raw) {
return result;
}
function validateExplicitDescription(description) {
if (typeof description !== 'string') {
return 'Description is required and must be a string.';
}
const text = description.trim();
if (text.length < 24) {
return 'Description must be explicit and substantial (at least 24 characters).';
}
const weakPatterns = /\b(discusses|explores|examines|talks about|is about|delves into)\b/i;
const explicitEntityPatterns = /\b(article|artifact|book|brief|claim|company|concept|conversation|dataset|decision|dimension|document|episode|essay|event|guide|idea|insight|interview|lesson|link|node|note|paper|person|plan|placeholder|podcast|post|presentation|project|question|record|research|resource|skill|source|status|summary|talk|target|test node|thread|tool|transcript|tweet|update|video|website|workflow)\b/i;
const uncertaintyPatterns = /\b(likely|probably|possibly|appears to be|seems to be|unclear|uncertain)\b/i;
if (weakPatterns.test(text)) {
return 'Description is too vague. State exactly what this is and why it matters.';
}
if (!explicitEntityPatterns.test(text) && !uncertaintyPatterns.test(text)) {
return 'Description must explicitly identify what this thing is, or state uncertainty explicitly.';
}
return null;
}
// FTS5 helpers
function sanitizeFtsQuery(input) {
return input
@@ -319,7 +314,7 @@ async function main() {
'getContext',
{
title: 'Get RA-H context',
description: 'Get knowledge graph overview: stats, hub nodes (most connected), dimensions, recent activity, and available skills. Call this first to orient yourself. For deeper operating policy, follow up with readSkill("db-operations").',
description: 'Get knowledge graph overview: stats, contexts, hub nodes (secondary diagnostics), dimensions, recent activity, and available skills. Call this first to orient yourself. For deeper operating policy, follow up with readSkill("db-operations").',
inputSchema: {}
},
async () => {
@@ -340,7 +335,7 @@ async function main() {
};
}
const summary = `Graph: ${context.stats.nodeCount} nodes, ${context.stats.edgeCount} edges, ${context.stats.dimensionCount} dimensions, ${skills.length} skills.`;
const summary = `Graph: ${context.stats.contextCount || 0} contexts, ${context.stats.nodeCount} nodes, ${context.stats.edgeCount} edges, ${context.stats.dimensionCount} dimensions, ${skills.length} skills.`;
return {
content: [{ type: 'text', text: summary }],
structuredContent: context
@@ -354,24 +349,28 @@ async function main() {
'createNode',
{
title: 'Add RA-H node',
description: 'Create a new node. Always search first (queryNodes) to avoid duplicates. Title: max 160 chars, clear and descriptive. Description is REQUIRED and must be explicit about what the thing is and why it matters for contextual grounding. Use "link" ONLY for external content (URL, video, article) — omit for synthesis/ideas derived from existing nodes. "source" = verbatim or canonical content for embedding. Legacy "content" and "chunk" are mapped to source for compatibility. Assign 1-5 existing dimensions — call queryDimensions first to use existing ones. Do not invent new dimensions.',
description: 'Create a new node. Always search first (queryNodes) to avoid duplicates. Set context explicitly when clear; otherwise RA-H will infer the best-fit context automatically on create. Title: max 160 chars, clear and descriptive. Description is REQUIRED and should be natural prose that makes clear what the thing is, why it belongs in the graph, and workflow status. Use "link" ONLY for external content (URL, video, article) — omit for synthesis/ideas derived from existing nodes. "source" = verbatim or canonical content for embedding. Legacy "content" and "chunk" are mapped to source for compatibility. Assign 1-5 dimensions — call queryDimensions first to use existing ones.',
inputSchema: addNodeInputSchema
},
async ({ title, content, source, link, description, dimensions, metadata, chunk }) => {
async ({ title, content, source, link, description, context_id, context_name, dimensions, metadata, chunk }) => {
const normalizedDimensions = sanitizeDimensions(dimensions);
if (normalizedDimensions.length === 0) {
throw new Error('At least one dimension is required.');
}
const descriptionError = validateExplicitDescription(description);
if (descriptionError) {
throw new Error(descriptionError);
let resolvedContextId;
try {
resolvedContextId = contextService.resolveContextId({ context_id, context_name });
} catch (error) {
log('Warning: invalid explicit context input on createNode; falling back to automatic inference:', error.message);
resolvedContextId = undefined;
}
const node = nodeService.createNode({
title: title.trim(),
source: source?.trim() || chunk?.trim() || content?.trim(),
link: link?.trim(),
description: description?.trim(),
description: typeof description === 'string' ? description.trim() : description,
context_id: resolvedContextId,
dimensions: normalizedDimensions,
metadata: metadata || {}
});
@@ -397,12 +396,41 @@ async function main() {
description: 'Search nodes by keyword across title, description, and source fields. Multi-word queries find nodes containing all words (not exact phrases). Returns up to 25 results (default 10). Call before creating nodes to check for duplicates. Optionally filter by dimensions. NOT for searching source documents (transcripts, articles) — use searchContentEmbeddings for that.',
inputSchema: searchNodesInputSchema
},
async ({ query: searchQuery, limit = 10, dimensions, created_after, created_before, event_after, event_before }) => {
async ({ query: searchQuery, limit = 10, contextId, dimensions, created_after, created_before, event_after, event_before }) => {
const normalizedDimensions = sanitizeDimensions(dimensions || []);
const safeLimit = Math.min(Math.max(limit, 1), 25);
const trimmedQuery = searchQuery.trim();
const fts = checkFtsAvailability();
if (contextId) {
const nodes = nodeService.getNodes({
search: trimmedQuery,
limit: safeLimit,
contextId,
dimensions: normalizedDimensions,
});
const summary = nodes.length === 0
? 'No matching RA-H nodes found in that context.'
: `Found ${nodes.length} node(s) in that context.`;
return {
content: [{ type: 'text', text: summary }],
structuredContent: {
count: nodes.length,
nodes: nodes.map((node) => ({
id: node.id,
title: node.title,
source: node.source ?? null,
description: node.description ?? null,
link: node.link ?? null,
dimensions: node.dimensions || [],
updated_at: node.updated_at,
})),
},
};
}
// Build temporal filter clauses
const temporalClauses = [];
const temporalParams = [];
@@ -596,21 +624,13 @@ async function main() {
'updateNode',
{
title: 'Update RA-H node',
description: 'Update an existing node. Description is REQUIRED on every update and must explicitly state WHAT this thing is + WHY it matters for contextual grounding. Source content lives in "source". Legacy "content" and "chunk" are mapped to source for compatibility. Dimensions are REPLACED entirely with the new array. Title, description, and link are overwritten. Call getNodesById first to verify current state before updating.',
description: 'Update an existing node. Description is REQUIRED on every update and should be natural prose that makes clear what this thing is, why it belongs in the graph, and workflow status. Source content lives in "source". Legacy "content" and "chunk" are mapped to source for compatibility. Dimensions are REPLACED entirely with the new array. Title, description, and link are overwritten. Call getNodesById first to verify current state before updating.',
inputSchema: updateNodeInputSchema
},
async ({ id, updates }) => {
if (!updates || Object.keys(updates).length === 0) {
throw new Error('At least one field must be provided in updates.');
}
if (!updates.description) {
throw new Error('Every node update requires an explicit description (WHAT this is + WHY it matters).');
}
const descriptionError = validateExplicitDescription(updates.description);
if (descriptionError) {
throw new Error(descriptionError);
}
// Map MCP legacy fields to canonical source
const mappedUpdates = { ...updates };
if (mappedUpdates.content !== undefined) {
@@ -621,6 +641,11 @@ async function main() {
}
delete mappedUpdates.content;
delete mappedUpdates.chunk;
if (Object.prototype.hasOwnProperty.call(mappedUpdates, 'description')) {
mappedUpdates.description = typeof mappedUpdates.description === 'string'
? mappedUpdates.description.trim()
: mappedUpdates.description;
}
const node = nodeService.updateNode(id, mappedUpdates);
@@ -735,6 +760,55 @@ async function main() {
}
);
registerToolWithAliases(
'queryContexts',
{
title: 'Query RA-H contexts',
description: 'List contexts, inspect a specific context, or search contexts by name/description.',
inputSchema: queryContextsInputSchema
},
async ({ contextId, name, search, limit = 50, includeNodes = false }) => {
const normalizedName = typeof name === 'string' ? name.trim().toLowerCase() : '';
let contexts = [];
if (contextId) {
const context = contextService.getContextById(contextId);
if (context) {
contexts = [context];
}
} else if (normalizedName) {
const context = contextService.getContextByName(normalizedName);
if (context) {
contexts = [context];
}
} else {
const all = contextService.listContexts();
contexts = all.filter((context) => {
if (search) {
const haystack = `${context.name || ''} ${context.description || ''}`.toLowerCase();
return haystack.includes(search.trim().toLowerCase());
}
return true;
}).slice(0, Math.min(Math.max(limit, 1), 100));
}
const includeContextNodes = includeNodes && contexts.length === 1 && (contextId || normalizedName);
const enriched = contexts.map((context) => {
if (!includeContextNodes) return context;
const nodes = nodeService.getNodes({ contextId: context.id, limit: 500 });
return { ...context, nodes };
});
return {
content: [{ type: 'text', text: enriched.length === 0 ? 'No matching contexts found.' : `Found ${enriched.length} context(s).` }],
structuredContent: {
count: enriched.length,
contexts: enriched
}
};
}
);
registerToolWithAliases(
'createDimension',
{
+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "@ra-h/mcp-server",
"version": "1.8.0",
"version": "1.10.1",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "@ra-h/mcp-server",
"version": "1.8.0",
"version": "1.10.1",
"license": "MIT",
"dependencies": {
"@modelcontextprotocol/sdk": "^1.0.0",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "ra-h-mcp-server",
"version": "1.8.0",
"version": "1.10.1",
"description": "Connect Claude Code/Desktop to your RA-H knowledge base. Direct SQLite access - no web app required.",
"main": "index.js",
"bin": {
@@ -0,0 +1,135 @@
'use strict';
const { getDb } = require('./sqlite-client');
function mapContext(row) {
if (!row) return null;
return {
id: row.id,
name: row.name,
description: row.description ?? null,
icon: row.icon ?? null,
count: Number(row.count ?? 0),
};
}
function listContexts() {
const db = getDb();
const rows = db.prepare(`
SELECT c.id, c.name, c.description, c.icon, COUNT(n.id) AS count
FROM contexts c
LEFT JOIN nodes n ON n.context_id = c.id
GROUP BY c.id
ORDER BY c.name COLLATE NOCASE ASC
`).all();
return rows.map(mapContext);
}
function getContextById(id) {
const db = getDb();
const row = db.prepare(`
SELECT c.id, c.name, c.description, c.icon, COUNT(n.id) AS count
FROM contexts c
LEFT JOIN nodes n ON n.context_id = c.id
WHERE c.id = ?
GROUP BY c.id
`).get(id);
return mapContext(row);
}
function getContextByName(name) {
const trimmed = typeof name === 'string' ? name.trim() : '';
if (!trimmed) return null;
const db = getDb();
const row = db.prepare(`
SELECT c.id, c.name, c.description, c.icon, COUNT(n.id) AS count
FROM contexts c
LEFT JOIN nodes n ON n.context_id = c.id
WHERE lower(c.name) = lower(?)
GROUP BY c.id
`).get(trimmed);
return mapContext(row);
}
function createContext({ name, description = null, icon = null }) {
const db = getDb();
const trimmedName = typeof name === 'string' ? name.trim() : '';
if (!trimmedName) {
throw new Error('Context name is required.');
}
const now = new Date().toISOString();
const info = db.prepare(`
INSERT INTO contexts (name, description, icon, created_at, updated_at)
VALUES (?, ?, ?, ?, ?)
`).run(trimmedName, description ?? null, icon ?? null, now, now);
return getContextById(Number(info.lastInsertRowid));
}
function updateContext({ id, name, description, icon }) {
const db = getDb();
const existing = getContextById(id);
if (!existing) {
throw new Error(`Context ${id} not found.`);
}
const nextName = typeof name === 'string' && name.trim() ? name.trim() : existing.name;
const nextDescription = description === undefined ? existing.description : description;
const nextIcon = icon === undefined ? existing.icon : icon;
const now = new Date().toISOString();
db.prepare(`
UPDATE contexts
SET name = ?, description = ?, icon = ?, updated_at = ?
WHERE id = ?
`).run(nextName, nextDescription ?? null, nextIcon ?? null, now, id);
return getContextById(id);
}
function resolveContextId(input = {}) {
const hasContextId = Object.prototype.hasOwnProperty.call(input, 'context_id');
const hasContextName = typeof input.context_name === 'string' && input.context_name.trim().length > 0;
if (!hasContextId && !hasContextName) {
return undefined;
}
if (hasContextId && input.context_id === null) {
if (hasContextName) {
throw new Error('context_name cannot be combined with context_id: null.');
}
return null;
}
let resolvedById = null;
if (hasContextId) {
resolvedById = getContextById(input.context_id);
if (!resolvedById) {
throw new Error(`Context ${input.context_id} not found.`);
}
}
if (!hasContextName) {
return resolvedById ? resolvedById.id : undefined;
}
const byName = getContextByName(input.context_name);
if (!byName) {
throw new Error(`Context "${input.context_name}" not found.`);
}
if (resolvedById && resolvedById.id !== byName.id) {
throw new Error('context_id and context_name refer to different contexts.');
}
return byName.id;
}
module.exports = {
listContexts,
getContextById,
getContextByName,
createContext,
updateContext,
resolveContextId,
};
@@ -1,47 +1,83 @@
'use strict';
const { query, transaction, getDb } = require('./sqlite-client');
const contextService = require('./contextService');
function normalizeDimensionName(value) {
return String(value || '').trim().replace(/\s+/g, ' ');
}
function getUnknownDimensions(dimensions) {
if (!Array.isArray(dimensions) || dimensions.length === 0) return [];
const normalized = dimensions
.map(normalizeDimensionName)
.filter(Boolean);
if (normalized.length === 0) return [];
const placeholders = normalized.map(() => '?').join(', ');
const rows = query(`SELECT name FROM dimensions WHERE name IN (${placeholders})`, normalized);
const existing = new Set(rows.map(row => normalizeDimensionName(row.name)));
return normalized.filter(value => !existing.has(value));
}
function formatUnknownDimensionsError(values) {
if (values.length === 1) {
return `Unknown dimension: "${values[0]}". Create it first or use an existing dimension.`;
function parseMetadata(metadata) {
if (!metadata) return {};
if (typeof metadata === 'string') {
try {
const parsed = JSON.parse(metadata);
return parsed && typeof parsed === 'object' && !Array.isArray(parsed) ? { ...parsed } : {};
} catch {
return {};
}
}
return metadata && typeof metadata === 'object' && !Array.isArray(metadata) ? { ...metadata } : {};
}
return `Unknown dimensions: ${values.map(value => `"${value}"`).join(', ')}. Create them first or use existing dimensions.`;
function normalizeString(value) {
if (typeof value !== 'string') return undefined;
const trimmed = value.trim();
return trimmed.length > 0 ? trimmed : undefined;
}
function buildCanonicalMetadata({ existing, metadata }) {
const prior = parseMetadata(existing);
const incoming = parseMetadata(metadata);
const sourceMetadata = {
...(prior.source_metadata && typeof prior.source_metadata === 'object' ? prior.source_metadata : {}),
...(incoming.source_metadata && typeof incoming.source_metadata === 'object' ? incoming.source_metadata : {}),
};
const merged = {
...prior,
...incoming,
state: incoming.state === 'processed' ? 'processed' : (prior.state === 'processed' ? 'processed' : 'not_processed'),
captured_by: incoming.captured_by || prior.captured_by || 'human',
source_metadata: sourceMetadata,
};
const type = normalizeString(incoming.type) || normalizeString(prior.type);
const capturedMethod = normalizeString(incoming.captured_method) || normalizeString(prior.captured_method);
if (type) merged.type = type;
else delete merged.type;
if (capturedMethod) merged.captured_method = capturedMethod;
else delete merged.captured_method;
return merged;
}
function mapNodeRow(row) {
return {
...row,
dimensions: JSON.parse(row.dimensions_json || '[]'),
metadata: row.metadata ? (typeof row.metadata === 'string' ? JSON.parse(row.metadata) : row.metadata) : null,
context: row.context_json ? JSON.parse(row.context_json) : null,
dimensions_json: undefined,
context_json: undefined,
};
}
/**
* Get nodes with optional filtering.
*/
function getNodes(filters = {}) {
const { dimensions, search, limit = 100, offset = 0 } = filters;
const { dimensions, search, limit = 100, offset = 0, contextId } = filters;
let sql = `
SELECT n.id, n.title, n.description, n.source, n.link, n.event_date, n.metadata,
n.created_at, n.updated_at,
n.created_at, n.updated_at, n.context_id,
COALESCE((SELECT JSON_GROUP_ARRAY(d.dimension)
FROM node_dimensions d WHERE d.node_id = n.id), '[]') as dimensions_json
FROM node_dimensions d WHERE d.node_id = n.id), '[]') as dimensions_json,
CASE
WHEN c.id IS NULL THEN NULL
ELSE json_object('id', c.id, 'name', c.name, 'description', c.description, 'icon', c.icon)
END as context_json
FROM nodes n
LEFT JOIN contexts c ON c.id = n.context_id
WHERE 1=1
`;
const params = [];
@@ -61,6 +97,10 @@ function getNodes(filters = {}) {
sql += ` AND (n.title LIKE ? COLLATE NOCASE OR n.description LIKE ? COLLATE NOCASE OR n.source LIKE ? COLLATE NOCASE)`;
params.push(`%${search}%`, `%${search}%`, `%${search}%`);
}
if (contextId !== undefined) {
sql += ' AND n.context_id = ?';
params.push(contextId);
}
// Sort by search relevance or updated_at
if (search) {
@@ -85,12 +125,7 @@ function getNodes(filters = {}) {
const rows = query(sql, params);
return rows.map(row => ({
...row,
dimensions: JSON.parse(row.dimensions_json || '[]'),
metadata: row.metadata ? (typeof row.metadata === 'string' ? JSON.parse(row.metadata) : row.metadata) : null,
dimensions_json: undefined
}));
return rows.map(mapNodeRow);
}
/**
@@ -99,10 +134,15 @@ function getNodes(filters = {}) {
function getNodeById(id) {
const sql = `
SELECT n.id, n.title, n.description, n.source, n.link, n.event_date, n.metadata,
n.created_at, n.updated_at,
n.created_at, n.updated_at, n.context_id,
COALESCE((SELECT JSON_GROUP_ARRAY(d.dimension)
FROM node_dimensions d WHERE d.node_id = n.id), '[]') as dimensions_json
FROM node_dimensions d WHERE d.node_id = n.id), '[]') as dimensions_json,
CASE
WHEN c.id IS NULL THEN NULL
ELSE json_object('id', c.id, 'name', c.name, 'description', c.description, 'icon', c.icon)
END as context_json
FROM nodes n
LEFT JOIN contexts c ON c.id = n.context_id
WHERE n.id = ?
`;
@@ -110,12 +150,7 @@ function getNodeById(id) {
if (rows.length === 0) return null;
const row = rows[0];
return {
...row,
dimensions: JSON.parse(row.dimensions_json || '[]'),
metadata: row.metadata ? (typeof row.metadata === 'string' ? JSON.parse(row.metadata) : row.metadata) : null,
dimensions_json: undefined
};
return mapNodeRow(row);
}
/**
@@ -129,6 +164,134 @@ function sanitizeTitle(title) {
return clean.slice(0, 160);
}
const STOP_WORDS = new Set([
'a', 'an', 'and', 'are', 'as', 'at', 'be', 'by', 'for', 'from', 'has', 'i',
'in', 'is', 'it', 'its', 'of', 'on', 'or', 'that', 'the', 'their', 'this',
'to', 'was', 'with', 'you', 'your'
]);
function normalizeText(value) {
if (typeof value !== 'string') return '';
return value.toLowerCase().replace(/[^a-z0-9\s]+/g, ' ').replace(/\s+/g, ' ').trim();
}
function tokenize(value) {
return normalizeText(value)
.split(' ')
.map((token) => token.trim())
.filter((token) => token.length >= 2 && !STOP_WORDS.has(token));
}
function uniqueTokens(values) {
return [...new Set(values.flatMap((value) => tokenize(value || '')))];
}
function safeStringify(value) {
try {
return JSON.stringify(value ?? {});
} catch {
return '';
}
}
function fetchContextCandidates() {
return query(`
WITH context_counts AS (
SELECT c.id, c.name, c.description, COUNT(n.id) AS count
FROM contexts c
LEFT JOIN nodes n ON n.context_id = c.id
GROUP BY c.id
),
ranked_anchors AS (
SELECT
c.id AS context_id,
n.title AS anchor_title,
n.description AS anchor_description,
ROW_NUMBER() OVER (
PARTITION BY c.id
ORDER BY COUNT(e.id) DESC, n.updated_at DESC, n.id ASC
) AS anchor_rank
FROM contexts c
LEFT JOIN nodes n ON n.context_id = c.id
LEFT JOIN edges e ON (e.from_node_id = n.id OR e.to_node_id = n.id)
GROUP BY c.id, n.id
)
SELECT
cc.id,
cc.name,
cc.description,
cc.count,
ra.anchor_title,
ra.anchor_description
FROM context_counts cc
LEFT JOIN ranked_anchors ra
ON ra.context_id = cc.id
AND ra.anchor_rank = 1
ORDER BY cc.name COLLATE NOCASE ASC
`).map((row) => ({
id: Number(row.id),
name: row.name,
description: row.description ?? null,
count: Number(row.count ?? 0),
anchor_title: row.anchor_title ?? null,
anchor_description: row.anchor_description ?? null,
}));
}
function scoreContextCandidate(candidate, input) {
const titleText = normalizeText(input.title || '');
const descriptionText = normalizeText(input.description || '');
const sourceText = normalizeText(String(input.source || '').slice(0, 4000));
const metadataText = normalizeText(safeStringify(input.metadata));
const dimensionTokens = uniqueTokens(input.dimensions || []);
const contextName = normalizeText(candidate.name);
const contextNameTokens = tokenize(candidate.name);
const contextDescriptorTokens = uniqueTokens([
candidate.description,
candidate.anchor_title,
candidate.anchor_description,
]);
let score = 0;
if (contextName && (titleText.includes(contextName) || descriptionText.includes(contextName))) score += 80;
if (contextName && sourceText.includes(contextName)) score += 40;
for (const token of contextNameTokens) {
if (dimensionTokens.includes(token)) score += 30;
if (titleText.includes(token)) score += 16;
if (descriptionText.includes(token)) score += 12;
if (sourceText.includes(token)) score += 6;
if (metadataText.includes(token)) score += 4;
}
for (const token of contextDescriptorTokens) {
if (dimensionTokens.includes(token)) score += 8;
if (titleText.includes(token)) score += 4;
if (descriptionText.includes(token)) score += 3;
if (sourceText.includes(token)) score += 2;
}
return score;
}
function inferBestContextIdForNode(input) {
const contexts = fetchContextCandidates();
if (contexts.length === 0) return null;
const ranked = contexts
.map((context) => ({ context, score: scoreContextCandidate(context, input) }))
.sort((a, b) => b.score - a.score || (b.context.count - a.context.count) || a.context.id - b.context.id);
const best = ranked[0];
if (!best) return null;
if (best.score > 0) return best.context.id;
const research = contexts.find((context) => context.name.trim().toLowerCase() === 'research');
if (research) return research.id;
return best.context.id;
}
/**
* Create a new node.
*/
@@ -140,26 +303,25 @@ function createNode(nodeData) {
link,
event_date,
dimensions = [],
metadata = {}
metadata = {},
context_id
} = nodeData;
const title = sanitizeTitle(rawTitle);
const unknownDimensions = getUnknownDimensions(dimensions);
if (unknownDimensions.length > 0) {
throw new Error(formatUnknownDimensionsError(unknownDimensions));
}
const canonicalMetadata = buildCanonicalMetadata({ metadata });
const now = new Date().toISOString();
const db = getDb();
const sourceToStore = source && source.trim()
? source
: [title, description].filter(Boolean).join('\n\n').trim() || null;
const sourceToStore = source ?? ([title, description].filter(Boolean).join('\n\n').trim() || null);
const effectiveContextId = context_id == null
? inferBestContextIdForNode({ title, description, source: sourceToStore, dimensions, metadata: canonicalMetadata })
: context_id;
const nodeId = transaction(() => {
const stmt = db.prepare(`
INSERT INTO nodes (title, description, source, link, event_date, metadata, created_at, updated_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
INSERT INTO nodes (title, description, source, link, event_date, metadata, context_id, created_at, updated_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
`);
const result = stmt.run(
@@ -168,7 +330,8 @@ function createNode(nodeData) {
sourceToStore,
link ?? null,
event_date ?? null,
JSON.stringify(metadata),
JSON.stringify(canonicalMetadata),
effectiveContextId ?? null,
now,
now
);
@@ -193,7 +356,6 @@ function createNode(nodeData) {
/**
* Update an existing node.
* Source-first update path.
*/
function updateNode(id, updates, options = {}) {
const { title, description, source, link, event_date, dimensions, metadata } = updates;
@@ -206,12 +368,9 @@ function updateNode(id, updates, options = {}) {
throw new Error(`Node with ID ${id} not found. Use rah_search_nodes to find nodes by keyword.`);
}
if (Array.isArray(dimensions)) {
const unknownDimensions = getUnknownDimensions(dimensions);
if (unknownDimensions.length > 0) {
throw new Error(formatUnknownDimensionsError(unknownDimensions));
}
}
const mergedMetadata = metadata !== undefined
? buildCanonicalMetadata({ existing: existing.metadata, metadata })
: undefined;
transaction(() => {
const setFields = [];
@@ -237,9 +396,13 @@ function updateNode(id, updates, options = {}) {
setFields.push('event_date = ?');
params.push(event_date);
}
if (metadata !== undefined) {
if (Object.prototype.hasOwnProperty.call(updates, 'context_id')) {
setFields.push('context_id = ?');
params.push(updates.context_id ?? null);
}
if (mergedMetadata !== undefined) {
setFields.push('metadata = ?');
params.push(JSON.stringify(metadata));
params.push(JSON.stringify(mergedMetadata));
}
// Always update timestamp
@@ -286,7 +449,7 @@ function getNodeCount() {
/**
* Get knowledge graph context overview.
* Returns stats, hub nodes, dimensions, and recent activity.
* Returns stats, contexts, hub nodes, dimensions, and recent activity.
*/
function getContext() {
const nodeCount = query('SELECT COUNT(*) as count FROM nodes')[0].count;
@@ -315,7 +478,8 @@ function getContext() {
`);
return {
stats: { nodeCount, edgeCount, dimensionCount: dimensions.length },
stats: { nodeCount, edgeCount, dimensionCount: dimensions.length, contextCount: contextService.listContexts().length },
contexts: contextService.listContexts(),
dimensions,
recentNodes,
hubNodes
@@ -26,6 +26,7 @@ const SEEDED_SKILL_IDS = new Set([
'persona',
'calibration',
'connect',
'node-context-enrichment',
]);
const DEPRECATED_SKILL_IDS = new Set([
@@ -59,7 +59,9 @@ function initDatabase() {
embedding BLOB,
embedding_updated_at TEXT,
embedding_text TEXT,
chunk_status TEXT DEFAULT 'not_chunked'
chunk_status TEXT DEFAULT 'not_chunked',
context_id INTEGER,
FOREIGN KEY (context_id) REFERENCES contexts(id) ON DELETE SET NULL
);
CREATE TABLE IF NOT EXISTS edges (
@@ -93,6 +95,15 @@ function initDatabase() {
updated_at TEXT DEFAULT CURRENT_TIMESTAMP
);
CREATE TABLE IF NOT EXISTS contexts (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL,
description TEXT NOT NULL DEFAULT '',
icon TEXT,
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
);
-- Seed default dimensions
INSERT OR IGNORE INTO dimensions (name, is_priority) VALUES ('research', 1);
INSERT OR IGNORE INTO dimensions (name, is_priority) VALUES ('ideas', 1);
@@ -143,6 +154,42 @@ function initDatabase() {
AND LENGTH(TRIM(chunk)) > 0;
`);
}
if (!nodeCols.includes('context_id')) {
db.exec('ALTER TABLE nodes ADD COLUMN context_id INTEGER REFERENCES contexts(id) ON DELETE SET NULL;');
}
db.exec(`
CREATE TABLE IF NOT EXISTS contexts (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL,
description TEXT NOT NULL DEFAULT '',
icon TEXT,
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
);
`);
const contextCols = db.prepare('PRAGMA table_info(contexts)').all().map(c => c.name);
if (!contextCols.includes('description')) {
db.exec("ALTER TABLE contexts ADD COLUMN description TEXT NOT NULL DEFAULT '';");
}
if (!contextCols.includes('icon')) {
db.exec('ALTER TABLE contexts ADD COLUMN icon TEXT;');
}
if (!contextCols.includes('created_at')) {
db.exec("ALTER TABLE contexts ADD COLUMN created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP;");
}
if (!contextCols.includes('updated_at')) {
db.exec("ALTER TABLE contexts ADD COLUMN updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP;");
}
db.exec(`
UPDATE contexts
SET description = COALESCE(NULLIF(TRIM(description), ''), name)
WHERE description IS NULL OR LENGTH(TRIM(description)) = 0;
CREATE UNIQUE INDEX IF NOT EXISTS idx_contexts_name_normalized
ON contexts(LOWER(TRIM(name)));
CREATE INDEX IF NOT EXISTS idx_nodes_context_id ON nodes(context_id);
`);
const edgeCols = db.prepare('PRAGMA table_info(edges)').all().map(c => c.name);
if (!edgeCols.includes('explanation')) {
+3 -4
View File
@@ -1,9 +1,6 @@
---
name: Audit
description: "Run a structured audit of graph quality, skill quality, and operational consistency."
when_to_use: "User asks for review, QA, cleanup, or governance checks."
when_not_to_use: "Simple one-off write/read requests."
success_criteria: "Findings are prioritized, concrete, and tied to actionable fixes."
description: "Use for structured review, QA, cleanup, or governance checks across graph quality, skill quality, and operational consistency."
---
# Audit
@@ -27,3 +24,5 @@ success_criteria: "Findings are prioritized, concrete, and tied to actionable fi
- Prefer specific evidence over generic commentary.
- Propose the smallest high-leverage fixes first.
- Separate defects from optional polish.
- Node descriptions must read like natural prose while still making what / why / status clear.
- Flag any node description missing a clear why or status component as a high-priority quality issue.
@@ -3,7 +3,7 @@ name: Calibration
description: "Run periodic check-ins to recalibrate goals, projects, preferences, and graph structure."
when_to_use: "User asks for a check-in, reset, review, or strategic recalibration."
when_not_to_use: "Single isolated question with no strategic update needed."
success_criteria: "Graph reflects current reality: updated hubs, changed priorities, and explicit deltas."
success_criteria: "Graph reflects current reality: updated contexts, changed priorities, and explicit deltas."
---
# Calibration
@@ -14,7 +14,7 @@ Re-anchor the graph to the user's current state.
## Check-in Sequence
1. Review major hub nodes and active project nodes.
1. Review major contexts, anchor nodes, and active project nodes.
2. Ask what changed: goals, priorities, constraints, beliefs, preferences.
3. Identify stale nodes and missing nodes.
4. Propose precise updates: update existing vs create new.
@@ -1,9 +1,6 @@
---
name: DB Operations
description: "Use this for all graph read/write operations with strict data quality standards."
when_to_use: "Any request to read, create, update, connect, classify, or traverse graph data."
when_not_to_use: "Pure conversation with no graph interaction needed."
success_criteria: "Writes are explicit and correct; descriptions are concrete; edges and dimensions are high-signal."
---
# DB Operations
@@ -11,7 +8,7 @@ success_criteria: "Writes are explicit and correct; descriptions are concrete; e
## Core Rules
1. Search before create to avoid duplicates.
2. Every create/update must include an explicit description of WHAT the thing is and WHY it matters.
2. Every create/update should aim for a natural description that makes clear what the thing is and any surrounding context available, but description quality is guidance only. RA-H should never block or rewrite a write because of description quality.
3. Use event dates when known (when it happened, not when saved).
4. Apply dimensions deliberately; prefer existing dimensions over creating noisy new ones.
5. Create edges when relationships are meaningful; edge explanations should read as a sentence.
@@ -19,9 +16,10 @@ success_criteria: "Writes are explicit and correct; descriptions are concrete; e
## Write Quality Contract
- `title`: clear and specific.
- `description`: concrete object-level description, not vague summaries.
- `notes/content`: extra context, analysis, supporting detail.
- `description`: natural prose, not labels. It should still make what / why / status clear when possible.
- `source`: full verbatim or canonical content of the node (transcript, article text, book passage, user's thoughts). This is what gets chunked and embedded for semantic search. For user-authored ideas or dictated notes, preserve the user's original wording with minimal cleanup.
- `link`: external source URL only.
- `metadata`: prefer canonical keys `type`, `state`, `captured_method`, `captured_by`, and `source_metadata`.
## Execution Pattern
@@ -29,9 +27,11 @@ success_criteria: "Writes are explicit and correct; descriptions are concrete; e
2. Decide: create vs update vs connect.
3. Execute minimum required writes.
4. Verify result reflects user intent exactly.
5. If description framing was materially inferred, complete the write first and then invite one concise user feedback pass instead of blocking creation.
## Do Not
- Create duplicate nodes when an update is correct.
- Write vague descriptions ("discusses", "explores", "is about").
- Replace a user's raw idea/source with a thin summary.
- Create weak or directionless edges.
@@ -0,0 +1,36 @@
---
name: Node Context Enrichment
description: "Use to rewrite thin node descriptions into natural prose that still makes what, why, and status clear, with dimension review and edge suggestions."
---
# Node Context Enrichment
Use this when a node already exists but its description is thin, generic, or missing personal context.
This skill should not silently rewrite and move on when contextual framing is inferred. If the enrichment depends on interpretation, update the node and then explicitly invite the user to correct or refine the contextual framing.
## Goal
Replace weak descriptions with a single clean natural description that captures:
1. What the artifact literally is
2. Why it is in Brad's graph
3. Status in Brad's workflow
Also review whether the node needs dimension fixes or obvious edge suggestions.
## Workflow
1. Load the node and inspect title, description, source, link, metadata, dimensions, and nearby edges.
2. Search for adjacent context before rewriting.
3. Infer the best available "why" from that context.
4. Rewrite the full description from scratch in natural prose. Do not append to the old text or use labels like WHAT:, WHY:, or STATUS:.
5. Review dimensions.
6. Suggest 1-3 high-signal edges when obvious.
7. Update the node once the description is strong enough to be useful.
8. After the update, tell the user what changed and ask whether they want to refine the important framing:
- what it is
- why it belongs in the graph
- status / current relevance / workflow position
The user feedback pass is required whenever the enriched "why" or status was inferred rather than directly stated in the node/source.
@@ -3,7 +3,7 @@ name: Onboarding
description: "Onboard a new user by mapping goals, projects, worldview, and preferences into a strong initial graph."
when_to_use: "New user setup or major reset of account context."
when_not_to_use: "User asks for a narrow tactical operation only."
success_criteria: "User has an initial hub-node structure and clear next steps for graph growth."
success_criteria: "User has an initial context structure, anchor candidates, and clear next steps for graph growth."
---
# Onboarding
@@ -22,13 +22,14 @@ Understand the user deeply enough to bootstrap a useful externalized context gra
## Graph Bootstrap
1. Propose 3-6 hub nodes with clear rationale.
2. Propose starter dimensions that reflect real domains.
3. Create initial edges between hubs and active projects.
4. Confirm with user before writing.
1. Propose 3-6 primary contexts with clear rationale.
2. Identify one strong anchor candidate per context.
3. Propose starter dimensions as secondary metadata and filters.
4. Create initial edges between anchor nodes and active project nodes.
5. Confirm with user before writing.
## Output
- Initial hub map
- Initial context map
- Suggested first write actions
- Suggested weekly maintenance rhythm
+103 -34
View File
@@ -44,8 +44,9 @@ let logger = (message) => console.log(`[mcp] ${message}`);
const instructions = [
'RA-H is a personal knowledge graph — local-first, vendor-neutral.',
'Core concepts: nodes (knowledge units), edges (connections with explanations), dimensions (categories).',
'Always call rah_get_context first to orient yourself — it returns hub nodes, dimensions, stats, and available guides.',
'Core concepts: contexts (primary scopes), nodes (knowledge units), edges (connections with explanations), and dimensions (secondary metadata and filters).',
'Always call rah_get_context first to orient yourself — it returns contexts, hub nodes, dimensions, stats, and available guides.',
'Use contexts as the primary scope layer. Use rah_query_contexts before assigning or filtering by context when needed.',
'When assigning dimensions, use only existing dimensions returned by rah_get_context or rah_query_dimensions.',
'Do not invent new dimensions from node titles, concepts, or phrasing.',
'Only call rah_create_dimension when the user explicitly instructs you to create a new dimension.',
@@ -91,9 +92,11 @@ const addNodeInputSchema = {
content: z.string().max(20000).optional(),
source: z.string().max(50000).optional(),
link: z.string().url().optional(),
description: z.string().max(2000).optional(),
description: z.string().max(500).optional().describe('Description of the node. Write it as natural prose, not labels or a checklist. It must still make clear what the artifact is, why it is in the graph (infer from conversation context; ask the user if needed), and its current workflow status. Max 500 characters. If the reason is unclear, say that naturally instead of inventing it. Never use filler phrases like "insightful for understanding" or "relevant to the user\'s work".'),
context_id: z.number().int().positive().nullable().optional(),
context_name: z.string().optional(),
dimensions: z.array(z.string()).min(1).max(5),
metadata: z.record(z.any()).optional(),
metadata: z.record(z.any()).optional().describe('Optional metadata. Prefer canonical keys: type, state, captured_method, captured_by, source_metadata.'),
chunk: z.string().max(50000).optional()
};
@@ -107,7 +110,16 @@ const addNodeOutputSchema = {
const searchNodesInputSchema = {
query: z.string().min(1).max(400),
limit: z.number().min(1).max(25).optional(),
dimensions: z.array(z.string()).max(5).optional()
dimensions: z.array(z.string()).max(5).optional(),
contextId: z.number().int().positive().optional()
};
const queryContextsInputSchema = {
contextId: z.number().int().positive().optional(),
name: z.string().optional(),
search: z.string().optional(),
limit: z.number().min(1).max(100).optional(),
includeNodes: z.boolean().optional()
};
const searchNodesOutputSchema = {
@@ -130,11 +142,13 @@ const updateNodeInputSchema = {
id: z.number().int().positive().describe('The ID of the node to update'),
updates: z.object({
title: z.string().optional().describe('New title'),
description: z.string().optional().describe('New description (overwrites existing)'),
content: z.string().optional().describe('Content to APPEND (not replace)'),
description: z.string().max(500).optional().describe('Description of the node. Write it as natural prose, not labels or a checklist. It must still make clear what the artifact is, why it is in the graph (infer from conversation context; ask the user if needed), and its current workflow status. Max 500 characters. If the reason is unclear, say that naturally instead of inventing it. Never use filler phrases like "insightful for understanding" or "relevant to the user\'s work".'),
content: z.string().optional().describe('Legacy alias for source. Mapped to source for backward compatibility.'),
source: z.string().optional().describe('Canonical source text for embedding.'),
link: z.string().optional().describe('New link'),
context_id: z.number().int().positive().nullable().optional().describe('Primary context ID. Omit to preserve existing context; use null to clear.'),
dimensions: z.array(z.string()).optional().describe('New dimensions (replaces existing)'),
metadata: z.record(z.any()).optional().describe('New metadata (replaces existing)')
metadata: z.record(z.any()).optional().describe('Metadata patch. This now merges with existing metadata. Prefer canonical keys: type, state, captured_method, captured_by, source_metadata.')
}).describe('Fields to update')
};
@@ -269,8 +283,7 @@ const extractUrlInputSchema = {
const extractUrlOutputSchema = {
success: z.boolean(),
title: z.string(),
content: z.string(),
chunk: z.string(),
source: z.string(),
metadata: z.record(z.any())
};
@@ -283,7 +296,7 @@ const extractYoutubeOutputSchema = {
success: z.boolean(),
title: z.string(),
channel: z.string(),
transcript: z.string(),
source: z.string(),
metadata: z.record(z.any())
};
@@ -295,8 +308,7 @@ const extractPdfInputSchema = {
const extractPdfOutputSchema = {
success: z.boolean(),
title: z.string(),
content: z.string(),
chunk: z.string(),
source: z.string(),
metadata: z.record(z.any())
};
@@ -349,11 +361,11 @@ mcpServer.registerTool(
'rah_add_node',
{
title: 'Add RA-H node',
description: 'Create a new node in the local RA-H knowledge base.',
description: 'Create a new node in the local RA-H knowledge base. Set context explicitly when clear; otherwise RA-H will infer the best-fit context automatically on create. Use only existing dimensions; do not invent new ones.',
inputSchema: addNodeInputSchema,
outputSchema: addNodeOutputSchema
},
async ({ title, content, source, link, description, dimensions, metadata, chunk }) => {
async ({ title, content, source, link, description, context_id, context_name, dimensions, metadata, chunk }) => {
const normalizedDimensions = sanitizeDimensions(dimensions);
if (normalizedDimensions.length === 0) {
throw new McpError(
@@ -364,9 +376,11 @@ mcpServer.registerTool(
const payload = {
title: title.trim(),
source: source?.trim() || chunk?.trim() || content?.trim() || undefined,
source: source?.trim() || content?.trim() || chunk?.trim() || undefined,
link: link?.trim() || undefined,
description: description?.trim() || undefined,
context_id: context_id === null ? null : context_id,
context_name: context_name?.trim() || undefined,
dimensions: normalizedDimensions,
metadata: metadata || {}
};
@@ -399,7 +413,7 @@ mcpServer.registerTool(
inputSchema: searchNodesInputSchema,
outputSchema: searchNodesOutputSchema
},
async ({ query, limit = 10, dimensions }) => {
async ({ query, limit = 10, dimensions, contextId }) => {
const params = new URLSearchParams();
params.set('search', query.trim());
params.set('limit', String(Math.min(Math.max(limit, 1), 25)));
@@ -408,6 +422,9 @@ mcpServer.registerTool(
if (dimensionList.length > 0) {
params.set('dimensions', dimensionList.join(','));
}
if (contextId) {
params.set('contextId', String(contextId));
}
const result = await callRaHApi(`/api/nodes?${params.toString()}`, {
method: 'GET'
@@ -436,11 +453,59 @@ mcpServer.registerTool(
}
);
mcpServer.registerTool(
'rah_query_contexts',
{
title: 'Query RA-H contexts',
description: 'List contexts, inspect a specific context, or search contexts by name/description.',
inputSchema: queryContextsInputSchema
},
async ({ contextId, name, search, limit = 50, includeNodes = false }) => {
const normalizedName = typeof name === 'string' ? name.trim().toLowerCase() : '';
let contexts = [];
if (contextId) {
const result = await callRaHApi(`/api/contexts/${contextId}`, { method: 'GET' });
if (result?.data) {
contexts = [result.data];
}
} else {
const result = await callRaHApi('/api/contexts', { method: 'GET' });
const all = Array.isArray(result.data) ? result.data : [];
contexts = all.filter((context) => {
if (normalizedName && context.name?.trim().toLowerCase() !== normalizedName) {
return false;
}
if (search) {
const haystack = `${context.name || ''} ${context.description || ''}`.toLowerCase();
return haystack.includes(search.trim().toLowerCase());
}
return true;
}).slice(0, Math.min(Math.max(limit, 1), 100));
}
const includeContextNodes = includeNodes && contexts.length === 1 && (contextId || normalizedName);
const enriched = await Promise.all(contexts.map(async (context) => {
if (!includeContextNodes) return context;
const nodeResult = await callRaHApi(`/api/contexts/${context.id}/nodes`, { method: 'GET' });
return { ...context, nodes: Array.isArray(nodeResult.data) ? nodeResult.data : [] };
}));
return {
content: [{ type: 'text', text: enriched.length === 0 ? 'No matching contexts found.' : `Found ${enriched.length} context(s).` }],
structuredContent: {
count: enriched.length,
contexts: enriched
}
};
}
);
mcpServer.registerTool(
'rah_update_node',
{
title: 'Update RA-H node',
description: 'Update an existing node. Content is APPENDED (not replaced). Dimensions are replaced.',
description: 'Update an existing node. Dimensions must be existing canonical dimensions; do not invent new ones.',
inputSchema: updateNodeInputSchema,
outputSchema: updateNodeOutputSchema
},
@@ -449,15 +514,15 @@ mcpServer.registerTool(
throw new McpError(ErrorCode.InvalidParams, 'At least one field must be provided in updates.');
}
// Map MCP legacy fields to canonical source
// Backward compatibility: map legacy content/chunk → source
const mappedUpdates = { ...updates };
if (mappedUpdates.content !== undefined) {
mappedUpdates.source = mappedUpdates.content;
}
if (mappedUpdates.chunk !== undefined && mappedUpdates.source === undefined) {
mappedUpdates.source = mappedUpdates.chunk;
}
delete mappedUpdates.content;
if (mappedUpdates.content !== undefined) {
mappedUpdates.source = mappedUpdates.content;
delete mappedUpdates.content;
}
delete mappedUpdates.chunk;
const result = await callRaHApi(`/api/nodes/${id}`, {
@@ -622,7 +687,7 @@ mcpServer.registerTool(
'rah_create_dimension',
{
title: 'Create RA-H dimension',
description: 'Create a new dimension/tag for organizing nodes.',
description: 'Create a new dimension/tag for organizing nodes only when the user explicitly instructs you to do so.',
inputSchema: createDimensionInputSchema,
outputSchema: createDimensionOutputSchema
},
@@ -760,8 +825,7 @@ mcpServer.registerTool(
structuredContent: {
success: true,
title: result.title || 'Untitled',
content: result.content || '',
chunk: result.chunk || '',
source: result.source || '',
metadata: result.metadata || {}
}
};
@@ -789,7 +853,7 @@ mcpServer.registerTool(
success: true,
title: result.title || 'Untitled',
channel: result.channel || 'Unknown',
transcript: result.transcript || '',
source: result.source || '',
metadata: result.metadata || {}
}
};
@@ -816,8 +880,7 @@ mcpServer.registerTool(
structuredContent: {
success: true,
title: result.title || 'Untitled PDF',
content: result.content || '',
chunk: result.chunk || '',
source: result.source || '',
metadata: result.metadata || {}
}
};
@@ -829,11 +892,12 @@ mcpServer.registerTool(
'rah_get_context',
{
title: 'Get RA-H context',
description: 'Get orientation context: hub nodes, dimensions, stats, and available guides. Call this first.',
description: 'Get orientation context: contexts, hub nodes, dimensions, stats, and available guides. Call this first.',
inputSchema: {},
outputSchema: {
stats: z.object({ nodeCount: z.number(), edgeCount: z.number(), dimensionCount: z.number() }),
stats: z.object({ nodeCount: z.number(), edgeCount: z.number(), dimensionCount: z.number(), contextCount: z.number().optional() }),
hubNodes: z.array(z.object({ id: z.number(), title: z.string(), description: z.string().nullable(), edgeCount: z.number() })),
contexts: z.array(z.object({ id: z.number(), name: z.string(), description: z.string().nullable(), icon: z.string().nullable().optional(), count: z.number() })).optional(),
dimensions: z.array(z.object({ name: z.string(), nodeCount: z.number(), description: z.string().nullable() })),
guides: z.array(z.string())
}
@@ -849,18 +913,23 @@ mcpServer.registerTool(
name: d.name, nodeCount: d.node_count ?? 0, description: d.description ?? null
})) : [];
const contextResult = await callRaHApi('/api/contexts', { method: 'GET' });
const contexts = Array.isArray(contextResult.data) ? contextResult.data.map(c => ({
id: c.id, name: c.name, description: c.description ?? null, icon: c.icon ?? null, count: c.count ?? 0
})) : [];
const guideResult = await callRaHApi('/api/guides', { method: 'GET' });
const guides = Array.isArray(guideResult.data) ? guideResult.data.map(g => g.name) : [];
const stats = { nodeCount: 0, edgeCount: 0, dimensionCount: dimensions.length };
const stats = { nodeCount: 0, edgeCount: 0, dimensionCount: dimensions.length, contextCount: contexts.length };
try {
const countResult = await callRaHApi('/api/nodes?limit=1', { method: 'GET' });
if (countResult.total !== undefined) stats.nodeCount = countResult.total;
} catch { /* use defaults */ }
return {
content: [{ type: 'text', text: `Knowledge graph: ${stats.dimensionCount} dimensions, ${hubNodes.length} hub nodes. ${guides.length} guides available.` }],
structuredContent: { stats, hubNodes, dimensions, guides }
content: [{ type: 'text', text: `Knowledge graph: ${stats.contextCount} contexts, ${stats.dimensionCount} dimensions, ${hubNodes.length} hub nodes. ${guides.length} guides available.` }],
structuredContent: { stats, hubNodes, contexts, dimensions, guides }
};
}
);
+119 -36
View File
@@ -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
}