feat: port holistic node refinement contract

This commit is contained in:
“BeeRad”
2026-04-11 21:37:52 +10:00
parent 35f9ecf89c
commit 3ae46245ec
119 changed files with 6596 additions and 10982 deletions
+24 -7
View File
@@ -41,8 +41,8 @@ Restart Claude. Done.
## What to Expect
Once connected, Claude will:
- **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
- **Call `getContext` first** to orient itself (stats, contexts, anchors/hubs, skills)
- **Proactively capture knowledge** — when a new insight, decision, person, or reference surfaces, it proposes a specific node (title, description, optional context) 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,7 +50,7 @@ Once connected, Claude will:
| Tool | Description |
|------|-------------|
| `getContext` | Get graph overview — stats, contexts, hub nodes, dimensions, recent activity |
| `getContext` | Get graph overview — stats, contexts, recent activity |
| `createNode` | Create a new node |
| `queryNodes` | Search nodes by keyword |
| `queryContexts` | List or inspect contexts |
@@ -59,10 +59,6 @@ Once connected, Claude will:
| `createEdge` | Create connection between nodes |
| `updateEdge` | Update an edge explanation |
| `queryEdge` | Find edges for a node |
| `queryDimensions` | List all dimensions |
| `createDimension` | Create a dimension |
| `updateDimension` | Update/rename a dimension |
| `deleteDimension` | Delete a dimension |
| `listSkills` | List available skills |
| `readSkill` | Read a skill by name |
| `writeSkill` | Create or update a skill |
@@ -70,6 +66,27 @@ Once connected, Claude will:
| `searchContentEmbeddings` | Search through source content (transcripts, books, articles) |
| `sqliteQuery` | Execute read-only SQL queries (SELECT/WITH/PRAGMA) |
## Node Metadata Contract
When `createNode` or `updateNode` includes metadata, prefer the canonical shape:
```json
{
"type": "website | youtube | pdf | tweet | note | chat | ...",
"state": "processed | not_processed",
"captured_method": "quick_add_note | website_extract | ...",
"captured_by": "human | agent",
"source_metadata": {}
}
```
Rules:
- `source_metadata` is for small factual source-specific fields only
- metadata updates merge with the existing object; they do not replace the full blob
- use `captured_by = "human"` for direct user creation and user-requested agent capture
- reserve `captured_by = "agent"` for autonomous/background creation only
## Skills
Skills are detailed instruction sets that teach agents how to work with your knowledge base. The default seeded skills are editable and shared by internal + external agents.
+107 -361
View File
@@ -27,18 +27,18 @@ try {
const { z } = require('zod');
const { McpServer } = require('@modelcontextprotocol/sdk/server/mcp.js');
const { StdioServerTransport } = require('@modelcontextprotocol/sdk/server/stdio.js');
const packageJson = require('./package.json');
const { initDatabase, getDatabasePath, closeDatabase, getDb, query } = require('./services/sqlite-client');
const nodeService = require('./services/nodeService');
const edgeService = require('./services/edgeService');
const contextService = require('./services/contextService');
const dimensionService = require('./services/dimensionService');
const skillService = require('./services/skillService');
// Server info
const serverInfo = {
name: 'ra-h-standalone',
version: '1.10.1'
version: packageJson.version
};
function buildInstructions() {
@@ -59,18 +59,14 @@ 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, contexts, dimensions, anchors/hubs).
1. Call getContext for orientation (stats, contexts, anchors/hubs).
2. For simple tasks, tool descriptions have everything you need.
3. For complex tasks, call readSkill("db-operations").
## Knowledge capture
Proactively offer to save valuable information when insights, decisions, or references surface.
Propose: "I'd add this as: [title] in [dimensions] — want me to?"
Propose: "I'd add this as: [title] — want me to?"
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}
@@ -82,22 +78,20 @@ All data stays on this device.`;
// Tool schemas
const addNodeInputSchema = {
title: z.string().min(1).max(160).describe('Clear, descriptive title'),
content: z.string().max(20000).optional().describe('Legacy content field; mapped to source'),
source: z.string().max(50000).optional().describe('Full source text'),
content: z.string().max(20000).optional().describe('Legacy alias for source content'),
source: z.string().max(50000).optional().describe('Canonical source content for embedding'),
link: z.string().url().optional().describe('Source URL'),
description: z.string().optional().describe('Strongly recommended. Write the description as natural prose, not labels or a checklist. It should make clear what the artifact is and any surrounding context available. RA-H will accept whatever description is provided and will not block the write.'),
context_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('Optional metadata. Prefer canonical keys: type, state, captured_method, captured_by, source_metadata.'),
chunk: z.string().max(50000).optional().describe('Full source text')
chunk: z.string().max(50000).optional().describe('Legacy alias for 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.'),
event_after: z.string().optional().describe('ISO date (YYYY-MM-DD). Only return nodes with event_date on or after this date.'),
@@ -113,12 +107,11 @@ const updateNodeInputSchema = {
updates: z.object({
title: z.string().optional().describe('New title'),
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'),
content: z.string().optional().describe('Legacy alias for source'),
source: z.string().optional().describe('Canonical source content for embedding'),
link: z.string().optional().describe('New link'),
context_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')
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')
};
@@ -146,25 +139,6 @@ const queryContextsInputSchema = {
includeNodes: z.boolean().optional().describe('Include nodes for an exact single-context lookup')
};
const listDimensionsInputSchema = {};
const createDimensionInputSchema = {
name: z.string().min(1).describe('Dimension name'),
description: z.string().max(500).optional().describe('Description'),
isPriority: z.boolean().optional().describe('Lock for auto-assignment')
};
const updateDimensionInputSchema = {
name: z.string().min(1).describe('Current dimension name'),
newName: z.string().optional().describe('New name (for renaming)'),
description: z.string().max(500).optional().describe('New description'),
isPriority: z.boolean().optional().describe('Lock/unlock dimension')
};
const deleteDimensionInputSchema = {
name: z.string().min(1).describe('Dimension name to delete')
};
const readSkillInputSchema = {
name: z.string().min(1).describe('Skill name (e.g. "db-operations", "onboarding", "persona")')
};
@@ -189,24 +163,6 @@ const sqliteQueryInputSchema = {
format: z.enum(['json', 'table']).optional().describe('Output format (default json)')
};
// Helper to sanitize dimensions
function sanitizeDimensions(raw) {
if (!Array.isArray(raw)) return [];
const result = [];
const seen = new Set();
for (const value of raw) {
if (typeof value !== 'string') continue;
const trimmed = value.trim();
if (!trimmed) continue;
const lowered = trimmed.toLowerCase();
if (seen.has(lowered)) continue;
seen.add(lowered);
result.push(trimmed);
if (result.length >= 5) break;
}
return result;
}
// FTS5 helpers
function sanitizeFtsQuery(input) {
return input
@@ -314,7 +270,7 @@ async function main() {
'getContext',
{
title: 'Get RA-H context',
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").',
description: 'Get knowledge graph overview: stats, contexts, hub nodes (secondary diagnostics), recent activity, and available skills. Call this first to orient yourself. For deeper operating policy, follow up with readSkill("db-operations").',
inputSchema: {}
},
async () => {
@@ -335,7 +291,7 @@ async function main() {
};
}
const summary = `Graph: ${context.stats.contextCount || 0} contexts, ${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, ${skills.length} skills.`;
return {
content: [{ type: 'text', text: summary }],
structuredContent: context
@@ -349,40 +305,36 @@ async function main() {
'createNode',
{
title: 'Add RA-H node',
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.',
description: 'Create a new node. Always search first (queryNodes) to avoid duplicates. Set context explicitly when clear and useful. 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 for embedding. Legacy "content" and "chunk" are mapped to source for compatibility.',
inputSchema: addNodeInputSchema
},
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.');
}
async ({ title, content, source, link, description, context_id, context_name, metadata, chunk }) => {
const sourceText = source?.trim() || content?.trim() || chunk?.trim();
const normalizedDescription = typeof description === 'string' ? description.trim() : description;
let resolvedContextId;
try {
resolvedContextId = contextService.resolveContextId({ context_id, context_name });
} catch (error) {
log('Warning: invalid explicit context input on createNode; falling back to automatic inference:', error.message);
resolvedContextId = undefined;
throw new Error(error.message);
}
const node = nodeService.createNode({
title: title.trim(),
source: source?.trim() || chunk?.trim() || content?.trim(),
source: sourceText,
link: link?.trim(),
description: typeof description === 'string' ? description.trim() : description,
description: normalizedDescription,
context_id: resolvedContextId,
dimensions: normalizedDimensions,
metadata: metadata || {}
});
const summary = `Created node #${node.id}: ${node.title} [${node.dimensions.join(', ')}]`;
const summary = `Created node #${node.id}: ${node.title}`;
return {
content: [{ type: 'text', text: summary }],
structuredContent: {
nodeId: node.id,
title: node.title,
dimensions: node.dimensions,
message: summary
}
};
@@ -393,169 +345,21 @@ async function main() {
'queryNodes',
{
title: 'Search RA-H nodes',
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.',
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 context. NOT for searching source documents (transcripts, articles) — use searchContentEmbeddings for that.',
inputSchema: searchNodesInputSchema
},
async ({ query: searchQuery, limit = 10, contextId, dimensions, created_after, created_before, event_after, event_before }) => {
const normalizedDimensions = sanitizeDimensions(dimensions || []);
async ({ query: searchQuery, limit = 10, contextId, created_after, created_before, event_after, event_before }) => {
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 = [];
if (created_after) { temporalClauses.push('n.created_at >= ?'); temporalParams.push(created_after); }
if (created_before) { temporalClauses.push('n.created_at < ?'); temporalParams.push(created_before); }
if (event_after) { temporalClauses.push('n.event_date >= ?'); temporalParams.push(event_after); }
if (event_before) { temporalClauses.push('n.event_date < ?'); temporalParams.push(event_before); }
const temporalSQL = temporalClauses.length > 0 ? temporalClauses.map(c => `AND ${c}`).join(' ') : '';
let nodes = null;
// Try FTS5 first (handles multi-word queries naturally)
if (fts.nodes) {
const ftsQuery = sanitizeFtsQuery(trimmedQuery);
if (ftsQuery) {
try {
let sql, params;
if (normalizedDimensions.length > 0) {
sql = `
WITH fts_matches AS (
SELECT rowid, rank FROM nodes_fts WHERE nodes_fts MATCH ? LIMIT 100
)
SELECT n.id, n.title, n.description, n.source, n.link,
n.created_at, n.updated_at, n.event_date,
COALESCE((SELECT JSON_GROUP_ARRAY(d.dimension)
FROM node_dimensions d WHERE d.node_id = n.id), '[]') as dimensions_json
FROM fts_matches fm
JOIN nodes n ON n.id = fm.rowid
WHERE EXISTS (
SELECT 1 FROM node_dimensions nd
WHERE nd.node_id = n.id
AND nd.dimension IN (${normalizedDimensions.map(() => '?').join(',')})
)
${temporalSQL}
ORDER BY fm.rank
LIMIT ?
`;
params = [ftsQuery, ...normalizedDimensions, ...temporalParams, safeLimit];
} else {
sql = `
WITH fts_matches AS (
SELECT rowid, rank FROM nodes_fts WHERE nodes_fts MATCH ? LIMIT ?
)
SELECT n.id, n.title, n.description, n.source, n.link,
n.created_at, n.updated_at, n.event_date,
COALESCE((SELECT JSON_GROUP_ARRAY(d.dimension)
FROM node_dimensions d WHERE d.node_id = n.id), '[]') as dimensions_json
FROM fts_matches fm
JOIN nodes n ON n.id = fm.rowid
${temporalSQL ? 'WHERE ' + temporalClauses.join(' AND ') : ''}
ORDER BY fm.rank
`;
params = [ftsQuery, safeLimit, ...temporalParams];
}
const rows = query(sql, params);
nodes = rows.map(row => ({
id: row.id,
title: row.title,
source: row.source ?? null,
description: row.description ?? null,
link: row.link ?? null,
dimensions: JSON.parse(row.dimensions_json || '[]'),
created_at: row.created_at,
updated_at: row.updated_at,
event_date: row.event_date ?? null
}));
} catch (err) {
log('FTS search failed, falling back to LIKE:', err.message);
nodes = null;
}
}
}
// Fallback: LIKE with word splitting (each word must appear somewhere)
if (nodes === null) {
const words = trimmedQuery.split(/\s+/).filter(w => w.length > 0);
let sql = `
SELECT n.id, n.title, n.description, n.source, n.link,
n.created_at, n.updated_at, n.event_date,
COALESCE((SELECT JSON_GROUP_ARRAY(d.dimension)
FROM node_dimensions d WHERE d.node_id = n.id), '[]') as dimensions_json
FROM nodes n
WHERE 1=1
`;
const params = [];
for (const word of words) {
sql += ` AND (n.title LIKE ? COLLATE NOCASE OR n.description LIKE ? COLLATE NOCASE OR n.source LIKE ? COLLATE NOCASE)`;
params.push(`%${word}%`, `%${word}%`, `%${word}%`);
}
if (normalizedDimensions.length > 0) {
sql += ` AND EXISTS (
SELECT 1 FROM node_dimensions nd
WHERE nd.node_id = n.id
AND nd.dimension IN (${normalizedDimensions.map(() => '?').join(',')})
)`;
params.push(...normalizedDimensions);
}
// Temporal filters
if (temporalSQL) {
sql += ` ${temporalSQL}`;
params.push(...temporalParams);
}
sql += ` ORDER BY n.updated_at DESC LIMIT ?`;
params.push(safeLimit);
const rows = query(sql, params);
nodes = rows.map(row => ({
id: row.id,
title: row.title,
source: row.source ?? null,
description: row.description ?? null,
link: row.link ?? null,
dimensions: JSON.parse(row.dimensions_json || '[]'),
created_at: row.created_at,
updated_at: row.updated_at,
event_date: row.event_date ?? null
}));
}
const nodes = nodeService.getNodes({
search: trimmedQuery,
limit: safeLimit,
contextId,
createdAfter: created_after,
createdBefore: created_before,
eventAfter: event_after,
eventBefore: event_before,
});
const summary = nodes.length === 0
? 'No nodes found matching that query.'
@@ -565,7 +369,17 @@ async function main() {
content: [{ type: 'text', text: summary }],
structuredContent: {
count: nodes.length,
nodes
nodes: nodes.map((node) => ({
id: node.id,
title: node.title,
source: node.source ?? null,
description: node.description ?? null,
link: node.link ?? null,
context_id: node.context_id ?? null,
created_at: node.created_at,
updated_at: node.updated_at,
event_date: node.event_date ?? null,
}))
}
};
}
@@ -589,8 +403,8 @@ async function main() {
for (const id of uniqueIds) {
const node = nodeService.getNodeById(id);
if (node) {
const rawChunk = node.source ?? null;
const chunkTruncated = rawChunk ? rawChunk.length > CHUNK_LIMIT : false;
const rawSource = node.source ?? null;
const sourceTruncated = rawSource ? rawSource.length > CHUNK_LIMIT : false;
nodes.push({
id: node.id,
@@ -598,10 +412,9 @@ async function main() {
source: node.source ?? null,
description: node.description ?? null,
link: node.link ?? null,
chunk: chunkTruncated ? rawChunk.substring(0, CHUNK_LIMIT) : rawChunk,
chunk_truncated: chunkTruncated,
chunk_length: rawChunk ? rawChunk.length : 0,
dimensions: node.dimensions || [],
chunk: sourceTruncated ? rawSource.substring(0, CHUNK_LIMIT) : rawSource,
chunk_truncated: sourceTruncated,
chunk_length: rawSource ? rawSource.length : 0,
metadata: node.metadata ?? null,
created_at: node.created_at,
updated_at: node.updated_at,
@@ -624,37 +437,44 @@ async function main() {
'updateNode',
{
title: 'Update RA-H node',
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.',
description: 'Update an existing node. 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". Legacy "content" is mapped to source for compatibility. Title, description, and link are overwritten. Call getNodesById first to verify current state before updating.',
inputSchema: updateNodeInputSchema
},
async ({ id, updates }) => {
if (!updates || Object.keys(updates).length === 0) {
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;
if (Object.prototype.hasOwnProperty.call(mappedUpdates, 'description')) {
mappedUpdates.description = typeof mappedUpdates.description === 'string'
? mappedUpdates.description.trim()
: mappedUpdates.description;
}
if (Object.prototype.hasOwnProperty.call(mappedUpdates, 'context_id')) {
mappedUpdates.context_id = contextService.resolveContextId({ context_id: mappedUpdates.context_id });
}
const node = nodeService.updateNode(id, mappedUpdates);
const message = `Updated node #${id}`;
return {
content: [{ type: 'text', text: `Updated node #${id}` }],
content: [{ type: 'text', text: message }],
structuredContent: {
success: true,
nodeId: node.id,
message: `Updated node #${id}`
message
}
};
}
@@ -738,145 +558,71 @@ async function main() {
}
);
// ========== DIMENSION TOOLS ==========
registerToolWithAliases(
'queryDimensions',
{
title: 'List RA-H dimensions',
description: 'Get all existing canonical dimensions with node counts. Call before creating nodes to assign existing dimensions. Only create a new dimension if the user explicitly instructs you to do so.',
inputSchema: listDimensionsInputSchema
},
async () => {
const dimensions = dimensionService.getDimensions();
return {
content: [{ type: 'text', text: `Found ${dimensions.length} dimension(s).` }],
structuredContent: {
count: dimensions.length,
dimensions
}
};
}
);
// ========== CONTEXT TOOLS ==========
registerToolWithAliases(
'queryContexts',
{
title: 'Query RA-H contexts',
description: 'List contexts, inspect a specific context, or search contexts by name/description.',
title: 'List RA-H contexts',
description: 'List or inspect contexts, the soft organizational layer for the graph. Use this before assigning or filtering by context.',
inputSchema: queryContextsInputSchema
},
async ({ contextId, name, search, limit = 50, includeNodes = false }) => {
const normalizedName = typeof name === 'string' ? name.trim().toLowerCase() : '';
const normalizedName = typeof name === 'string' ? name.trim() : '';
const normalizedSearch = typeof search === 'string' ? search.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];
}
contexts = context ? [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));
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 enriched = contexts.map((context) => {
if (!includeContextNodes) return context;
const structuredContexts = contexts.map((context) => {
if (!includeContextNodes) {
return context;
}
const nodes = nodeService.getNodes({ contextId: context.id, limit: 500 });
return { ...context, nodes };
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,
})),
};
});
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',
{
title: 'Create RA-H dimension',
description: 'Create a new dimension/category only when the user explicitly instructs you to do so. Use lowercase, singular form (e.g. "biology" not "Biology" or "biologies"). Set isPriority=true to lock it for automatic assignment to new nodes. Always include a description.',
inputSchema: createDimensionInputSchema
},
async ({ name, description, isPriority }) => {
const dimension = dimensionService.createDimension({
name,
description,
isPriority
});
const summary = structuredContexts.length === 0
? 'No contexts found.'
: `Found ${structuredContexts.length} context(s).`;
return {
content: [{ type: 'text', text: `Created dimension: ${dimension.dimension}` }],
content: [{ type: 'text', text: summary }],
structuredContent: {
success: true,
dimension: dimension.dimension,
message: `Created dimension: ${dimension.dimension}`
}
};
}
);
registerToolWithAliases(
'updateDimension',
{
title: 'Update RA-H dimension',
description: 'Update or rename a dimension.',
inputSchema: updateDimensionInputSchema
},
async ({ name, newName, description, isPriority }) => {
const result = dimensionService.updateDimension({
name,
currentName: name,
newName,
description,
isPriority
});
return {
content: [{ type: 'text', text: `Updated dimension: ${result.dimension}` }],
structuredContent: {
success: true,
dimension: result.dimension,
message: `Updated dimension: ${result.dimension}`
}
};
}
);
registerToolWithAliases(
'deleteDimension',
{
title: 'Delete RA-H dimension',
description: 'Delete a dimension and remove it from all nodes. WARNING: This is destructive — the dimension will be removed from ALL nodes that use it. Consider checking node counts with queryDimensions first.',
inputSchema: deleteDimensionInputSchema
},
async ({ name }) => {
const result = dimensionService.deleteDimension(name);
return {
content: [{ type: 'text', text: `Deleted dimension: ${name}` }],
structuredContent: {
success: true,
message: `Deleted dimension: ${name}`
}
count: structuredContexts.length,
contexts: structuredContexts,
},
};
}
);
@@ -1096,7 +842,7 @@ async function main() {
'sqliteQuery',
{
title: 'Execute read-only SQL',
description: 'Execute read-only SQL queries against the knowledge graph database. Tables: nodes, edges, dimensions, node_dimensions, chunks. 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("db-operations") for table definitions and query patterns.',
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.',
inputSchema: sqliteQueryInputSchema
},
async ({ sql: userSql, format = 'json' }) => {
@@ -1,204 +0,0 @@
'use strict';
const { query, transaction, getDb } = require('./sqlite-client');
/**
* Get all dimensions with counts.
*/
function getDimensions() {
const sql = `
WITH dimension_counts AS (
SELECT nd.dimension, COUNT(*) AS count
FROM node_dimensions nd
GROUP BY nd.dimension
)
SELECT
d.name AS dimension,
d.description,
d.icon,
d.is_priority AS isPriority,
COALESCE(dc.count, 0) AS count
FROM dimensions d
LEFT JOIN dimension_counts dc ON dc.dimension = d.name
ORDER BY d.is_priority DESC, d.name ASC
`;
const rows = query(sql);
return rows.map(row => ({
dimension: row.dimension,
description: row.description,
icon: row.icon || null,
isPriority: Boolean(row.isPriority),
count: Number(row.count)
}));
}
/**
* Create or update a dimension.
*/
function createDimension(data) {
const { name, description, isPriority = false } = data;
const db = getDb();
if (!name || !name.trim()) {
throw new Error('Dimension name is required');
}
const trimmedName = name.trim();
const stmt = db.prepare(`
INSERT INTO dimensions(name, description, is_priority, updated_at)
VALUES (?, ?, ?, CURRENT_TIMESTAMP)
ON CONFLICT(name) DO UPDATE SET
description = COALESCE(?, description),
is_priority = COALESCE(?, is_priority),
updated_at = CURRENT_TIMESTAMP
RETURNING name, description, is_priority
`);
const rows = stmt.all(
trimmedName,
description ?? null,
isPriority ? 1 : 0,
description ?? null,
isPriority ? 1 : 0
);
if (rows.length === 0) {
throw new Error('Failed to create dimension');
}
return {
dimension: rows[0].name,
description: rows[0].description,
isPriority: Boolean(rows[0].is_priority)
};
}
/**
* Update a dimension.
*/
function updateDimension(data) {
const { name, currentName, newName, description, isPriority } = data;
const db = getDb();
// Handle rename
if (currentName && newName && currentName !== newName) {
// Check if new name already exists
const existing = query('SELECT name FROM dimensions WHERE name = ?', [newName]);
if (existing.length > 0) {
throw new Error('A dimension with this name already exists');
}
transaction(() => {
// Update dimensions table
const dimStmt = db.prepare(`
UPDATE dimensions
SET name = ?, updated_at = CURRENT_TIMESTAMP
WHERE name = ?
`);
const dimResult = dimStmt.run(newName, currentName);
if (dimResult.changes === 0) {
throw new Error('Dimension not found. Use rah_list_dimensions to see all dimensions.');
}
// Update node_dimensions
const nodeDimStmt = db.prepare(`
UPDATE node_dimensions
SET dimension = ?
WHERE dimension = ?
`);
nodeDimStmt.run(newName, currentName);
});
return {
dimension: newName,
previousName: currentName,
renamed: true
};
}
// Handle update (description/isPriority)
const targetName = name || currentName;
if (!targetName) {
throw new Error('Dimension name is required');
}
const updates = [];
const params = [];
if (description !== undefined) {
updates.push('description = ?');
params.push(description);
}
if (isPriority !== undefined) {
updates.push('is_priority = ?');
params.push(isPriority ? 1 : 0);
}
if (updates.length === 0) {
throw new Error('At least one update field must be provided (description, isPriority, or newName).');
}
updates.push('updated_at = CURRENT_TIMESTAMP');
params.push(targetName);
const stmt = db.prepare(`
UPDATE dimensions
SET ${updates.join(', ')}
WHERE name = ?
`);
const result = stmt.run(...params);
if (result.changes === 0) {
throw new Error('Dimension not found. Use rah_list_dimensions to see all dimensions.');
}
return {
dimension: targetName,
description,
isPriority
};
}
/**
* Delete a dimension.
*/
function deleteDimension(name) {
const db = getDb();
if (!name || !name.trim()) {
throw new Error('Dimension name is required');
}
const removal = transaction(() => {
const nodeDimStmt = db.prepare('DELETE FROM node_dimensions WHERE dimension = ?');
const dimStmt = db.prepare('DELETE FROM dimensions WHERE name = ?');
const removedLinks = nodeDimStmt.run(name).changes ?? 0;
const removedRow = dimStmt.run(name).changes ?? 0;
return { removedLinks, removedRow };
});
if (!removal.removedLinks && !removal.removedRow) {
throw new Error('Dimension not found. Use rah_list_dimensions to see all dimensions.');
}
return {
dimension: name,
deleted: true,
removedLinks: removal.removedLinks
};
}
module.exports = {
getDimensions,
createDimension,
updateDimension,
deleteDimension
};
@@ -53,10 +53,8 @@ function buildCanonicalMetadata({ existing, metadata }) {
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,
};
}
@@ -65,13 +63,11 @@ function mapNodeRow(row) {
* Get nodes with optional filtering.
*/
function getNodes(filters = {}) {
const { dimensions, search, limit = 100, offset = 0, contextId } = filters;
const { 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.context_id,
COALESCE((SELECT JSON_GROUP_ARRAY(d.dimension)
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)
@@ -82,16 +78,6 @@ function getNodes(filters = {}) {
`;
const params = [];
// Filter by dimensions
if (dimensions && dimensions.length > 0) {
sql += ` AND EXISTS (
SELECT 1 FROM node_dimensions nd
WHERE nd.node_id = n.id
AND nd.dimension IN (${dimensions.map(() => '?').join(',')})
)`;
params.push(...dimensions);
}
// Text search
if (search) {
sql += ` AND (n.title LIKE ? COLLATE NOCASE OR n.description LIKE ? COLLATE NOCASE OR n.source LIKE ? COLLATE NOCASE)`;
@@ -135,8 +121,6 @@ function getNodeById(id) {
const sql = `
SELECT n.id, n.title, n.description, n.source, n.link, n.event_date, n.metadata,
n.created_at, n.updated_at, n.context_id,
COALESCE((SELECT JSON_GROUP_ARRAY(d.dimension)
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)
@@ -164,134 +148,6 @@ 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.
*/
@@ -302,7 +158,6 @@ function createNode(nodeData) {
source,
link,
event_date,
dimensions = [],
metadata = {},
context_id
} = nodeData;
@@ -314,9 +169,7 @@ function createNode(nodeData) {
const db = getDb();
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 effectiveContextId = context_id ?? null;
const nodeId = transaction(() => {
const stmt = db.prepare(`
@@ -338,16 +191,6 @@ function createNode(nodeData) {
const id = Number(result.lastInsertRowid);
// Insert dimensions
if (dimensions.length > 0) {
const dimStmt = db.prepare(
'INSERT OR IGNORE INTO node_dimensions (node_id, dimension) VALUES (?, ?)'
);
for (const dimension of dimensions) {
dimStmt.run(id, dimension);
}
}
return id;
});
@@ -358,7 +201,7 @@ function createNode(nodeData) {
* Update an existing node.
*/
function updateNode(id, updates, options = {}) {
const { title, description, source, link, event_date, dimensions, metadata } = updates;
const { title, description, source, link, event_date, metadata } = updates;
const now = new Date().toISOString();
const db = getDb();
@@ -415,14 +258,6 @@ function updateNode(id, updates, options = {}) {
stmt.run(...params);
}
// Handle dimensions separately
if (Array.isArray(dimensions)) {
db.prepare('DELETE FROM node_dimensions WHERE node_id = ?').run(id);
const dimStmt = db.prepare('INSERT OR IGNORE INTO node_dimensions (node_id, dimension) VALUES (?, ?)');
for (const dim of dimensions) {
dimStmt.run(id, dim);
}
}
});
return getNodeById(id);
@@ -449,21 +284,15 @@ function getNodeCount() {
/**
* Get knowledge graph context overview.
* Returns stats, contexts, hub nodes, dimensions, and recent activity.
* Returns stats, contexts, hub nodes, and recent activity.
*/
function getContext() {
const nodeCount = query('SELECT COUNT(*) as count FROM nodes')[0].count;
const edgeCount = query('SELECT COUNT(*) as count FROM edges')[0].count;
const dimensionService = require('./dimensionService');
const dimensions = dimensionService.getDimensions();
const recentNodes = query(`
SELECT n.id, n.title, n.description,
GROUP_CONCAT(nd.dimension) as dimensions
SELECT n.id, n.title, n.description
FROM nodes n
LEFT JOIN node_dimensions nd ON n.id = nd.node_id
GROUP BY n.id
ORDER BY n.created_at DESC
LIMIT 5
`);
@@ -478,9 +307,8 @@ function getContext() {
`);
return {
stats: { nodeCount, edgeCount, dimensionCount: dimensions.length, contextCount: contextService.listContexts().length },
stats: { nodeCount, edgeCount, dimensionCount: 0, contextCount: contextService.listContexts().length },
contexts: contextService.listContexts(),
dimensions,
recentNodes,
hubNodes
};
@@ -43,75 +43,6 @@ function initDatabase() {
fs.mkdirSync(path.dirname(dbPath), { recursive: true });
db = new Database(dbPath);
console.error('[RA-H] Creating new database at:', dbPath);
// Create core schema
db.exec(`
CREATE TABLE IF NOT EXISTS nodes (
id INTEGER PRIMARY KEY,
title TEXT,
description TEXT,
source TEXT,
link TEXT,
event_date TEXT,
created_at TEXT,
updated_at TEXT,
metadata TEXT,
embedding BLOB,
embedding_updated_at TEXT,
embedding_text TEXT,
chunk_status TEXT DEFAULT 'not_chunked',
context_id INTEGER,
FOREIGN KEY (context_id) REFERENCES contexts(id) ON DELETE SET NULL
);
CREATE TABLE IF NOT EXISTS 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
);
CREATE INDEX IF NOT EXISTS idx_edges_from ON edges(from_node_id);
CREATE INDEX IF NOT EXISTS idx_edges_to ON edges(to_node_id);
CREATE TABLE IF NOT EXISTS node_dimensions (
node_id INTEGER NOT NULL,
dimension TEXT NOT NULL,
PRIMARY KEY (node_id, dimension),
FOREIGN KEY (node_id) REFERENCES nodes(id) ON DELETE CASCADE
) WITHOUT ROWID;
CREATE INDEX IF NOT EXISTS idx_dim_by_dimension ON node_dimensions(dimension, node_id);
CREATE INDEX IF NOT EXISTS idx_dim_by_node ON node_dimensions(node_id, dimension);
CREATE TABLE IF NOT EXISTS dimensions (
name TEXT PRIMARY KEY,
description TEXT,
icon TEXT,
is_priority INTEGER DEFAULT 0,
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);
INSERT OR IGNORE INTO dimensions (name, is_priority) VALUES ('projects', 1);
INSERT OR IGNORE INTO dimensions (name, is_priority) VALUES ('memory', 1);
INSERT OR IGNORE INTO dimensions (name, is_priority) VALUES ('preferences', 1);
`);
console.error('[RA-H] Database created successfully');
} else {
db = new Database(dbPath);
@@ -123,9 +54,18 @@ function initDatabase() {
db.pragma('cache_size = 5000');
db.pragma('busy_timeout = 5000');
ensureCoreSchema(db);
// Migrations for existing databases
const edgeCols = db.prepare('PRAGMA table_info(edges)').all().map(c => c.name);
const nodeCols = db.prepare('PRAGMA table_info(nodes)').all().map(c => c.name);
if (!nodeCols.includes('source')) {
const contextCols = db.prepare('PRAGMA table_info(contexts)').all().map(c => c.name);
let hasSourceColumn = nodeCols.includes('source');
if (!hasSourceColumn) {
db.exec('ALTER TABLE nodes ADD COLUMN source TEXT;');
hasSourceColumn = true;
console.error('[RA-H] Migrated nodes: added source column');
}
if (nodeCols.includes('content')) {
db.exec(`
@@ -154,22 +94,33 @@ 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;');
if (hasSourceColumn) {
db.exec(`
UPDATE nodes
SET source = title || CASE
WHEN description IS NOT NULL AND LENGTH(TRIM(description)) > 0
THEN char(10) || char(10) || description
ELSE ''
END
WHERE source IS NULL OR LENGTH(TRIM(source)) = 0;
`);
}
if (!edgeCols.includes('explanation')) {
db.exec('ALTER TABLE edges ADD COLUMN explanation TEXT;');
try {
db.exec(`
UPDATE edges SET explanation = json_extract(context, '$.explanation')
WHERE explanation IS NULL AND json_extract(context, '$.explanation') IS NOT NULL;
`);
} catch {}
console.error('[RA-H] Migrated edges: added explanation column');
}
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
);
`);
if (!nodeCols.includes('context_id')) {
db.exec('ALTER TABLE nodes ADD COLUMN context_id INTEGER REFERENCES contexts(id) ON DELETE SET NULL;');
console.error('[RA-H] Migrated nodes: added context_id column');
}
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 '';");
}
@@ -182,30 +133,230 @@ function initDatabase() {
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;
`);
db.exec(`
CREATE UNIQUE INDEX IF NOT EXISTS idx_contexts_name_normalized
ON contexts(LOWER(TRIM(name)));
CREATE INDEX IF NOT EXISTS idx_nodes_context_id ON nodes(context_id);
`);
const edgeCols = db.prepare('PRAGMA table_info(edges)').all().map(c => c.name);
if (!edgeCols.includes('explanation')) {
db.exec('ALTER TABLE edges ADD COLUMN explanation TEXT;');
try {
db.exec(`
UPDATE edges SET explanation = json_extract(context, '$.explanation')
WHERE explanation IS NULL AND json_extract(context, '$.explanation') IS NOT NULL;
`);
} catch {}
console.error('[RA-H] Migrated edges: added explanation column');
db.exec(`
CREATE TABLE IF NOT EXISTS dimension_migration_snapshots (
id INTEGER PRIMARY KEY AUTOINCREMENT,
migrated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
dimension_count INTEGER NOT NULL,
assignment_count INTEGER NOT NULL,
payload TEXT
);
`);
const hasLegacyDimensions = db.prepare("SELECT 1 FROM sqlite_master WHERE type='table' AND name='dimensions'").get();
const hasLegacyNodeDimensions = db.prepare("SELECT 1 FROM sqlite_master WHERE type='table' AND name='node_dimensions'").get();
if (hasLegacyDimensions || hasLegacyNodeDimensions) {
const snapshotCount = Number((db.prepare('SELECT COUNT(*) as count FROM dimension_migration_snapshots').get() || {}).count || 0);
if (snapshotCount === 0) {
const dimensionCount = hasLegacyDimensions
? Number((db.prepare('SELECT COUNT(*) as count FROM dimensions').get() || {}).count || 0)
: 0;
const assignmentCount = hasLegacyNodeDimensions
? Number((db.prepare('SELECT COUNT(*) as count FROM node_dimensions').get() || {}).count || 0)
: 0;
const payload = hasLegacyNodeDimensions
? ((db.prepare(`
SELECT COALESCE(
json_group_array(
json_object(
'node_id', nd.node_id,
'dimension', nd.dimension,
'description', d.description,
'icon', d.icon,
'is_priority', d.is_priority
)
),
'[]'
) AS payload
FROM node_dimensions nd
LEFT JOIN dimensions d ON d.name = nd.dimension
`).get() || {}).payload || '[]')
: '[]';
db.prepare(`
INSERT INTO dimension_migration_snapshots (dimension_count, assignment_count, payload)
VALUES (?, ?, ?)
`).run(dimensionCount, assignmentCount, payload);
}
db.exec(`
DROP INDEX IF EXISTS idx_dim_by_dimension;
DROP INDEX IF EXISTS idx_dim_by_node;
DROP TABLE IF EXISTS node_dimensions;
DROP TABLE IF EXISTS dimensions;
`);
}
return db;
}
function ensureCoreSchema(db) {
db.exec(`
CREATE TABLE IF NOT EXISTS contexts (
id INTEGER PRIMARY KEY,
name TEXT NOT NULL,
description TEXT NOT NULL,
icon TEXT,
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
);
CREATE TABLE IF NOT EXISTS nodes (
id INTEGER PRIMARY KEY,
title TEXT,
description TEXT,
source TEXT,
link TEXT,
event_date TEXT,
created_at TEXT DEFAULT CURRENT_TIMESTAMP,
updated_at TEXT DEFAULT CURRENT_TIMESTAMP,
metadata TEXT,
embedding BLOB,
embedding_updated_at TEXT,
embedding_text TEXT,
chunk_status TEXT DEFAULT 'not_chunked',
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);
}
function ensureEdgesTableSchema(db) {
const hasEdgesTable = db.prepare("SELECT 1 FROM sqlite_master WHERE type='table' AND name='edges'").get();
if (!hasEdgesTable) {
db.exec(`
CREATE TABLE edges (
id INTEGER PRIMARY KEY,
from_node_id INTEGER NOT NULL,
to_node_id INTEGER NOT NULL,
source TEXT,
created_at TEXT DEFAULT CURRENT_TIMESTAMP,
context TEXT,
explanation TEXT,
FOREIGN KEY (from_node_id) REFERENCES nodes(id) ON DELETE CASCADE,
FOREIGN KEY (to_node_id) REFERENCES nodes(id) ON DELETE CASCADE
);
`);
} else {
const edgeColNames = new Set(db.prepare('PRAGMA table_info(edges)').all().map((col) => col.name));
const needsLegacyRewrite =
!edgeColNames.has('from_node_id') ||
!edgeColNames.has('to_node_id') ||
!edgeColNames.has('source') ||
!edgeColNames.has('created_at') ||
!edgeColNames.has('context') ||
edgeColNames.has('from_id') ||
edgeColNames.has('to_id') ||
edgeColNames.has('description') ||
edgeColNames.has('updated_at');
if (needsLegacyRewrite) {
rebuildLegacyEdgesTable(db, edgeColNames);
}
}
db.exec(`
CREATE INDEX IF NOT EXISTS idx_edges_from ON edges(from_node_id);
CREATE INDEX IF NOT EXISTS idx_edges_to ON edges(to_node_id);
`);
}
function rebuildLegacyEdgesTable(db, edgeColNames) {
const fromExpr = edgeColNames.has('from_node_id')
? 'from_node_id'
: edgeColNames.has('from_id')
? 'from_id'
: 'NULL';
const toExpr = edgeColNames.has('to_node_id')
? 'to_node_id'
: edgeColNames.has('to_id')
? 'to_id'
: 'NULL';
const sourceExpr = edgeColNames.has('source') ? 'source' : "'legacy'";
const createdAtExpr = edgeColNames.has('created_at') ? 'created_at' : 'CURRENT_TIMESTAMP';
const contextExpr = edgeColNames.has('context') ? 'context' : 'NULL';
const explanationExpr = edgeColNames.has('explanation')
? 'explanation'
: edgeColNames.has('description')
? 'description'
: edgeColNames.has('context')
? "CASE WHEN json_valid(context) THEN json_extract(context, '$.explanation') ELSE NULL END"
: 'NULL';
console.error('[RA-H] Migrating legacy edges table to canonical schema');
let flippedForeignKeys = false;
try {
db.exec('PRAGMA foreign_keys=OFF;');
flippedForeignKeys = true;
} catch {}
try {
db.exec('BEGIN TRANSACTION;');
db.exec(`
DROP INDEX IF EXISTS idx_edges_from;
DROP INDEX IF EXISTS idx_edges_to;
ALTER TABLE edges RENAME TO edges_legacy_migration;
CREATE TABLE edges (
id INTEGER PRIMARY KEY,
from_node_id INTEGER NOT NULL,
to_node_id INTEGER NOT NULL,
source TEXT,
created_at TEXT DEFAULT CURRENT_TIMESTAMP,
context TEXT,
explanation TEXT,
FOREIGN KEY (from_node_id) REFERENCES nodes(id) ON DELETE CASCADE,
FOREIGN KEY (to_node_id) REFERENCES nodes(id) ON DELETE CASCADE
);
INSERT INTO edges (id, from_node_id, to_node_id, source, created_at, context, explanation)
SELECT
id,
${fromExpr},
${toExpr},
${sourceExpr},
COALESCE(${createdAtExpr}, CURRENT_TIMESTAMP),
${contextExpr},
${explanationExpr}
FROM edges_legacy_migration
WHERE ${fromExpr} IS NOT NULL
AND ${toExpr} IS NOT NULL;
DROP TABLE edges_legacy_migration;
COMMIT;
`);
} catch (error) {
try {
db.exec('ROLLBACK;');
} catch {}
throw error;
} finally {
if (flippedForeignKeys) {
try {
db.exec('PRAGMA foreign_keys=ON;');
} catch {}
}
}
}
/**
* Get the database instance.
* Throws if not initialized.
+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.
2. Edge quality: missing links, weak explanations, wrong directionality.
3. Dimension quality: drift, overlap, low-signal categories.
3. Context quality: drift, overlap, low-signal buckets, or contexts being overused where stronger node metadata should carry the meaning.
4. Skill quality: trigger clarity, overlap, dead/unused skills.
## Output Format
@@ -1,6 +1,6 @@
---
name: DB Operations
description: "Use this for all graph read/write operations with strict data quality standards."
description: "Use for graph read, write, connect, classify, or traverse operations with strict data quality standards."
---
# DB Operations
@@ -8,30 +8,72 @@ description: "Use this for all graph read/write operations with strict data qual
## Core Rules
1. Search before create to avoid duplicates.
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.
2. Always try to include a natural description that clearly says 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.
4. Apply contexts only when they are explicit and helpful. One node gets at most one context. If explicit context is missing on create, leave it empty instead of guessing.
5. Do not rely on dimensions. Node quality comes from title, description, source, metadata, and strong edges.
5. Create edges when relationships are meaningful; edge explanations should read as a sentence.
6. For user-authored ideas, notes, or dictated thoughts, preserve the user's wording in `source` as fully as possible with only minimal cleanup.
## Write Quality Contract
- `title`: clear and specific.
- `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.
- `description`: concrete object-level description, not vague summaries.
- `source`: full verbatim or canonical content of the node (transcript, article text, book passage, user's thoughts). This is what gets chunked and embedded for semantic search.
- For idea capture from chat, the `source` should usually be the raw user thought, not a compressed assistant summary.
- `link`: external source URL only.
- `metadata`: prefer canonical keys `type`, `state`, `captured_method`, `captured_by`, and `source_metadata`.
- `context_id`: the node's primary context. Prefer setting it when the scope is explicit. Leave it null rather than guessing.
- `metadata`: use the canonical node metadata contract when metadata is needed:
- `type`
- `state` (`processed` or `not_processed`)
- `captured_method`
- `captured_by`
- `source_metadata`
- `source_metadata`: factual source-specific details only. Keep it compact. No AI summaries or reasoning text.
- metadata updates are merge-safe patches, not full-blob replacements. Do not assume `updateNode.metadata` wipes existing keys.
- Derived analysis, briefs, and research notes should be stored in a separate linked node, not appended to the source node.
## Description Standard
Every node description should read like natural prose, not a template or checklist.
It must still make three things clear:
1. What — what the artifact is in simple explicit terms (format + creator + core claim)
2. Why — why it is in the graph; what Brad is interested in; what it connects to
3. Status — where it sits in his workflow (queued, in progress, processed, unknown)
If the agent has graph context (active context, context anchor, context capsule, focused nodes), it should infer the why from that context and write it naturally.
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 status is unknown, say naturally that it has not been reviewed yet.
Ask a clarification question only when a missing detail would materially change the node being created. If the user has already given enough substance to infer the artifact, title, and likely why, do the work instead of bouncing it back.
For user-authored idea capture, do not treat the inferred description as final if the "why" or status was mostly inferred. Save the node first, then tell the user what description framing you inferred and invite one short correction pass on:
- what this is
- why it belongs here
- where it sits in their workflow
Keep it concise, but do not block the write over length or quality.
## Metadata Semantics
- Direct user creation, quick add, and user-requested agent capture should default to `captured_by = "human"`.
- Only autonomous/background creation without direct user instruction should use `captured_by = "agent"`.
- Prefer leaving `type` blank over forcing a weak label.
- `state` is the user-visible processed flag. If no state is known, default to `not_processed`.
## Execution Pattern
1. Read context (search + relevant nodes + relevant edges).
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.
4. If the node is a user-authored idea and the contextual framing was inferred, offer one concise feedback pass after the write.
5. Verify result reflects user intent exactly.
## 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.
@@ -1,6 +1,6 @@
---
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."
description: "Use to rewrite thin node descriptions into natural prose that still makes what, why, and status clear, with context review and edge suggestions."
---
# Node Context Enrichment
@@ -17,15 +17,21 @@ Replace weak descriptions with a single clean natural description that captures:
2. Why it is in Brad's graph
3. Status in Brad's workflow
Also review whether the node needs dimension fixes or obvious edge suggestions.
Also review whether the node needs context cleanup 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.
1. Load the node and inspect title, description, source, link, metadata, context, and nearby edges.
2. Search for adjacent context before rewriting:
- the node's primary context and its anchor node when present
- recently connected project or belief nodes
- related nodes with overlapping titles, creators, or neighboring context
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.
5. Review context fit:
- keep the current context when it is clearly the primary scope
- suggest clearing context when it is weak or misleading
- suggest a different context only when the primary scope is explicit
6. Suggest 1-3 high-signal edges when obvious.
7. Update the node once the description is strong enough to be useful.
8. After the update, tell the user what changed and ask whether they want to refine the important framing:
@@ -34,3 +40,52 @@ Also review whether the node needs dimension fixes or obvious edge suggestions.
- 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.
## Description Standard
Every rewritten description must naturally cover:
1. What
- explicit artifact type
- creator/author/speaker when known
- core subject, claim, or function
2. Why
- why Brad saved it
- what project, belief, question, or theme it connects to
- if genuinely unknown, say that naturally without inventing context
3. Status
- queued, in progress, processed, not yet reviewed, saved for later, etc.
- if unknown, say naturally that it has not been reviewed yet
Max 500 characters.
## Batch Mode
Use batch enrichment when cleaning up many nodes with the same failure mode.
1. Pull a tight node set first.
2. Group by pattern:
- vague imported links
- thin quick-add captures
- old source nodes missing workflow state
3. Enrich each node individually. Do not reuse boilerplate "why" text across unrelated nodes.
4. Return a compact summary of:
- nodes updated
- context assignments to review
- edge suggestions not yet created
## Quality Bar
- No filler phrases like `insightful for understanding`, `relevant to`, or `important for`.
- No generic summaries that only restate the topic.
- No invented certainty. If context is weak, say so explicitly.
- Prefer one compact 3-sentence description over bloated prose.
## Output Pattern
For each node:
- New description
- Context change: keep / change / clear
- Edge suggestions: source -> target with explicit explanation
- One short invitation for user feedback when contextual framing was inferred
+152 -21
View File
@@ -1,35 +1,166 @@
---
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 context structure, anchor candidates, and clear next steps for graph growth."
description: "Use for new-user setup, empty or near-empty graphs, or major resets to map goals, projects, worldview, and preferences into an initial graph."
---
# Onboarding
## Goal
## Your Job
Understand the user deeply enough to bootstrap a useful externalized context graph.
Three things: help the user understand the basic structure of the system, help them start building useful context in it, and bootstrap the context capsule when durable cross-session facts become clear.
Adapt to the user.
- If they already know what they want to add, help them add it.
- If they want guidance, guide them with simple prompts.
- Do not force a rigid interview if they are already giving you usable context.
## Start With Orientation, Not Setup Friction
For signed-in cloud/mac users, do not start by asking whether the app is open or whether they added API keys. The app is already open, and billing-backed cloud usage does not require the old local setup checklist.
Start with product orientation and goal discovery first.
Only bring up setup details if the user actually needs them:
1. If they are on local/BYO-key mode, point them to Settings → API Keys.
2. If they ask about the database location, tell them the default macOS path is `~/Library/Application Support/RA-H/db/rah.sqlite`.
3. If API keys are relevant, explain them plainly:
- **OpenAI** — powers embeddings, semantic retrieval, and extraction-related AI work.
- **Anthropic** — mainly relevant for compatible runtime paths and local/dev setups.
4. If they are not ready to configure anything yet, keep onboarding. They can still learn the structure and add manual content.
## Capsule Bootstrap
As part of onboarding, also bootstrap the context capsule on the user's behalf.
When the user gives durable identity, preference, worldview, project, or agent-behavior information:
1. Call `readSkill('context-capsule')`
2. Call `readCapsule`
3. When you have enough confidence, call `writeCapsule`
Use the capsule for:
- preferred name
- agent name or greeting preference
- interaction style
- major projects
- major interests
- explicit worldview or belief signals
- what the user is using RA-H for
Do not write the capsule for tentative, one-off, or low-confidence statements.
Keep it compressed and canonical. Replace stale instructions instead of preserving contradictory history.
## Explain the System First
Before asking anything, orient the user. Be direct, not salesy:
> "RA-H is a context system built on a simple graph. The goal is to build context that persists and gets more useful over time."
Explain the structure in simple terms:
- **Contexts** — an optional soft organization layer. These are broad areas like health, job, life, or research. A node can belong to one context when it is explicit and useful, but context is not required.
- **Nodes** — individual things. A project, idea, person, source, belief, decision, or topic. Each node must have a clear description of what it is and why it matters.
- **Edges** — explicit connections between things. Each edge must clearly explain the relationship.
- **Metadata and edges** — secondary structure that makes nodes more useful once the core artifact is clear.
Then say:
> "If you know specifically how you'd like to create your context corpus, feel free to tell me what you'd like to add and I can help you set things up. Otherwise, I can guide you through bootstrapping your context with a few suggested prompts."
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."
## Interview Flow
1. Clarify outcomes: goals for this system and success horizon.
2. Map active projects: responsibilities, timelines, decision pressure.
3. Capture worldview: beliefs, principles, working assumptions, decision criteria.
4. Capture identity/context anchors: roles, domains, recurring themes.
5. Capture interaction preferences: style, rigor, speed vs depth, tone.
Keep it conversational. Use these buckets and adapt based on what the user gives you.
## Graph Bootstrap
**1. Projects and active work**
- What are you working on right now?
- What projects, responsibilities, or decisions should be part of your context?
- What keeps coming up enough that it should probably live in the graph?
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.
**2. Goals, motivations, beliefs, world models**
- What are you trying to achieve?
- What motivations, principles, or beliefs shape how you work and make decisions?
- Are there any mental models or recurring ways you think about things that should be captured?
## Output
**3. Learning, exploration, and research**
- What are you reading, watching, listening to, or researching lately?
- Any podcasts, articles, papers, books, or rabbit holes that matter right now?
- Are there specific people, thinkers, or sources you follow closely?
- Initial context map
- Suggested first write actions
- Suggested weekly maintenance rhythm
**4. Interaction style and preferences**
- How do you want me to work with you?
- Do you want concise answers, deeper exploration, pushback, or straightforward execution?
## First-Run Teaching Points
Work these in naturally when they are relevant:
- **First node creation** — explain that a node is one concrete thing worth keeping: a project, source, person, belief, decision, or idea.
- **Contexts** — explain that contexts are the primary folders or scopes for the graph.
- **MCP connection** — if the user mentions Claude Code or external agents, offer a quick setup path and point them to the MCP docs/skill flow rather than reciting a giant config block immediately.
- **What to do after setup** — once the graph has a few solid nodes, the next useful move is usually one of:
- connect related nodes with explicit edges
- ingest a source they care about
- add one or two skills/preferences so future conversations stay grounded
## How to Work
Do your best to build the graph as useful context emerges.
- Add nodes when the user mentions concrete things worth keeping.
- Assign a primary context when one is clear. Prefer leaving context empty over low-confidence guessing.
- Add edges when relationships are clear enough to explain well.
- Explain what you're adding in plain language so the user understands the structure as it develops.
When the graph is empty or nearly empty, bias toward creating a small, clean starter set rather than over-modeling everything.
## Write Standards
Before writing anything, call `readSkill('db-operations')` for full quality standards. Key points that matter most here:
- Search before creating — avoid duplicates from day one
- Every description must be concrete: what it IS and why it matters to them, not what it "explores" or "discusses"
- Contexts should hold the primary scope when clear, otherwise leave them empty
- Every edge needs an explicit explanation sentence
## Propose Before Writing
When there is enough context, summarize the proposed structure before touching the database:
> "Here's what I'm planning to create: [list contexts with one-line rationale], [list starter nodes], [list key edges]. Does this look right? Anything to adjust?"
Write only after confirmation.
If onboarding also surfaced durable cross-session facts, include the capsule update in the proposal:
> "I also plan to bootstrap your context capsule with your name, interaction preferences, and current priorities so future chats start grounded. Sound right?"
> "I also plan to bootstrap your context capsule with your name and interaction preferences so future chats start grounded. Sound right?"
For very early setup, include the first actionable next step too:
> "After this starter pass, the best next move will be [add a source / connect these nodes / capture another active project]."
## Completion
After writing, give a brief recap:
- What was created
- How the structure works
- What would be useful to add next
If setup is still incomplete, end with the smallest next action, for example:
- add OpenAI in Settings
- connect Claude Code via MCP
- add one more source node
## Do Not
- Create meta-nodes like "User Profile", "Preferences", or "Goals" — the graph IS the profile
- Write anything before proposing the structure and getting confirmation
- Skip the interview and go straight to writing
- Write vague descriptions ("is about", "explores", "discusses", "touches on")
- Ask one disconnected question at a time when a natural multi-part thread is cleaner