Merge branch 'codex/os-shared-skills-sync'

This commit is contained in:
“BeeRad”
2026-04-17 20:58:59 +10:00
64 changed files with 1924 additions and 1902 deletions
+2 -3
View File
@@ -37,9 +37,9 @@ npm run build
- `README.md` - product overview + quick start
- `docs/README.md` - docs index
- `docs/4_tools-and-guides.md` - MCP tools + guides
- `docs/4_tools-and-guides.md` - MCP tools + skills surface
- `docs/6_ui.md` - UI behavior
- `docs/8_mcp.md` - MCP setup and troubleshooting
- `docs/8_mcp.md` - MCP setup, troubleshooting, and memory-file guidance
- `docs/development/process.md` - contributor process
- `docs/development/docs-process.md` - docs maintenance process
@@ -48,4 +48,3 @@ npm run build
- `ra-h_os` accepts direct contributions.
- Maintainers may sync relevant changes between public and private repos.
- Public contributions should remain attributable and not be overwritten.
+2 -2
View File
@@ -25,7 +25,7 @@ npm run dev
## Key Paths
- `src/services/database/` - data layer
- `src/tools/` - MCP tool implementations
- `src/config/guides/` - built-in guide content
- `src/config/skills/` - built-in skill content
- `app/api/` - API routes
## Docs
@@ -34,4 +34,4 @@ npm run dev
- `docs/2_schema.md`
- `docs/4_tools-and-guides.md`
- `docs/6_ui.md`
- `docs/8_mcp.md`
- `docs/8_mcp.md` - includes MCP setup plus recommended memory-file guidance
+15 -1
View File
@@ -132,11 +132,25 @@ If you publish a newer MCP release and want clients to use it immediately, bump
**What happens:** Once connected, the agent should use `queryNodes` for specific existing-node lookup, `retrieveQueryContext` when broader graph grounding would help, and `getContext` only for orientation. It should search before creating, propose durable writeback selectively instead of pestering, and treat the graph itself as the source of grounding rather than a separate contexts layer. The MCP server stores source on the node. The app later turns that source into chunks and embeddings.
**Recommended memory file:** If you use Claude Code or another coding agent, add one short repo-level memory file (`AGENTS.md` or `CLAUDE.md`) that reinforces the core graph behavior. Keep it simple and do not maintain conflicting versions across multiple files.
Suggested snippet:
```md
You are helping build a thoughtful graph of atomic units of context.
- Use `queryNodes` for direct lookup of a specific existing node.
- Use `retrieveQueryContext` when broader graph context would help with the current turn.
- Search before creating. Prefer updating the same artifact when it is clearly the same thing.
- `description` should state plainly what the thing is first, then why it belongs and current status.
- Preserve the user's wording in `source` for user-authored ideas unless they explicitly want a rewrite.
```
Available tools:
| Tool | What it does |
|------|--------------|
| `getContext` | Get graph overview — stats, hub nodes, guides, and orientation signals |
| `getContext` | Get graph overview — stats, hub nodes, skills, and orientation signals |
| `retrieveQueryContext` | Pull relevant graph context for a broader current-turn task |
| `queryNodes` | Find nodes by keyword |
| `createNode` | Create a new node |
-50
View File
@@ -1,50 +0,0 @@
import { NextRequest, NextResponse } from 'next/server';
import { readGuide, deleteGuide } from '@/services/guides/guideService';
export const runtime = 'nodejs';
export async function GET(
_request: NextRequest,
{ params }: { params: Promise<{ name: string }> }
) {
try {
const { name } = await params;
const guide = readGuide(name);
if (!guide) {
return NextResponse.json(
{ success: false, error: `Guide "${name}" not found` },
{ status: 404 }
);
}
return NextResponse.json({ success: true, data: guide });
} catch (error) {
console.error('[API /guides/[name]] error:', error);
return NextResponse.json(
{ success: false, error: error instanceof Error ? error.message : 'Failed to read guide' },
{ status: 500 }
);
}
}
export async function DELETE(
_request: NextRequest,
{ params }: { params: Promise<{ name: string }> }
) {
try {
const { name } = await params;
const result = deleteGuide(name);
if (!result.success) {
return NextResponse.json(
{ success: false, error: result.error },
{ status: 400 }
);
}
return NextResponse.json({ success: true, message: `Guide "${name}" deleted` });
} catch (error) {
console.error('[API /guides/[name] DELETE] error:', error);
return NextResponse.json(
{ success: false, error: error instanceof Error ? error.message : 'Failed to delete guide' },
{ status: 500 }
);
}
}
-17
View File
@@ -1,17 +0,0 @@
import { NextResponse } from 'next/server';
import { listGuides } from '@/services/guides/guideService';
export const runtime = 'nodejs';
export async function GET() {
try {
const guides = listGuides();
return NextResponse.json({ success: true, data: guides });
} catch (error) {
console.error('[API /guides] error:', error);
return NextResponse.json(
{ success: false, error: error instanceof Error ? error.message : 'Failed to list guides' },
{ status: 500 }
);
}
}
+107 -11
View File
@@ -13,11 +13,15 @@ const SERVER_INFO = {
const instructions = [
'RA-H is a personal knowledge graph — local-first, vendor-neutral.',
'Core concepts: nodes (knowledge units) and edges (connections with explanations).',
'The graph is the default working memory for substantive turns.',
'Core concepts: nodes (knowledge units), edges (connections with explanations), and shared editable skills.',
'If the user is trying to find a specific existing node, use rah_search_nodes first.',
'If the user is asking a broader question or request that would benefit from graph context, use rah_retrieve_query_context.',
'If graph context would help with a broader task, use rah_retrieve_query_context before answering.',
'Use rah_get_context only for orientation when high-level graph state would actually help.',
'Search before creating: use rah_search_nodes to check if content already exists.',
'Do not keep re-running retrieval if you already have enough relevant graph context in play.',
'Search before creating, and prefer rah_update_node when the artifact is clearly the same thing.',
'Use rah_list_skills and rah_read_skill for non-trivial workflows that need operating doctrine. Use rah_write_skill and rah_delete_skill when the user explicitly wants to change that shared skill set.',
'Only suggest saving durable knowledge when it is unusually valuable. Keep the ask brief, for example: Add "X" as a node?',
'Every edge needs an explanation: why does this connection exist?',
'Never create or update an edge unless the user has explicitly confirmed the relationship.',
].join(' ');
@@ -182,9 +186,10 @@ function createServer(request: NextRequest): McpServer {
link: z.string().optional(),
metadata: z.record(z.any()).optional(),
}),
source_update_basis: z.string().optional(),
},
},
async ({ id, updates }) => {
async ({ id, updates, source_update_basis }) => {
if (!updates || Object.keys(updates).length === 0) {
throw new Error('At least one field must be provided in updates.');
}
@@ -201,7 +206,7 @@ function createServer(request: NextRequest): McpServer {
const payload = await callRaHApi(request, `/api/nodes/${id}`, {
method: 'PUT',
body: JSON.stringify(mappedUpdates),
body: JSON.stringify({ ...mappedUpdates, source_update_basis }),
});
return {
@@ -364,6 +369,97 @@ function createServer(request: NextRequest): McpServer {
}
);
server.registerTool(
'rah_list_skills',
{
title: 'List RA-H skills',
description: 'List the shared skills available to internal and external RA-H agents. Use this to see the current operating doctrine before reading or editing a specific skill.',
inputSchema: {},
},
async () => {
const result = await callRaHApi(request, '/api/skills', { method: 'GET' });
const skills = Array.isArray(result.data) ? result.data : [];
return {
content: [{ type: 'text', text: `Found ${skills.length} skill(s).` }],
structuredContent: {
count: skills.length,
skills,
},
};
}
);
server.registerTool(
'rah_read_skill',
{
title: 'Read RA-H skill',
description: 'Read one shared RA-H skill by name. Use this before executing a non-trivial workflow that matches the skill trigger.',
inputSchema: {
name: z.string().min(1),
},
},
async ({ name }) => {
const result = await callRaHApi(request, `/api/skills/${encodeURIComponent(name)}`, { method: 'GET' });
return {
content: [{ type: 'text', text: result.data.content }],
structuredContent: result.data,
};
}
);
server.registerTool(
'rah_write_skill',
{
title: 'Write RA-H skill',
description: 'Create or update a shared RA-H skill when the user explicitly wants to change the doctrine surface. Content should be the full markdown body for that skill.',
inputSchema: {
name: z.string().min(1),
content: z.string().min(1),
},
},
async ({ name, content }) => {
const result = await callRaHApi(request, '/api/skills', {
method: 'POST',
body: JSON.stringify({ name, content }),
});
return {
content: [{ type: 'text', text: `Skill "${name}" saved.` }],
structuredContent: {
success: true,
name,
message: result.message || `Skill "${name}" saved.`,
},
};
}
);
server.registerTool(
'rah_delete_skill',
{
title: 'Delete RA-H skill',
description: 'Delete a shared RA-H skill when the user explicitly wants it removed from the shared skill set.',
inputSchema: {
name: z.string().min(1),
},
},
async ({ name }) => {
const result = await callRaHApi(request, `/api/skills/${encodeURIComponent(name)}`, {
method: 'DELETE',
});
return {
content: [{ type: 'text', text: `Skill "${name}" deleted.` }],
structuredContent: {
success: true,
name,
message: result.message || `Skill "${name}" deleted.`,
},
};
}
);
server.registerTool(
'rah_search_embeddings',
{
@@ -483,13 +579,13 @@ function createServer(request: NextRequest): McpServer {
'rah_get_context',
{
title: 'Get RA-H context',
description: 'Get orientation context: high-level graph state, hub nodes, stats, and available guides.',
description: 'Get orientation context: high-level graph state, hub nodes, stats, and available skills. Use this for orientation only, not as the default retrieval path for substantive requests.',
inputSchema: {},
},
async () => {
const [hubPayload, guidesPayload, countPayload, edgesPayload] = await Promise.all([
const [hubPayload, skillsPayload, countPayload, edgesPayload] = await Promise.all([
callRaHApi(request, '/api/nodes?sortBy=edges&limit=10'),
callRaHApi(request, '/api/guides').catch(() => ({ data: [] })),
callRaHApi(request, '/api/skills').catch(() => ({ data: [] })),
callRaHApi(request, '/api/nodes?limit=1').catch(() => ({ total: 0, count: 0 })),
callRaHApi(request, '/api/edges?limit=1').catch(() => ({ count: 0, total: 0 })),
]);
@@ -501,19 +597,19 @@ function createServer(request: NextRequest): McpServer {
edgeCount: node.edge_count ?? 0,
})) : [];
const guides = Array.isArray(guidesPayload.data) ? guidesPayload.data.map((guide: any) => guide.name) : [];
const skills = Array.isArray(skillsPayload.data) ? skillsPayload.data : [];
const nodeCount = countPayload.total ?? countPayload.count ?? 0;
const edgeCount = edgesPayload.total ?? edgesPayload.count ?? 0;
return {
content: [{ type: 'text', text: `Knowledge graph: ${nodeCount} nodes, ${edgeCount} edges, ${guides.length} guides available.` }],
content: [{ type: 'text', text: `Knowledge graph: ${nodeCount} nodes, ${edgeCount} edges, ${skills.length} skills available.` }],
structuredContent: {
stats: {
nodeCount,
edgeCount,
},
hubNodes,
guides,
skills,
},
};
}
+1 -48
View File
@@ -1,5 +1,5 @@
import { NextRequest, NextResponse } from 'next/server';
import { readSkill, writeSkill, deleteSkill } from '@/services/skills/skillService';
import { readSkill, deleteSkill } from '@/services/skills/skillService';
export const runtime = 'nodejs';
@@ -50,50 +50,3 @@ export async function DELETE(
);
}
}
export async function PUT(
request: NextRequest,
{ params }: { params: Promise<{ name: string }> }
) {
try {
const { name } = await params;
const body = await request.json();
const description = typeof body?.description === 'string' ? body.description.trim() : '';
const content = typeof body?.content === 'string' ? body.content : '';
const safeName = name.trim();
if (!safeName) {
return NextResponse.json(
{ success: false, error: 'Skill name is required' },
{ status: 400 }
);
}
const safeDescription = description.replace(/\r?\n/g, ' ').trim();
const frontmatter = [
'---',
`name: ${safeName}`,
`description: ${safeDescription}`,
'---',
'',
content.trim(),
'',
].join('\n');
const result = writeSkill(safeName, frontmatter);
if (!result.success) {
return NextResponse.json(
{ success: false, error: result.error },
{ status: 400 }
);
}
return NextResponse.json({ success: true, message: `Skill "${safeName}" saved` });
} catch (error) {
console.error('[API /skills/[name] PUT] error:', error);
return NextResponse.json(
{ success: false, error: error instanceof Error ? error.message : 'Failed to save skill' },
{ status: 500 }
);
}
}
+43 -2
View File
@@ -1,5 +1,5 @@
import { NextResponse } from 'next/server';
import { listSkills } from '@/services/skills/skillService';
import { NextRequest, NextResponse } from 'next/server';
import { listSkills, writeSkill } from '@/services/skills/skillService';
export const runtime = 'nodejs';
@@ -15,3 +15,44 @@ export async function GET() {
);
}
}
export async function POST(request: NextRequest) {
try {
const body = await request.json();
const name = typeof body?.name === 'string' ? body.name.trim() : '';
const content = typeof body?.content === 'string' ? body.content : '';
if (!name) {
return NextResponse.json(
{ success: false, error: 'Skill name is required' },
{ status: 400 }
);
}
if (!content.trim()) {
return NextResponse.json(
{ success: false, error: 'Skill content is required' },
{ status: 400 }
);
}
const result = writeSkill(name, content);
if (!result.success) {
return NextResponse.json(
{ success: false, error: result.error || 'Failed to write skill' },
{ status: 400 }
);
}
return NextResponse.json({
success: true,
message: `Skill "${name}" saved`,
});
} catch (error) {
console.error('[API /skills POST] error:', error);
return NextResponse.json(
{ success: false, error: error instanceof Error ? error.message : 'Failed to write skill' },
{ status: 500 }
);
}
}
+18 -17
View File
@@ -44,7 +44,11 @@ const serverInfo = {
function buildInstructions() {
const now = new Date().toISOString().split('T')[0];
let skillIndex = '- db-operations: Core graph read/write operating policy.';
let skillIndex = [
'- onboarding: Bootstrap a useful starter graph quickly and with low friction.',
'- create-skill: Create or rewrite a reusable skill when a workflow is repeatable.',
'- refine: Clean up or sharpen one node or a small set of nodes before writing changes.'
].join('\n');
try {
const skills = skillService.listSkills();
@@ -61,11 +65,12 @@ function buildInstructions() {
## Quick start
1. If the user is trying to find a specific existing node, call queryNodes first.
2. If graph context would help with a broader task, call retrieveQueryContext.
2. If graph context would help with a broader task, call retrieveQueryContext before answering.
3. Call getContext only when orientation about the overall graph would actually help.
4. Do not keep re-running retrieval if you already have enough relevant graph context in play.
5. For simple tasks, tool descriptions have everything you need.
6. For complex tasks, call readSkill("db-operations").
5. Search before creating, and prefer updateNode when the artifact is clearly the same thing.
6. For simple tasks, tool descriptions should be enough.
7. For non-trivial workflows or policy detail, call readSkill on the matching skill.
## Knowledge capture
Only suggest saving durable knowledge when it seems unusually durable and valuable.
@@ -142,7 +147,7 @@ const queryEdgesInputSchema = {
};
const readSkillInputSchema = {
name: z.string().min(1).describe('Skill name (e.g. "db-operations", "onboarding", "persona")')
name: z.string().min(1).describe('Skill name (e.g. "onboarding", "create-skill", "refine")')
};
const writeSkillInputSchema = {
@@ -246,14 +251,13 @@ async function main() {
'getContext',
{
title: 'Get RA-H context',
description: 'Get knowledge graph overview: stats, hub nodes, recent activity, and available skills. Use this for orientation only, not as the default retrieval path for substantive requests. For deeper operating policy, follow up with readSkill("db-operations").',
description: 'Get knowledge graph overview: stats, hub nodes, recent activity, and available skills. Use this for orientation only, not as the default retrieval path for substantive requests.',
inputSchema: {}
},
async () => {
const context = nodeService.getContext();
const skills = skillService.listSkills();
context.skills = skills.map(s => ({ name: s.name, description: s.description, immutable: s.immutable }));
context.guides = context.skills;
// First-run welcome message
if (context.stats.nodeCount === 0) {
@@ -267,7 +271,7 @@ async function main() {
};
}
const summary = `Graph: ${context.stats.nodeCount} nodes, ${context.stats.edgeCount} edges, ${skills.length} skills.`;
const summary = `Graph: ${context.stats.nodeCount} nodes, ${context.stats.edgeCount} edges, ${context.hubNodes.length} hub nodes, ${skills.length} skills.`;
return {
content: [{ type: 'text', text: summary }],
structuredContent: context
@@ -553,15 +557,13 @@ async function main() {
}
);
// ========== DIMENSION TOOLS ==========
// ========== SKILL TOOLS ==========
registerToolWithAliases(
'listSkills',
{
title: 'List RA-H skills',
description: 'List all skills available in this workspace. Read "db-operations" first for core graph operating policy.',
description: 'List the shared skills available to internal and external RA-H agents. Use this to see the current operating doctrine before reading or editing a specific skill.',
inputSchema: {}
},
async () => {
@@ -571,8 +573,7 @@ async function main() {
content: [{ type: 'text', text: `Found ${skills.length} skill(s).` }],
structuredContent: {
count: skills.length,
skills,
guides: skills
skills
}
};
}
@@ -582,7 +583,7 @@ async function main() {
'readSkill',
{
title: 'Read RA-H skill',
description: 'Read a skill by name. Returns full markdown with procedural instructions. Read "db-operations" for core policy. Call listSkills to see all available skills.',
description: 'Read one shared RA-H skill by name. Use this before executing a non-trivial workflow that matches the skill trigger.',
inputSchema: readSkillInputSchema
},
async ({ name }) => {
@@ -603,7 +604,7 @@ async function main() {
'writeSkill',
{
title: 'Write RA-H skill',
description: 'Create or update a skill. Content should be markdown with YAML frontmatter (name, description).',
description: 'Create or update a shared RA-H skill when the user explicitly wants to change the doctrine surface. Content should be the full markdown body for that skill.',
inputSchema: writeSkillInputSchema
},
async ({ name, content }) => {
@@ -628,7 +629,7 @@ async function main() {
'deleteSkill',
{
title: 'Delete RA-H skill',
description: 'Delete a skill.',
description: 'Delete a shared RA-H skill when the user explicitly wants it removed from the shared skill set.',
inputSchema: deleteSkillInputSchema
},
async ({ name }) => {
@@ -770,7 +771,7 @@ async function main() {
'sqliteQuery',
{
title: 'Execute read-only SQL',
description: 'Execute read-only SQL queries against the knowledge graph database. Tables: nodes, edges, chunks, chats, voice_usage, logs, and migration snapshots. Use PRAGMA table_info(tablename) for schema. Only SELECT/WITH/PRAGMA allowed. Use when structured tools are insufficient — e.g., complex JOINs, aggregations, or custom filtering. Read readSkill("schema") for table definitions and query patterns.',
description: 'Execute read-only SQL queries against the knowledge graph database. Tables include nodes, 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 -1
View File
@@ -10,7 +10,7 @@
"start": "node index.js",
"validate:syntax": "node --check index.js && node --check services/*.js",
"validate:publish": "npm run validate:syntax && npm pack --dry-run >/dev/null",
"prepublishOnly": "npm run validate:publish"
"prepublishOnly": "node ../../scripts/dev/sync-skills.mjs --check && npm run validate:publish"
},
"keywords": [
"mcp",
@@ -1,10 +0,0 @@
'use strict';
const skillService = require('./skillService');
module.exports = {
listGuides: skillService.listSkills,
readGuide: skillService.readSkill,
writeGuide: skillService.writeSkill,
deleteGuide: skillService.deleteSkill,
};
@@ -2,6 +2,108 @@
const { query, transaction, getDb } = require('./sqlite-client');
function sanitizeFtsQuery(input) {
return String(input || '')
.toLowerCase()
.replace(/[^a-z0-9\s]+/g, ' ')
.trim()
.split(/\s+/)
.filter(word => word.length > 0 && !/^(AND|OR|NOT|NEAR)$/i.test(word))
.join(' ');
}
function extractRelaxedSearchTerms(queryText) {
const stopWords = new Set([
'a', 'an', 'and', 'are', 'as', 'at', 'be', 'by', 'can', 'do', 'find',
'for', 'from', 'hello', 'i', 'in', 'is', 'it', 'me', 'my', 'of', 'on',
'or', 'recent', 'stuff', 'term', 'that', 'the', 'this', 'to', 'with', 'you'
]);
const rawTerms = String(queryText || '')
.toLowerCase()
.replace(/[^a-z0-9\s]+/g, ' ')
.split(/\s+/)
.map(term => term.trim())
.filter(Boolean);
const expanded = new Set();
for (const term of rawTerms) {
if (!stopWords.has(term) && term.length >= 3) {
expanded.add(term);
}
const alphaParts = term.replace(/\d+/g, ' ').split(/\s+/).filter(Boolean);
for (const part of alphaParts) {
if (!stopWords.has(part) && part.length >= 3) {
expanded.add(part);
}
}
}
return Array.from(expanded).slice(0, 8);
}
function normalizeSearchText(value) {
return String(value || '')
.toLowerCase()
.replace(/[^a-z0-9]+/g, ' ')
.replace(/\s+/g, ' ')
.trim();
}
function getQueryTerms(queryText) {
return normalizeSearchText(queryText).split(' ').filter(term => term.length > 0);
}
function countOccurrences(text, term) {
if (!text || !term) return 0;
const matches = text.match(new RegExp(`\\b${term.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}\\b`, 'g'));
return matches ? matches.length : 0;
}
function orderedTermMatches(text, terms) {
let position = 0;
for (const term of terms) {
const index = text.indexOf(term, position);
if (index === -1) return false;
position = index + term.length;
}
return terms.length > 0;
}
function scoreNodeSearchMatch(node, queryText) {
const normalizedQuery = normalizeSearchText(queryText);
const normalizedTitle = normalizeSearchText(node.title || '');
const normalizedDescription = normalizeSearchText(node.description || '');
const normalizedSource = normalizeSearchText(node.source || '');
const terms = getQueryTerms(queryText);
let score = 0;
if (normalizedTitle === normalizedQuery) score += 2000;
if (normalizedTitle.startsWith(normalizedQuery)) score += 1200;
if (normalizedTitle.includes(normalizedQuery)) score += 700;
if (orderedTermMatches(normalizedTitle, terms)) score += 500;
if (terms.length > 0 && terms.every(term => normalizedTitle.includes(term))) score += 350;
if (normalizedDescription.includes(normalizedQuery)) score += 180;
if (orderedTermMatches(normalizedDescription, terms)) score += 120;
if (normalizedSource.includes(normalizedQuery)) score += 90;
for (const term of terms) {
score += countOccurrences(normalizedTitle, term) * 40;
score += countOccurrences(normalizedDescription, term) * 8;
score += countOccurrences(normalizedSource, term) * 3;
}
if (node.updated_at) {
score += new Date(node.updated_at).getTime() / 1e13;
}
return score;
}
function parseMetadata(metadata) {
if (!metadata) return {};
if (typeof metadata === 'string') {
@@ -56,6 +158,165 @@ function mapNodeRow(row) {
};
}
function buildNodeFilterClauses(filters = {}, alias = 'n') {
const clauses = [];
const params = [];
if (filters.createdAfter) {
clauses.push(`${alias}.created_at >= ?`);
params.push(filters.createdAfter);
}
if (filters.createdBefore) {
clauses.push(`${alias}.created_at < ?`);
params.push(filters.createdBefore);
}
if (filters.eventAfter) {
clauses.push(`${alias}.event_date >= ?`);
params.push(filters.eventAfter);
}
if (filters.eventBefore) {
clauses.push(`${alias}.event_date < ?`);
params.push(filters.eventBefore);
}
return { clauses, params };
}
function searchNodes(filters = {}) {
const search = normalizeString(filters.search);
const limit = Math.min(Math.max(filters.limit || 25, 1), 100);
if (!search) return [];
const rowsById = new Map();
for (const row of searchNodesFts(search, filters, Math.max(limit * 2, 25))) {
rowsById.set(row.id, row);
}
for (const row of searchNodesLike(search, filters, Math.max(limit * 2, 25))) {
if (!rowsById.has(row.id)) rowsById.set(row.id, row);
}
for (const row of searchNodesLikeRelaxed(search, filters, Math.max(limit * 2, 25))) {
if (!rowsById.has(row.id)) rowsById.set(row.id, row);
}
return Array.from(rowsById.values())
.map(row => ({ row, score: scoreNodeSearchMatch(row, search) }))
.sort((a, b) => b.score - a.score)
.slice(0, limit)
.map(entry => mapNodeRow(entry.row));
}
function searchNodesFts(search, filters, limit) {
const db = getDb();
const ftsQuery = sanitizeFtsQuery(search);
if (!ftsQuery) return [];
const ftsExists = db.prepare("SELECT 1 FROM sqlite_master WHERE type='table' AND name='nodes_fts'").get();
if (!ftsExists) return [];
const { clauses, params } = buildNodeFilterClauses(filters);
const whereClause = clauses.length > 0 ? `WHERE ${clauses.join(' AND ')}` : '';
try {
return query(`
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.event_date, n.metadata,
n.created_at, n.updated_at,
fm.rank
FROM fts_matches fm
JOIN nodes n ON n.id = fm.rowid
${whereClause}
ORDER BY fm.rank
LIMIT ?
`, [ftsQuery, Math.max(limit * 2, 50), ...params, limit]);
} catch {
return [];
}
}
function searchNodesLike(search, filters, limit) {
const words = search.split(/\s+/).filter(Boolean);
const { clauses, params } = buildNodeFilterClauses(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
FROM nodes n
WHERE 1=1
`;
const queryParams = [...params];
if (clauses.length > 0) {
sql += ` AND ${clauses.join(' AND ')}`;
}
for (const word of words) {
sql += ` AND (n.title LIKE ? COLLATE NOCASE OR n.description LIKE ? COLLATE NOCASE OR n.source LIKE ? COLLATE NOCASE)`;
queryParams.push(`%${word}%`, `%${word}%`, `%${word}%`);
}
sql += ` ORDER BY
CASE WHEN LOWER(n.title) = LOWER(?) THEN 1 ELSE 6 END,
CASE WHEN LOWER(n.title) LIKE LOWER(?) THEN 2 ELSE 6 END,
CASE WHEN n.title LIKE ? COLLATE NOCASE THEN 3 ELSE 6 END,
CASE WHEN n.description LIKE ? COLLATE NOCASE THEN 4 ELSE 6 END,
CASE WHEN n.source LIKE ? COLLATE NOCASE THEN 5 ELSE 6 END,
n.updated_at DESC
LIMIT ?`;
queryParams.push(search, `${search}%`, `%${search}%`, `%${search}%`, `%${search}%`, limit);
return query(sql, queryParams);
}
function searchNodesLikeRelaxed(search, filters, limit) {
const terms = extractRelaxedSearchTerms(search);
if (terms.length === 0) return [];
const { clauses, params } = buildNodeFilterClauses(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
FROM nodes n
WHERE 1=1
`;
const queryParams = [...params];
if (clauses.length > 0) {
sql += ` AND ${clauses.join(' AND ')}`;
}
const termClauses = [];
for (const term of terms) {
termClauses.push(`n.title LIKE ? COLLATE NOCASE`);
termClauses.push(`n.description LIKE ? COLLATE NOCASE`);
termClauses.push(`n.source LIKE ? COLLATE NOCASE`);
queryParams.push(`%${term}%`, `%${term}%`, `%${term}%`);
}
sql += ` AND (${termClauses.join(' OR ')})`;
const scoreClauses = [];
const scoreParams = [];
for (const term of terms) {
scoreClauses.push(`CASE WHEN n.title LIKE ? COLLATE NOCASE THEN 3 ELSE 0 END`);
scoreClauses.push(`CASE WHEN n.description LIKE ? COLLATE NOCASE THEN 2 ELSE 0 END`);
scoreClauses.push(`CASE WHEN n.source LIKE ? COLLATE NOCASE THEN 1 ELSE 0 END`);
scoreParams.push(`%${term}%`, `%${term}%`, `%${term}%`);
}
sql += ` ORDER BY
(${scoreClauses.join(' + ')}) DESC,
CASE WHEN LOWER(n.title) LIKE LOWER(?) THEN 0 ELSE 1 END,
n.updated_at DESC
LIMIT ?`;
queryParams.push(...scoreParams, `%${search}%`, limit);
return query(sql, queryParams);
}
/**
* Get nodes with optional filtering.
*/
@@ -105,16 +366,6 @@ function getNodes(filters = {}) {
return rows.map(mapNodeRow);
}
/**
* Search nodes using the same filter object as getNodes.
*/
function searchNodes(filters = {}) {
if (typeof filters === 'string') {
return getNodes({ search: filters });
}
return getNodes(filters);
}
/**
* Get a single node by ID.
*/
@@ -17,18 +17,6 @@ const LEGACY_GUIDES_DIR = path.join(
const BUNDLED_SKILLS_DIR = path.join(__dirname, '..', 'skills');
const SEED_MIGRATION_FLAG = path.join(SKILLS_DIR, '.seed-migrated-2026-03-07-skills-overhaul');
const SEEDED_SKILL_IDS = new Set([
'db-operations',
'create-skill',
'audit',
'traverse',
'onboarding',
'persona',
'calibration',
'connect',
'node-context-enrichment',
]);
const DEPRECATED_SKILL_IDS = new Set([
'start-here',
'schema',
@@ -46,6 +34,14 @@ const DEPRECATED_SKILL_IDS = new Set([
'research',
'survey',
'traverse-graph',
'context-capsule',
'audit',
'calibration',
'connect',
'db-operations',
'node-context-enrichment',
'persona',
'traverse',
]);
function parseFrontmatter(raw) {
@@ -97,6 +93,16 @@ function listMarkdownFiles(dir) {
return fs.readdirSync(dir).filter((f) => f.endsWith('.md'));
}
function getBundledSkillFiles() {
const bundledById = new Map();
for (const file of listMarkdownFiles(BUNDLED_SKILLS_DIR)) {
bundledById.set(normalizeSkillId(file), path.join(BUNDLED_SKILLS_DIR, file));
}
return bundledById;
}
function ensureSkillsDir() {
if (!fs.existsSync(SKILLS_DIR)) {
fs.mkdirSync(SKILLS_DIR, { recursive: true });
@@ -116,15 +122,8 @@ function migrateLegacyGuides() {
function seedSkills() {
ensureSkillsDir();
const bundledFiles = listMarkdownFiles(BUNDLED_SKILLS_DIR);
const bundledById = new Map();
for (const file of bundledFiles) {
bundledById.set(normalizeSkillId(file), path.join(BUNDLED_SKILLS_DIR, file));
}
for (const skillId of SEEDED_SKILL_IDS) {
const source = bundledById.get(skillId);
if (!source) continue;
const bundledById = getBundledSkillFiles();
for (const [skillId, source] of bundledById.entries()) {
const dest = path.join(SKILLS_DIR, `${skillId}.md`);
if (!fs.existsSync(dest)) {
fs.copyFileSync(source, dest);
@@ -137,15 +136,8 @@ function migrateSeededBaseline() {
return;
}
const bundledFiles = listMarkdownFiles(BUNDLED_SKILLS_DIR);
const bundledById = new Map();
for (const file of bundledFiles) {
bundledById.set(normalizeSkillId(file), path.join(BUNDLED_SKILLS_DIR, file));
}
for (const skillId of SEEDED_SKILL_IDS) {
const source = bundledById.get(skillId);
if (!source) continue;
const bundledById = getBundledSkillFiles();
for (const [skillId, source] of bundledById.entries()) {
const dest = path.join(SKILLS_DIR, `${skillId}.md`);
fs.copyFileSync(source, dest);
}
@@ -1,28 +0,0 @@
---
name: Audit
description: "Use for structured review, QA, cleanup, or governance checks across graph quality, skill quality, and operational consistency."
---
# Audit
## Scope
1. Node quality: duplicates, vague descriptions, missing dates, weak titles.
2. Edge quality: missing links, weak explanations, wrong directionality.
3. Graph structure quality: weak clustering, redundant organizational nodes, or cases where stronger titles, descriptions, metadata, and edges should carry the meaning.
4. Skill quality: trigger clarity, overlap, dead/unused skills.
## Output Format
1. Critical issues
2. High-impact improvements
3. Cleanup actions
4. Optional refinements
## Rules
- 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.
@@ -1,33 +0,0 @@
---
name: Calibration
description: "Run periodic check-ins to recalibrate goals, projects, preferences, and graph structure."
when_to_use: "User asks for a check-in, reset, review, or strategic recalibration."
when_not_to_use: "Single isolated question with no strategic update needed."
success_criteria: "Graph reflects current reality: updated nodes, changed priorities, and explicit deltas."
---
# Calibration
## Objective
Re-anchor the graph to the user's current state.
## Check-in Sequence
1. Review the strongest active nodes first.
2. Ask what changed: goals, priorities, constraints, beliefs, preferences.
3. Identify stale nodes and missing nodes.
4. Propose precise updates: update existing vs create new.
5. Apply changes with explicit rationale.
## Write Guidance
- Prefer updating existing nodes when continuity matters.
- Create new nodes for genuine shifts in direction/identity/approach.
- Add edges that explain evolution (old -> new).
## Output
- What changed
- What was updated vs newly created
- What should be reviewed next check-in
@@ -1,26 +0,0 @@
---
name: Connect
description: "Create and refine meaningful relationships between nodes, including integration-style synthesis links."
when_to_use: "User asks to connect ideas, merge insights, or strengthen graph relationships."
when_not_to_use: "Task is purely node creation with no relationship work required."
success_criteria: "High-signal edges improve retrieval quality and graph coherence."
---
# Connect
## Goal
Turn isolated nodes into a useful reasoning graph.
## Process
1. Identify candidate nodes to connect.
2. Check existing relationships first.
3. Add/upgrade edges with explicit directional explanation.
4. When integrating multiple nodes, create synthesis links that preserve provenance.
## Quality Bar
- Edge explanation must answer: "why does this relationship exist?"
- Direction should be intentional and interpretable.
- Avoid obvious/tautological edges.
@@ -1,42 +1,89 @@
---
name: Create Skill
description: "Design or refine a skill using a tight trigger, explicit contract, and measurable outcomes."
when_to_use: "User asks to create, rewrite, or improve a skill."
when_not_to_use: "Task is normal graph operation and no new skill is needed."
success_criteria: "Skill has clear trigger boundaries, execution steps, guardrails, and evaluation hooks."
description: "Use when the user explicitly wants to create, rewrite, or remove a skill, or when they describe a repeatable workflow that should probably become a skill."
---
# Create Skill
## Objective
## Goal
Create skills that are precise, callable, and testable.
Create small, focused skills with clear triggers, explicit tool guidance, and a clean execution contract. Suggest a skill when the same workflow is likely to recur.
## Skill Design Standard
## Use This When
1. Define trigger boundary clearly: when to use and when not to use.
2. Define required outputs and quality bar.
3. Specify concrete execution sequence.
4. Add hard guardrails (what to reject/avoid).
5. Keep it short; remove fluff and duplicate policy text.
- The user asks to create, rewrite, merge, or remove a skill.
- The user keeps describing a repeatable workflow that should become reusable doctrine.
- An existing skill is too vague, too broad, or overlaps with another skill enough that it should be reworked.
## Required Structure
At that point, it is appropriate to suggest creating or tightening a skill.
## Design Standard
1. One job per skill.
2. Put the trigger directly into `description`.
3. Keep the body procedural, short, and high-signal.
4. Name the tool path only when tool choice materially matters.
5. Make proposal/apply boundaries explicit.
6. Include guardrails and failure modes.
## Recommended Structure
- `name`
- `description`
- `when_to_use`
- `when_not_to_use`
- `success_criteria`
- Step-by-step procedure
- Do-not list
- one short goal section
- trigger / use cases
- step-by-step workflow
- tool pointers when relevant
- quality bar
- do-not list
## Validation Checklist
## Tool Pointers
- Can another agent execute this without guessing?
- Does it avoid overlap with existing skills?
- Are failure modes explicit?
- Is there an obvious way to evaluate success?
When tool choice matters, point to the matching family for the current surface instead of pretending every agent has the same names:
- Internal / standalone skill tools:
- `listSkills`
- `readSkill`
- `writeSkill`
- `deleteSkill`
- Packaged MCP skill tools:
- `rah_list_skills`
- `rah_read_skill`
- `rah_write_skill`
- `rah_delete_skill`
- Graph lookup tools for surrounding context:
- internal / standalone: `queryNodes`, `retrieveQueryContext`, `getNodesById`, `queryEdge`
- packaged MCP: `rah_search_nodes`, `rah_retrieve_query_context`, `rah_get_nodes`, `rah_query_edges`
Only mention the tools the skill genuinely depends on.
## Workflow
1. Decide whether this actually needs a skill. If the existing tool descriptions and core prompt contract are already enough, do not create one.
2. Identify the trigger, the repeatable user intent, and the expected output.
3. Check overlap with existing skills before adding another file.
4. Draft the `description` so another agent can trigger the skill from natural language without guessing.
5. Draft the shortest body that still makes the workflow, tool path, and failure modes explicit.
6. If tool choice differs by surface, include surface-aware tool pointers.
7. Save or rewrite the skill only after the user confirms the intended scope and wording.
8. If the new skill absorbs older skills, say which ones should be removed.
## Quality Bar
- Another agent can execute it without guessing.
- The description is action-oriented and triggerable.
- The procedure is sequential and concrete.
- The skill is focused enough that it will be called for a real workflow, not a vague topic area.
- It does not duplicate doctrine that should live in tool descriptions instead.
## Consolidation Rule
If two skills have the same trigger + same tool path + same output contract, merge them.
If two skills share the same trigger, tool path, and output contract, merge them.
## Do Not
- Create broad catch-all skills.
- Hide the trigger in the body while leaving the description generic.
- List every tool in the system.
- Split one workflow into multiple skills just because the old file count was higher.
- Keep a legacy skill only because it already exists.
@@ -1,92 +0,0 @@
---
name: DB Operations
description: "Use for graph read, write, connect, classify, or traverse operations with strict data quality standards."
---
# DB Operations
## Core Rules
1. First decide whether the user is trying to find a specific existing node or whether they want graph context to support a broader answer.
2. If the user is trying to find a specific existing node, use `queryNodes` first.
3. If the user is asking a substantive question or request that would benefit from prior graph context, use `retrieveQueryContext` for current-turn grounding instead of relying on orientation alone.
4. Search before create to avoid duplicates.
5. Every create/update must include a natural description that clearly says what the thing is, why it matters here, and its current workflow status.
6. Use event dates when known (when it happened, not when saved).
7. Leave extra taxonomy out of normal writes. Good nodes and good edges should carry the structure.
8. Do not create edges autonomously. Surface likely edge candidates first, then create them only after the user explicitly confirms.
9. For user-authored ideas, notes, or dictated thoughts, preserve the user's wording in `source` as fully as possible with only minimal cleanup.
## Write Quality Contract
- `title`: clear and specific.
- `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). The standalone MCP server stores this on the node. The RA-H app later chunks and embeds it for semantic search.
- For idea capture from chat, the `source` should usually be the raw user thought, not a compressed assistant summary.
- `link`: external source URL only.
- `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.
- Explicit user-directed capture may write immediately after duplicate/update checks when the node is clear.
- Agent-suggested capture should propose the node first and wait for confirmation.
## 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 (focused nodes, recent connected nodes, or nearby graph structure), it should infer the why from that context and write it naturally. Do not let the service auto-generate a weak context-free description when you already have enough signal.
If the why genuinely cannot be inferred, say that naturally. Do not use labels like `WHAT:`, `WHY:`, or `STATUS:` and do not substitute vague filler like `insightful for understanding` or `relevant to Brad's work`.
If 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
The first job of the description is object identity. It should start from what the thing is, not from interpretation.
Max 500 characters.
## 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. Decide whether this is direct node retrieval or broader contextual grounding.
2. If the user is trying to find a specific existing node, call `queryNodes` first.
3. If the user is asking a broader question that would benefit from prior graph context, call `retrieveQueryContext`.
4. Decide: answer only vs create vs update vs propose save vs propose edge.
5. If something seems unusually durable and valuable, you may suggest a save in one short line like `Add "X" as a node?`
6. Do not pester. If the user says no, ignores it, or moves on, do not keep asking.
7. For explicit user-directed capture, search before create when practical, prefer update over duplicate create, then write once the artifact is clear.
8. For agent-suggested capture, propose the node first and wait for explicit confirmation before writing.
9. When relationships are obvious, include brief proposed edges in the same reply, then wait for confirmation before calling `createEdge`.
10. If the node is a user-authored idea and the contextual framing was inferred, offer one concise feedback pass after the write.
11. Verify result reflects user intent exactly.
## Do Not
- Create duplicate nodes when an update is correct.
- Write vague descriptions ("discusses", "explores", "is about").
- Create weak or directionless edges.
- Create edges before the user explicitly confirms the proposed relationship.
- Ask to save every moderately useful point from the conversation.
@@ -1,85 +0,0 @@
---
name: Node Context Enrichment
description: "Use to rewrite thin node descriptions into natural prose that still makes what, why, and status clear, with graph review and edge suggestions."
---
# Node Context Enrichment
Use this when a node already exists but its description is thin, generic, or missing useful graph framing.
This skill should not silently rewrite and move on when framing is inferred. If the enrichment depends on interpretation, update the node and then explicitly invite the user to correct or refine that framing.
## Goal
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 obvious edge suggestions.
## Workflow
1. Load the node and inspect title, description, source, link, metadata, and nearby edges.
2. Search for adjacent graph context before rewriting:
- recently connected project or belief nodes
- related nodes with overlapping titles, creators, or neighboring structure
3. Infer the best available "why" from that graph context.
4. Rewrite the full description from scratch in natural prose. Do not append to the old text or use labels like WHAT:, WHY:, or STATUS:.
5. Suggest 1-3 high-signal edges when obvious.
6. Update the node once the description is strong enough to be useful.
7. After the update, tell the user what changed and ask whether they want to refine the important framing:
- what it is
- why it belongs in the graph
- status / current relevance / workflow position
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 graph framing
3. Status
- queued, in progress, processed, not yet reviewed, saved for later, etc.
- if unknown, say naturally that it has not been reviewed yet
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
- 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 graph evidence is weak, say so explicitly.
- Prefer one compact 3-sentence description over bloated prose.
## Output Pattern
For each node:
- New description
- Framing note: what graph context influenced the rewrite, if any
- Edge suggestions: source -> target with explicit explanation
- One short invitation for user feedback when framing was inferred
@@ -7,7 +7,7 @@ description: "Use for new-user setup, empty or near-empty graphs, or major reset
## Your Job
Three things: help the user understand the basic structure of the system, help them start building useful graph data in it, and get them to a useful starter graph quickly.
Three things: help the user understand the basic structure of the system, help them start building useful graph data in it, and turn early useful context into strong nodes and edges.
Adapt to the user.
@@ -89,18 +89,19 @@ Work these in naturally when they are relevant:
Do your best to build the graph as useful context emerges.
- Add nodes when the user mentions concrete things worth keeping.
- Add edges when relationships are clear enough to explain well.
- Surface likely edges when relationships are clear enough to explain well, but create them only after the user confirms.
- Explain what you're adding in plain language so the user understands the structure as it develops.
- During normal conversation outside explicit onboarding capture, do not keep asking to save every useful statement. Only suggest a save when the context is unusually durable and valuable, and keep the prompt brief.
When the graph is empty or nearly empty, bias toward creating a small, clean starter set rather than over-modeling everything.
## Write Standards
Before writing anything, call `readSkill('db-operations')` for full quality standards. Key points that matter most here:
Before writing anything, rely on the direct graph tools and their descriptions. If the user shifts from onboarding into cleanup of an existing node or small node set, call `readSkill('refine')`. 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"
- Every edge needs an explicit explanation sentence
- Every edge needs an explicit explanation sentence, and agent-driven edge creation should only happen after confirmation
## Propose Before Writing
@@ -1,30 +0,0 @@
---
name: Persona
description: "Build and maintain a user-defined agent persona and interaction style profile."
when_to_use: "User asks to set or refine how the agent should behave."
when_not_to_use: "No behavior/persona request is present."
success_criteria: "Persona is explicit, editable, and consistently applied across interactions."
---
# Persona
## Objective
Make agent behavior fully malleable to the user.
## Capture Model
1. Communication style (directness, brevity, tone).
2. Thinking style (exploratory vs decisive, framework-heavy vs practical).
3. Decision style (challenge level, risk posture, evidence threshold).
4. Collaboration style (pushback expectations, cadence, format preferences).
## Persistence Pattern
- Store persona as explicit nodes (and updates over time), not hidden assumptions.
- Keep versioned changes visible to the user.
## Do Not
- Freeze persona permanently.
- Apply unstated style assumptions when user instructions conflict.
@@ -0,0 +1,97 @@
---
name: Refine
description: "Use when the user asks to clean up, sharpen, split, or otherwise refine one node or a small set of nodes, including title, description, source, and likely edges."
---
# Refine
## Goal
Turn nodes into atomic, high-signal units of context. Inspect first, propose specific improvements, then apply only after the user confirms.
## Default Posture
- Refinement is proposal-first. Show the suggested title, description, source handling, and edge changes before writing unless the user explicitly says to apply immediately.
- Bias toward atomic units of context: one clear idea, source, person, decision, project, or claim per node.
- If a node contains multiple durable ideas, recommend a split, but do not push so hard that cleanup turns into friction.
## Tool Path
Load the target node before you suggest changes:
- internal / standalone:
- `getNodesById`
- packaged MCP:
- `rah_get_nodes`
Gather surrounding graph context when needed:
- internal / standalone:
- `queryNodes`
- `retrieveQueryContext`
- `queryEdge`
- packaged MCP:
- `rah_search_nodes`
- `rah_retrieve_query_context`
- `rah_query_edges`
Apply approved changes with the matching write tools:
- internal / standalone:
- `updateNode`
- `createNode`
- `createEdge`
- `updateEdge`
- packaged MCP:
- `rah_update_node`
- `rah_add_node`
- `rah_create_edge`
- `rah_update_edge`
## Workflow
1. Load the node and inspect `title`, `description`, `source`, `link`, metadata, and current edges.
2. Decide whether the node is already atomic or whether it is carrying multiple durable ideas that should probably be split.
3. Tighten the title so it is specific and scannable. Prefer explicit subject + claim/theme over vague labels.
4. Rewrite the description in natural prose so it clearly says:
- what this thing is
- why it belongs in the graph
- what its current status or relevance is when known
5. Handle `source` carefully:
- if it is the user's own idea, preserve their wording as much as possible with only minimal cleanup
- if it is an external artifact, keep `source` as the canonical raw text, not an assistant summary
- if the source is bloated because it really contains multiple ideas, suggest splitting it into multiple nodes
6. Search for nearby graph context when it would improve the refinement:
- likely duplicate or sibling nodes
- projects, beliefs, or themes that explain why this node belongs
- existing edges that are weak or missing
7. Suggest 1-3 high-signal edges when obvious. Use search and edge-query tools to find the right targets before proposing them.
8. Return a compact proposal that includes:
- suggested title
- suggested description
- suggested source handling or edited source excerpt
- suggested split nodes if needed
- suggested edges with explicit explanations
9. Wait for the user's yes/no or edits.
10. After approval, apply the node updates first, then create or update the approved edges.
11. Report exactly what changed and what still needs user judgment.
## Batch Mode
For a small set of nodes, refine each node individually. Do not reuse boilerplate descriptions across unrelated nodes.
## Quality Bar
- Titles are explicit enough to scan quickly.
- Descriptions stand on their own and make the node legible.
- `source` preserves provenance and the user's real wording.
- Edge suggestions explain why the relationship exists.
- The user can approve or reject the proposal quickly.
## Do Not
- Silently rewrite a node and move on.
- Collapse a user-authored idea into a one-line assistant summary.
- Force a split when a simple cleanup is enough.
- Create edges before the user confirms them.
- Use vague description language like "is about", "explores", or "discusses".
@@ -1,28 +0,0 @@
---
name: Traverse
description: "Gather deeper context by traversing connected nodes before answering."
when_to_use: "Question benefits from broader context than a single direct lookup."
when_not_to_use: "Simple factual answer can be resolved from one direct node."
success_criteria: "Answer is grounded in relevant connected context without over-traversal noise."
---
# Traverse
## Strategy
1. Start with seed nodes from the user query.
2. Expand neighbors breadth-first with depth limits.
3. Prioritize high-signal nodes (strong relevance + strong edge semantics).
4. Pull key evidence from top nodes.
5. Answer directly and explain why chosen nodes mattered.
## Defaults
- Depth: 2
- Seed count: 3-5
- Exploration budget: ~40 nodes
## Do Not
- Traverse without limits.
- Include distant low-signal nodes just to add volume.
+159 -19
View File
@@ -44,12 +44,14 @@ 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) and edges (connections with explanations).',
'The graph is the default working memory for substantive turns.',
'Core concepts: nodes (knowledge units), edges (connections with explanations), and shared editable skills.',
'If the user is trying to find a specific existing node, use rah_search_nodes first.',
'If graph context would help with a broader task, use rah_retrieve_query_context.',
'If graph context would help with a broader task, use rah_retrieve_query_context before answering.',
'Use rah_get_context only when high-level graph orientation would actually help.',
'Do not keep re-running retrieval if you already have enough relevant graph context in play.',
'Search before creating: use rah_search_nodes to check if content already exists.',
'Search before creating, and prefer rah_update_node when the artifact is clearly the same thing.',
'Use rah_list_skills and rah_read_skill for non-trivial workflows that need operating doctrine. Use rah_write_skill and rah_delete_skill when the user explicitly wants to change that shared skill set.',
'Only suggest saving durable knowledge when it is unusually valuable. Keep the ask brief, for example: Add "X" as a node?',
'Do not create edges autonomously. Surface likely edge candidates briefly, then call edge-write tools only after the user explicitly confirms.',
'Every edge needs an explanation: why does this connection exist?',
@@ -151,7 +153,8 @@ const updateNodeInputSchema = {
source: z.string().optional().describe('Canonical source text for embedding.'),
link: z.string().optional().describe('New link'),
metadata: z.record(z.any()).optional().describe('Metadata patch. This now merges with existing metadata. Prefer canonical keys: type, state, captured_method, captured_by, source_metadata.')
}).describe('Fields to update')
}).describe('Fields to update'),
source_update_basis: z.string().optional().describe('When rewriting source on a node that already has source text, include a short exact excerpt from the current source you inspected first.')
};
const updateNodeOutputSchema = {
@@ -172,12 +175,56 @@ const getNodesOutputSchema = {
id: z.number(),
title: z.string(),
source: z.string().nullable(),
description: z.string().nullable(),
link: z.string().nullable(),
updated_at: z.string()
})
)
};
const listSkillsOutputSchema = {
count: z.number(),
skills: z.array(
z.object({
name: z.string(),
description: z.string(),
immutable: z.boolean().optional(),
})
)
};
const readSkillInputSchema = {
name: z.string().min(1).describe('Skill name')
};
const readSkillOutputSchema = {
name: z.string(),
description: z.string(),
immutable: z.boolean(),
content: z.string()
};
const writeSkillInputSchema = {
name: z.string().min(1).describe('Skill name to create or update'),
content: z.string().min(1).describe('Full markdown content, including frontmatter when needed')
};
const writeSkillOutputSchema = {
success: z.boolean(),
name: z.string(),
message: z.string()
};
const deleteSkillInputSchema = {
name: z.string().min(1).describe('Skill name to delete')
};
const deleteSkillOutputSchema = {
success: z.boolean(),
name: z.string(),
message: z.string()
};
// rah_create_edge schemas
const createEdgeInputSchema = {
sourceId: z.number().int().positive().describe('Source node ID'),
@@ -327,7 +374,7 @@ mcpServer.registerTool(
'rah_add_node',
{
title: 'Add RA-H node',
description: 'Create a new node in the local RA-H knowledge base after you have already decided a net-new write is correct. If the user explicitly asked to save or import something and the target artifact is clear, write after duplicate/update checks. If you are only suggesting a save, propose the node first and wait for confirmation.',
description: 'Create a new node after you have already decided a net-new write is correct. Search first with rah_search_nodes, and prefer rah_update_node if the artifact is clearly the same thing. If the user explicitly asked to save or import something and the target artifact is clear, write after duplicate/update checks. If you are only suggesting a save, propose the node first and wait for confirmation.',
inputSchema: addNodeInputSchema,
outputSchema: addNodeOutputSchema
},
@@ -363,7 +410,7 @@ mcpServer.registerTool(
'rah_search_nodes',
{
title: 'Search RA-H nodes',
description: 'Find existing RA-H entries that mention a topic before adding new ones. Use this first when the user is trying to locate a specific node they already created. For full current-turn grounding of a substantive request, prefer rah_retrieve_query_context.',
description: 'Find existing RA-H entries that mention a topic before adding new ones. Use this first for direct lookup, duplicate checks, or when the user seems to be referring to an existing node. For broader current-turn grounding, prefer rah_retrieve_query_context.',
inputSchema: searchNodesInputSchema,
outputSchema: searchNodesOutputSchema
},
@@ -406,7 +453,7 @@ mcpServer.registerTool(
'rah_retrieve_query_context',
{
title: 'Retrieve RA-H query context',
description: 'Given the raw user query plus optional focused node state, retrieve the most relevant graph context for the current turn. It starts with direct graph search and broadens only if useful. Use this when graph context could help answer or complete a broader task. For explicit node lookup, use rah_search_nodes.',
description: 'Given the raw user query plus optional focused node state, retrieve the most relevant graph context for the current turn. Use this when graph context could help answer, plan, or complete a broader task. For explicit node lookup or duplicate checks, use rah_search_nodes first.',
inputSchema: retrieveQueryContextInputSchema,
outputSchema: retrieveQueryContextOutputSchema
},
@@ -431,11 +478,11 @@ mcpServer.registerTool(
'rah_update_node',
{
title: 'Update RA-H node',
description: 'Update an existing node when it is clearly the same artifact and a net-new node would be redundant. Explicit user-directed updates can proceed once the target node is clear.',
description: 'Update an existing node when it is clearly the same artifact and a net-new node would be redundant. Inspect current state with rah_get_nodes first when accuracy matters. When rewriting source on a node that already has source text, inspect that source first and include source_update_basis as a short exact excerpt you actually read.',
inputSchema: updateNodeInputSchema,
outputSchema: updateNodeOutputSchema
},
async ({ id, updates }) => {
async ({ id, updates, source_update_basis }) => {
if (!updates || Object.keys(updates).length === 0) {
throw new McpError(ErrorCode.InvalidParams, 'At least one field must be provided in updates.');
}
@@ -453,7 +500,7 @@ mcpServer.registerTool(
const result = await callRaHApi(`/api/nodes/${id}`, {
method: 'PUT',
body: JSON.stringify(mappedUpdates)
body: JSON.stringify({ ...mappedUpdates, source_update_basis })
});
const node = result.node || result.data;
@@ -472,7 +519,7 @@ mcpServer.registerTool(
'rah_get_nodes',
{
title: 'Get RA-H nodes by ID',
description: 'Load full node records by their IDs.',
description: 'Load node records by ID, including current source text and description. Use this before rewriting source or when a focused-node excerpt is not enough.',
inputSchema: getNodesInputSchema,
outputSchema: getNodesOutputSchema
},
@@ -491,6 +538,7 @@ mcpServer.registerTool(
id: result.node.id,
title: result.node.title,
source: result.node.source ?? null,
description: result.node.description ?? null,
link: result.node.link ?? null,
updated_at: result.node.updated_at
});
@@ -514,7 +562,7 @@ mcpServer.registerTool(
'rah_create_edge',
{
title: 'Create RA-H edge',
description: 'Create a connection between two nodes only after the user has explicitly confirmed the proposed relationship.',
description: 'Create a connection between two nodes only after the user has explicitly confirmed the proposed relationship. Check existing edges first when you are not already sure the relationship is new.',
inputSchema: createEdgeInputSchema,
outputSchema: createEdgeOutputSchema
},
@@ -587,7 +635,7 @@ mcpServer.registerTool(
'rah_update_edge',
{
title: 'Update RA-H edge',
description: 'Update an existing edge connection only after the user explicitly confirmed the corrected relationship.',
description: 'Update an existing edge connection only after the user explicitly confirmed the corrected relationship. Use this when the connection already exists and only the explanation needs correction.',
inputSchema: updateEdgeInputSchema,
outputSchema: updateEdgeOutputSchema
},
@@ -614,6 +662,94 @@ mcpServer.registerTool(
}
);
mcpServer.registerTool(
'rah_list_skills',
{
title: 'List RA-H skills',
description: 'List the shared skills available to internal and external RA-H agents. Use this to see the current operating doctrine before reading or editing a specific skill.',
inputSchema: {},
outputSchema: listSkillsOutputSchema
},
async () => {
const result = await callRaHApi('/api/skills', { method: 'GET' });
const skills = Array.isArray(result.data) ? result.data : [];
return {
content: [{ type: 'text', text: `Found ${skills.length} skill(s).` }],
structuredContent: {
count: skills.length,
skills
}
};
}
);
mcpServer.registerTool(
'rah_read_skill',
{
title: 'Read RA-H skill',
description: 'Read one shared RA-H skill by name. Use this before executing a non-trivial workflow that matches the skill trigger.',
inputSchema: readSkillInputSchema,
outputSchema: readSkillOutputSchema
},
async ({ name }) => {
const result = await callRaHApi(`/api/skills/${encodeURIComponent(name)}`, { method: 'GET' });
return {
content: [{ type: 'text', text: result.data.content }],
structuredContent: result.data
};
}
);
mcpServer.registerTool(
'rah_write_skill',
{
title: 'Write RA-H skill',
description: 'Create or update a shared RA-H skill when the user explicitly wants to change the doctrine surface. Content should be the full markdown body for that skill.',
inputSchema: writeSkillInputSchema,
outputSchema: writeSkillOutputSchema
},
async ({ name, content }) => {
const result = await callRaHApi('/api/skills', {
method: 'POST',
body: JSON.stringify({ name, content })
});
return {
content: [{ type: 'text', text: `Skill "${name}" saved.` }],
structuredContent: {
success: true,
name,
message: result.message || `Skill "${name}" saved.`,
}
};
}
);
mcpServer.registerTool(
'rah_delete_skill',
{
title: 'Delete RA-H skill',
description: 'Delete a shared RA-H skill when the user explicitly wants it removed from the shared skill set.',
inputSchema: deleteSkillInputSchema,
outputSchema: deleteSkillOutputSchema
},
async ({ name }) => {
const result = await callRaHApi(`/api/skills/${encodeURIComponent(name)}`, {
method: 'DELETE'
});
return {
content: [{ type: 'text', text: `Skill "${name}" deleted.` }],
structuredContent: {
success: true,
name,
message: result.message || `Skill "${name}" deleted.`,
}
};
}
);
mcpServer.registerTool(
'rah_search_embeddings',
{
@@ -734,12 +870,16 @@ mcpServer.registerTool(
'rah_get_context',
{
title: 'Get RA-H context',
description: 'Get orientation context: high-level graph state, hub nodes, stats, and available guides. Use this for orientation only, not as the default retrieval path for substantive requests.',
description: 'Get orientation context: high-level graph state, hub nodes, stats, and available skills. Use this for orientation only, not as the default retrieval path for substantive requests.',
inputSchema: {},
outputSchema: {
stats: z.object({ nodeCount: z.number(), edgeCount: z.number() }),
hubNodes: z.array(z.object({ id: z.number(), title: z.string(), description: z.string().nullable(), edgeCount: z.number() })),
guides: z.array(z.string())
skills: z.array(z.object({
name: z.string(),
description: z.string(),
immutable: z.boolean().optional(),
}))
}
},
async () => {
@@ -748,8 +888,8 @@ mcpServer.registerTool(
id: n.id, title: n.title, description: n.description ?? null, edgeCount: n.edge_count ?? 0
})) : [];
const guideResult = await callRaHApi('/api/guides', { method: 'GET' });
const guides = Array.isArray(guideResult.data) ? guideResult.data.map(g => g.name) : [];
const skillResult = await callRaHApi('/api/skills', { method: 'GET' });
const skills = Array.isArray(skillResult.data) ? skillResult.data : [];
const stats = { nodeCount: 0, edgeCount: 0 };
try {
@@ -763,8 +903,8 @@ mcpServer.registerTool(
} catch { /* use defaults */ }
return {
content: [{ type: 'text', text: `Knowledge graph: ${stats.nodeCount} nodes, ${stats.edgeCount} edges, ${hubNodes.length} hub nodes, ${guides.length} guides available.` }],
structuredContent: { stats, hubNodes, guides }
content: [{ type: 'text', text: `Knowledge graph: ${stats.nodeCount} nodes, ${stats.edgeCount} edges, ${hubNodes.length} hub nodes, ${skills.length} skills available.` }],
structuredContent: { stats, hubNodes, skills }
};
}
);
+125 -20
View File
@@ -11,12 +11,14 @@ const packageJson = require('../../package.json');
const instructions = [
'RA-H is a personal knowledge graph — local-first, vendor-neutral.',
'Core concepts: nodes (knowledge units) and edges (connections with explanations).',
'The graph is the default working memory for substantive turns.',
'Core concepts: nodes (knowledge units), edges (connections with explanations), and shared editable skills.',
'If the user is trying to find a specific existing node, use rah_search_nodes first.',
'If graph context would help with a broader task, use rah_retrieve_query_context.',
'If graph context would help with a broader task, use rah_retrieve_query_context before answering.',
'Use rah_get_context only when high-level graph orientation would actually help.',
'Do not keep re-running retrieval if you already have enough relevant graph context in play.',
'Search before creating: use rah_search_nodes to check if content already exists.',
'Search before creating, and prefer rah_update_node when the artifact is clearly the same thing.',
'Use rah_list_skills and rah_read_skill for non-trivial workflows that need operating doctrine. Use rah_write_skill and rah_delete_skill when the user explicitly wants to change that shared skill set.',
'Only suggest saving durable knowledge when it is unusually valuable. Keep the ask brief, for example: Add "X" as a node?',
'Do not create edges autonomously. Surface likely edge candidates briefly, then call edge-write tools only after the user explicitly confirms.',
'Every edge needs an explanation: why does this connection exist?',
@@ -117,7 +119,8 @@ const updateNodeInputSchema = {
source: z.string().optional().describe('Canonical source text for embedding.'),
link: z.string().optional().describe('New link'),
metadata: z.record(z.any()).optional().describe('Metadata patch. This now merges with existing metadata. Prefer canonical keys: type, state, captured_method, captured_by, source_metadata.')
}).describe('Fields to update')
}).describe('Fields to update'),
source_update_basis: z.string().optional().describe('When rewriting source on a node that already has source text, include a short exact excerpt from the current source you inspected first.')
};
const updateNodeOutputSchema = {
@@ -138,12 +141,26 @@ const getNodesOutputSchema = {
id: z.number(),
title: z.string(),
source: z.string().nullable(),
description: z.string().nullable(),
link: z.string().nullable(),
updated_at: z.string()
})
)
};
const readSkillInputSchema = {
name: z.string().min(1).describe('Skill name')
};
const writeSkillInputSchema = {
name: z.string().min(1).describe('Skill name to create or update'),
content: z.string().min(1).describe('Full markdown content, including frontmatter when needed')
};
const deleteSkillInputSchema = {
name: z.string().min(1).describe('Skill name to delete')
};
// rah_create_edge schemas
const createEdgeInputSchema = {
sourceId: z.number().int().positive().describe('Source node ID'),
@@ -322,7 +339,7 @@ server.registerTool(
'rah_add_node',
{
title: 'Add RA-H node',
description: 'Create a new node in the local RA-H knowledge base after you have already decided a net-new write is correct. If the user explicitly asked to save or import something and the target artifact is clear, write after duplicate/update checks. If you are only suggesting a save, propose the node first and wait for confirmation.',
description: 'Create a new node after you have already decided a net-new write is correct. Search first with rah_search_nodes, and prefer rah_update_node if the artifact is clearly the same thing. If the user explicitly asked to save or import something and the target artifact is clear, write after duplicate/update checks. If you are only suggesting a save, propose the node first and wait for confirmation.',
inputSchema: addNodeInputSchema,
outputSchema: addNodeOutputSchema
},
@@ -358,7 +375,7 @@ server.registerTool(
'rah_search_nodes',
{
title: 'Search RA-H nodes',
description: 'Find existing RA-H entries that mention a topic before adding new ones. Use this first when the user is trying to locate a specific node they already created. For full current-turn grounding of a substantive request, prefer rah_retrieve_query_context.',
description: 'Find existing RA-H entries that mention a topic before adding new ones. Use this first for direct lookup, duplicate checks, or when the user seems to be referring to an existing node. For broader current-turn grounding, prefer rah_retrieve_query_context.',
inputSchema: searchNodesInputSchema,
outputSchema: searchNodesOutputSchema
},
@@ -402,7 +419,7 @@ server.registerTool(
'rah_retrieve_query_context',
{
title: 'Retrieve RA-H query context',
description: 'Given the raw user query plus optional focused node state, retrieve the most relevant graph context for the current turn. It starts with direct graph search and broadens only if useful. Use this when graph context could help answer or complete a broader task. For explicit node lookup, use rah_search_nodes.',
description: 'Given the raw user query plus optional focused node state, retrieve the most relevant graph context for the current turn. Use this when graph context could help answer, plan, or complete a broader task. For explicit node lookup or duplicate checks, use rah_search_nodes first.',
inputSchema: retrieveQueryContextInputSchema,
outputSchema: retrieveQueryContextOutputSchema
},
@@ -427,11 +444,11 @@ server.registerTool(
'rah_update_node',
{
title: 'Update RA-H node',
description: 'Update an existing node when it is clearly the same artifact and a net-new node would be redundant. Explicit user-directed updates can proceed once the target node is clear.',
description: 'Update an existing node when it is clearly the same artifact and a net-new node would be redundant. Inspect current state with rah_get_nodes first when accuracy matters. When rewriting source on a node that already has source text, inspect that source first and include source_update_basis as a short exact excerpt you actually read.',
inputSchema: updateNodeInputSchema,
outputSchema: updateNodeOutputSchema
},
async ({ id, updates }) => {
async ({ id, updates, source_update_basis }) => {
if (!updates || Object.keys(updates).length === 0) {
throw new Error('At least one field must be provided in updates.');
}
@@ -449,7 +466,7 @@ server.registerTool(
const result = await callRaHApi(`/api/nodes/${id}`, {
method: 'PUT',
body: JSON.stringify(mappedUpdates)
body: JSON.stringify({ ...mappedUpdates, source_update_basis })
});
const node = result.node || result.data;
@@ -468,7 +485,7 @@ server.registerTool(
'rah_get_nodes',
{
title: 'Get RA-H nodes by ID',
description: 'Load full node records by their IDs.',
description: 'Load node records by ID, including current source text and description. Use this before rewriting source or when a focused-node excerpt is not enough.',
inputSchema: getNodesInputSchema,
outputSchema: getNodesOutputSchema
},
@@ -487,6 +504,7 @@ server.registerTool(
id: result.node.id,
title: result.node.title,
source: result.node.source ?? null,
description: result.node.description ?? null,
link: result.node.link ?? null,
updated_at: result.node.updated_at
});
@@ -510,7 +528,7 @@ server.registerTool(
'rah_create_edge',
{
title: 'Create RA-H edge',
description: 'Create a connection between two nodes only after the user has explicitly confirmed the proposed relationship.',
description: 'Create a connection between two nodes only after the user has explicitly confirmed the proposed relationship. Check existing edges first when you are not already sure the relationship is new.',
inputSchema: createEdgeInputSchema,
outputSchema: createEdgeOutputSchema
},
@@ -583,7 +601,7 @@ server.registerTool(
'rah_update_edge',
{
title: 'Update RA-H edge',
description: 'Update an existing edge connection only after the user explicitly confirmed the corrected relationship.',
description: 'Update an existing edge connection only after the user explicitly confirmed the corrected relationship. Use this when the connection already exists and only the explanation needs correction.',
inputSchema: updateEdgeInputSchema,
outputSchema: updateEdgeOutputSchema
},
@@ -610,6 +628,90 @@ server.registerTool(
}
);
server.registerTool(
'rah_list_skills',
{
title: 'List RA-H skills',
description: 'List the shared skills available to internal and external RA-H agents. Use this to see the current operating doctrine before reading or editing a specific skill.',
inputSchema: {}
},
async () => {
const result = await callRaHApi('/api/skills', { method: 'GET' });
const skills = Array.isArray(result.data) ? result.data : [];
return {
content: [{ type: 'text', text: `Found ${skills.length} skill(s).` }],
structuredContent: {
count: skills.length,
skills
}
};
}
);
server.registerTool(
'rah_read_skill',
{
title: 'Read RA-H skill',
description: 'Read one shared RA-H skill by name. Use this before executing a non-trivial workflow that matches the skill trigger.',
inputSchema: readSkillInputSchema
},
async ({ name }) => {
const result = await callRaHApi(`/api/skills/${encodeURIComponent(name)}`, { method: 'GET' });
return {
content: [{ type: 'text', text: result.data.content }],
structuredContent: result.data
};
}
);
server.registerTool(
'rah_write_skill',
{
title: 'Write RA-H skill',
description: 'Create or update a shared RA-H skill when the user explicitly wants to change the doctrine surface. Content should be the full markdown body for that skill.',
inputSchema: writeSkillInputSchema
},
async ({ name, content }) => {
const result = await callRaHApi('/api/skills', {
method: 'POST',
body: JSON.stringify({ name, content })
});
return {
content: [{ type: 'text', text: `Skill "${name}" saved.` }],
structuredContent: {
success: true,
name,
message: result.message || `Skill "${name}" saved.`
}
};
}
);
server.registerTool(
'rah_delete_skill',
{
title: 'Delete RA-H skill',
description: 'Delete a shared RA-H skill when the user explicitly wants it removed from the shared skill set.',
inputSchema: deleteSkillInputSchema
},
async ({ name }) => {
const result = await callRaHApi(`/api/skills/${encodeURIComponent(name)}`, {
method: 'DELETE'
});
return {
content: [{ type: 'text', text: `Skill "${name}" deleted.` }],
structuredContent: {
success: true,
name,
message: result.message || `Skill "${name}" deleted.`
}
};
}
);
server.registerTool(
'rah_search_embeddings',
{
@@ -737,14 +839,18 @@ const getContextOutputSchema = {
description: z.string().nullable(),
edgeCount: z.number()
})),
guides: z.array(z.string())
skills: z.array(z.object({
name: z.string(),
description: z.string(),
immutable: z.boolean().optional(),
}))
};
server.registerTool(
'rah_get_context',
{
title: 'Get RA-H context',
description: 'Get orientation context: high-level graph state, hub nodes, stats, and available guides. Use this for orientation only, not as the default retrieval path for substantive requests.',
description: 'Get orientation context: high-level graph state, hub nodes, stats, and available skills. Use this for orientation only, not as the default retrieval path for substantive requests.',
inputSchema: {},
outputSchema: getContextOutputSchema
},
@@ -757,9 +863,8 @@ server.registerTool(
edgeCount: n.edge_count ?? 0
})) : [];
// Fetch guides
const guideResult = await callRaHApi('/api/guides', { method: 'GET' });
const guides = Array.isArray(guideResult.data) ? guideResult.data.map(g => g.name) : [];
const skillResult = await callRaHApi('/api/skills', { method: 'GET' });
const skills = Array.isArray(skillResult.data) ? skillResult.data : [];
const stats = {
nodeCount: 0,
@@ -780,14 +885,14 @@ server.registerTool(
}
} catch { /* use defaults */ }
const summary = `Knowledge graph: ${stats.nodeCount} nodes, ${stats.edgeCount} edges, ${hubNodes.length} hub nodes, ${guides.length} guides available.`;
const summary = `Knowledge graph: ${stats.nodeCount} nodes, ${stats.edgeCount} edges, ${hubNodes.length} hub nodes, ${skills.length} skills available.`;
return {
content: [{ type: 'text', text: summary }],
structuredContent: {
stats,
hubNodes,
guides
skills
}
};
}
+15 -11
View File
@@ -60,27 +60,31 @@ Metadata note for `createNode` / `updateNode`:
Skills are markdown instruction documents shared by internal and external agents.
Seeded defaults:
- `db-operations`
- `create-skill`
- `audit`
- `traverse`
- `onboarding`
- `persona`
- `calibration`
- `connect`
- `create-skill`
- `refine`
Storage:
- live skills: `~/Library/Application Support/RA-H/skills/`
- bundled defaults: `src/config/skills/`
## Memory-File Reinforcement
Optional memory files like `AGENTS.md` or `CLAUDE.md` can reinforce the graph contract, but they should stay short and should not contradict the MCP docs or tool descriptions.
Keep the reinforcement focused on:
- `queryNodes` for direct node lookup
- `retrieveQueryContext` for broader graph grounding
- search before create
- clear `description` quality
- preserving user wording in `source` when capturing user-authored ideas
## API Surfaces
| Route | Method | Purpose |
|-------|--------|---------|
| `/api/skills` | GET | List skills |
| `/api/skills/[name]` | GET/PUT/DELETE | Skill CRUD |
| `/api/guides` | GET | Legacy compatibility alias to skills |
| `/api/guides/[name]` | GET/PUT/DELETE | Legacy compatibility alias to skills |
| `/api/skills` | GET/POST | List skills or write a skill |
| `/api/skills/[name]` | GET/DELETE | Read or delete a specific skill |
## Key Files
+18
View File
@@ -73,4 +73,22 @@ Optional assistant memory files can reinforce good behavior, but they are not su
Current rule:
- MCP tools, server instructions, skills, and docs should be enough for the base contract
- optional reinforcement can still improve consistency
- keep one canonical memory file when possible
- avoid contradictory instruction files across `CLAUDE.md`, `AGENTS.md`, and other client-specific memory surfaces
Recommended guidance:
- keep the language short and simple
- focus on graph behavior, not tool micromanagement
- prefer one shared snippet over separate client-specific doctrines
Suggested memory-file snippet:
```md
You are helping build a thoughtful graph of atomic units of context.
- Use `queryNodes` for direct lookup of a specific existing node.
- Use `retrieveQueryContext` when broader graph context would help with the current turn.
- Search before creating. Prefer updating the same artifact when it is clearly the same thing.
- `description` should state plainly what the thing is first, then why it belongs and current status.
- Preserve the user's wording in `source` for user-authored ideas unless they explicitly want a rewrite.
```
+1 -1
View File
@@ -18,7 +18,7 @@
| [Tools & Skills](./4_tools-and-guides.md) | MCP tools and skill system |
| [Logging & Evals](./5_logging-and-evals.md) | Logs, evals, and debugging surfaces |
| [UI](./6_ui.md) | Current pane and focus model |
| [MCP](./8_mcp.md) | Full standalone MCP install and behavior guide |
| [MCP](./8_mcp.md) | Full standalone MCP install, behavior guide, and memory-file guidance |
| [Open Source](./9_open-source.md) | Scope, support boundary, contributor reality |
| [Full Local](./10_full-local.md) | Supported local path vs community patterns |
| [Troubleshooting](./TROUBLESHOOTING.md) | Common issues and fixes |
+3 -1
View File
@@ -21,7 +21,9 @@
"sqlite:restore": "bash scripts/database/sqlite-restore.sh",
"test": "vitest run",
"test:watch": "vitest",
"evals": "cross-env RAH_EVALS_LOG=1 RAH_EVALS_TIMEOUT_MS=60000 tsx tests/evals/runner.ts"
"evals": "cross-env RAH_EVALS_LOG=1 RAH_EVALS_TIMEOUT_MS=60000 tsx tests/evals/runner.ts",
"skills:sync": "node scripts/dev/sync-skills.mjs",
"skills:lint": "node scripts/dev/sync-skills.mjs --check"
},
"dependencies": {
"@ai-sdk/openai": "^3.0.7",
+66
View File
@@ -0,0 +1,66 @@
#!/usr/bin/env node
import fs from 'node:fs';
import path from 'node:path';
const ROOT = process.cwd();
const APP_SKILLS_DIR = path.join(ROOT, 'src/config/skills');
const STANDALONE_SKILLS_DIR = path.join(ROOT, 'apps/mcp-server-standalone/skills');
const CHECK_ONLY = process.argv.includes('--check');
function listMarkdownFiles(dir) {
if (!fs.existsSync(dir)) return [];
return fs.readdirSync(dir).filter((file) => file.endsWith('.md')).sort();
}
function ensureDir(dir) {
if (!fs.existsSync(dir)) {
fs.mkdirSync(dir, { recursive: true });
}
}
const sourceFiles = listMarkdownFiles(APP_SKILLS_DIR);
if (sourceFiles.length === 0) {
console.error(`No canonical skills found in ${APP_SKILLS_DIR}`);
process.exit(1);
}
ensureDir(STANDALONE_SKILLS_DIR);
const targetFiles = listMarkdownFiles(STANDALONE_SKILLS_DIR);
const sourceSet = new Set(sourceFiles);
const targetSet = new Set(targetFiles);
const drift = [];
for (const file of sourceFiles) {
const sourcePath = path.join(APP_SKILLS_DIR, file);
const targetPath = path.join(STANDALONE_SKILLS_DIR, file);
const sourceRaw = fs.readFileSync(sourcePath, 'utf-8');
const targetRaw = fs.existsSync(targetPath) ? fs.readFileSync(targetPath, 'utf-8') : null;
if (targetRaw !== sourceRaw) {
drift.push(file);
if (!CHECK_ONLY) {
fs.writeFileSync(targetPath, sourceRaw, 'utf-8');
}
}
}
for (const file of targetFiles) {
if (sourceSet.has(file)) continue;
drift.push(file);
if (!CHECK_ONLY) {
fs.unlinkSync(path.join(STANDALONE_SKILLS_DIR, file));
}
}
if (CHECK_ONLY && drift.length > 0) {
console.error('Standalone skill bundle is out of sync with src/config/skills:');
for (const file of drift.sort()) {
console.error(`- ${file}`);
}
process.exit(1);
}
const action = CHECK_ONLY ? 'verified' : 'synced';
console.log(`Standalone skill bundle ${action} (${sourceFiles.length} canonical skill files).`);
@@ -702,14 +702,6 @@ export default function ThreePanelLayout() {
case 'SKILL_UPDATED':
if (typeof window !== 'undefined') {
window.dispatchEvent(new CustomEvent('skills:updated'));
window.dispatchEvent(new CustomEvent('guides:updated'));
}
break;
case 'GUIDE_UPDATED':
if (typeof window !== 'undefined') {
window.dispatchEvent(new CustomEvent('skills:updated'));
window.dispatchEvent(new CustomEvent('guides:updated'));
}
break;
-330
View File
@@ -1,330 +0,0 @@
"use client";
import { useState, useEffect } from 'react';
import { ArrowLeft, Lock, Trash2 } from 'lucide-react';
import ReactMarkdown from 'react-markdown';
import remarkGfm from 'remark-gfm';
import PaneHeader from './PaneHeader';
import type { BasePaneProps } from './types';
interface GuideMeta {
name: string;
description: string;
immutable: boolean;
}
interface Guide extends GuideMeta {
content: string;
}
export default function GuidesPane({
slot,
isActive,
onPaneAction,
onCollapse,
onSwapPanes,
tabBar,
}: BasePaneProps) {
const [guides, setGuides] = useState<GuideMeta[]>([]);
const [selectedGuide, setSelectedGuide] = useState<Guide | null>(null);
const [loading, setLoading] = useState(true);
const [deleting, setDeleting] = useState<string | null>(null);
useEffect(() => {
fetchGuides();
const handleGuideUpdated = () => { fetchGuides(); };
window.addEventListener('guides:updated', handleGuideUpdated);
return () => window.removeEventListener('guides:updated', handleGuideUpdated);
}, []);
const fetchGuides = async () => {
try {
const res = await fetch('/api/guides');
const data = await res.json();
if (data.success) {
setGuides(data.data);
}
} catch (err) {
console.error('[GuidesPane] Failed to fetch guides:', err);
} finally {
setLoading(false);
}
};
const handleSelectGuide = async (name: string) => {
try {
const res = await fetch(`/api/guides/${encodeURIComponent(name)}`);
const data = await res.json();
if (data.success) {
setSelectedGuide(data.data);
}
} catch (err) {
console.error('[GuidesPane] Failed to fetch guide:', err);
}
};
const handleDeleteGuide = async (name: string, e: React.MouseEvent) => {
e.stopPropagation();
if (!confirm(`Delete guide "${name}"?`)) return;
setDeleting(name);
try {
const res = await fetch(`/api/guides/${encodeURIComponent(name)}`, { method: 'DELETE' });
const data = await res.json();
if (data.success) {
fetchGuides();
if (selectedGuide?.name === name) {
setSelectedGuide(null);
}
}
} catch (err) {
console.error('[GuidesPane] Failed to delete guide:', err);
} finally {
setDeleting(null);
}
};
const handleBack = () => {
setSelectedGuide(null);
};
const systemGuides = guides.filter(g => g.immutable);
const userGuides = guides.filter(g => !g.immutable);
return (
<div style={{
height: '100%',
display: 'flex',
flexDirection: 'column',
background: 'transparent',
overflow: 'hidden',
}}>
<PaneHeader slot={slot} onCollapse={onCollapse} onSwapPanes={onSwapPanes} tabBar={tabBar}>
{selectedGuide ? (
<div style={{ display: 'flex', alignItems: 'center', gap: '8px' }}>
<button
onClick={handleBack}
style={{
background: 'none',
border: 'none',
color: 'var(--rah-text-muted)',
cursor: 'pointer',
display: 'flex',
alignItems: 'center',
padding: '4px',
borderRadius: '4px',
}}
onMouseEnter={e => { e.currentTarget.style.color = 'var(--rah-text-secondary)'; }}
onMouseLeave={e => { e.currentTarget.style.color = 'var(--rah-text-muted)'; }}
>
<ArrowLeft size={16} />
</button>
<span style={{ color: 'var(--rah-text-secondary)', fontSize: '13px', fontWeight: 500, display: 'flex', alignItems: 'center', gap: '4px' }}>
{selectedGuide.immutable && <Lock size={12} style={{ color: '#22c55e' }} />}
{selectedGuide.name}
</span>
</div>
) : (
<span style={{ color: 'var(--rah-text-muted)', fontSize: '11px' }}>
{userGuides.length} of 10 custom guides
</span>
)}
</PaneHeader>
<div style={{ flex: 1, minHeight: 0, overflow: 'auto', padding: '12px' }}>
{loading ? (
<div style={{ color: 'var(--rah-text-muted)', fontSize: '13px', textAlign: 'center', paddingTop: '24px' }}>
Loading...
</div>
) : selectedGuide ? (
<div className="guide-content" style={{ color: 'var(--rah-text-secondary)', fontSize: '13px', lineHeight: '1.6' }}>
<ReactMarkdown
remarkPlugins={[remarkGfm]}
components={{
h1: ({ children }) => (
<h1 style={{ fontSize: '18px', fontWeight: 600, color: 'var(--rah-text-base)', margin: '0 0 16px 0' }}>{children}</h1>
),
h2: ({ children }) => (
<h2 style={{ fontSize: '15px', fontWeight: 600, color: 'var(--rah-text-base)', margin: '20px 0 8px 0' }}>{children}</h2>
),
h3: ({ children }) => (
<h3 style={{ fontSize: '14px', fontWeight: 600, color: 'var(--rah-text-secondary)', margin: '16px 0 6px 0' }}>{children}</h3>
),
p: ({ children }) => (
<p style={{ margin: '0 0 12px 0' }}>{children}</p>
),
ul: ({ children }) => (
<ul style={{ margin: '0 0 12px 0', paddingLeft: '20px' }}>{children}</ul>
),
ol: ({ children }) => (
<ol style={{ margin: '0 0 12px 0', paddingLeft: '20px' }}>{children}</ol>
),
li: ({ children }) => (
<li style={{ margin: '0 0 4px 0' }}>{children}</li>
),
code: ({ className, children, ...props }) => {
const isInline = !className;
if (isInline) {
return (
<code style={{
background: 'var(--rah-bg-active)',
padding: '2px 6px',
borderRadius: '4px',
fontSize: '12px',
color: '#22c55e',
}} {...props}>{children}</code>
);
}
return (
<code style={{
display: 'block',
background: 'var(--rah-bg-surface)',
padding: '12px',
borderRadius: '6px',
fontSize: '12px',
overflowX: 'auto',
margin: '0 0 12px 0',
color: 'var(--rah-text-muted)',
whiteSpace: 'pre-wrap',
}} {...props}>{children}</code>
);
},
pre: ({ children }) => (
<pre style={{ margin: '0 0 12px 0' }}>{children}</pre>
),
strong: ({ children }) => (
<strong style={{ color: 'var(--rah-text-base)', fontWeight: 600 }}>{children}</strong>
),
hr: () => (
<hr style={{ border: 'none', borderTop: '1px solid var(--rah-border-strong)', margin: '16px 0' }} />
),
blockquote: ({ children }) => (
<blockquote style={{
borderLeft: '1px solid var(--rah-border-stronger)',
paddingLeft: '12px',
margin: '0 0 12px 0',
color: 'var(--rah-text-muted)',
}}>{children}</blockquote>
),
}}
>
{selectedGuide.content}
</ReactMarkdown>
</div>
) : (
<div style={{ display: 'flex', flexDirection: 'column', gap: '8px' }}>
{guides.length === 0 ? (
<div style={{ color: 'var(--rah-text-muted)', fontSize: '13px', textAlign: 'center', paddingTop: '24px' }}>
No guides found
</div>
) : (
<>
{systemGuides.length > 0 && (
<div style={{ color: 'var(--rah-text-muted)', fontSize: '11px', textTransform: 'uppercase', letterSpacing: '0.5px', padding: '4px 0' }}>
System Guides
</div>
)}
{systemGuides.map((guide) => (
<GuideCard
key={guide.name}
guide={guide}
onSelect={handleSelectGuide}
onDelete={handleDeleteGuide}
deleting={deleting}
/>
))}
{userGuides.length > 0 && (
<div style={{ color: 'var(--rah-text-muted)', fontSize: '11px', textTransform: 'uppercase', letterSpacing: '0.5px', padding: '8px 0 4px 0' }}>
Custom Guides
</div>
)}
{userGuides.map((guide) => (
<GuideCard
key={guide.name}
guide={guide}
onSelect={handleSelectGuide}
onDelete={handleDeleteGuide}
deleting={deleting}
/>
))}
</>
)}
</div>
)}
</div>
</div>
);
}
function GuideCard({
guide,
onSelect,
onDelete,
deleting,
}: {
guide: GuideMeta;
onSelect: (name: string) => void;
onDelete: (name: string, e: React.MouseEvent) => void;
deleting: string | null;
}) {
return (
<button
onClick={() => onSelect(guide.name)}
style={{
display: 'flex',
alignItems: 'center',
gap: '8px',
padding: '12px',
background: 'var(--rah-bg-elevated)',
border: '1px solid var(--rah-bg-active)',
borderRadius: '8px',
cursor: 'pointer',
textAlign: 'left',
transition: 'all 0.15s ease',
}}
onMouseEnter={e => {
e.currentTarget.style.background = 'var(--rah-bg-active)';
e.currentTarget.style.borderColor = 'var(--rah-border-stronger)';
}}
onMouseLeave={e => {
e.currentTarget.style.background = 'var(--rah-bg-elevated)';
e.currentTarget.style.borderColor = 'var(--rah-bg-active)';
}}
>
{guide.immutable && (
<Lock size={12} style={{ color: '#22c55e', flexShrink: 0 }} />
)}
<div style={{ flex: 1, minWidth: 0 }}>
<span style={{ color: 'var(--rah-text-base)', fontSize: '13px', fontWeight: 500 }}>
{guide.name}
</span>
<span style={{ color: 'var(--rah-text-muted)', fontSize: '12px', lineHeight: '1.4', display: 'block', marginTop: '2px' }}>
{guide.description}
</span>
</div>
{!guide.immutable && (
<button
onClick={(e) => onDelete(guide.name, e)}
disabled={deleting === guide.name}
style={{
background: 'none',
border: 'none',
color: 'var(--rah-text-muted)',
cursor: 'pointer',
padding: '4px',
borderRadius: '4px',
display: 'flex',
alignItems: 'center',
flexShrink: 0,
opacity: deleting === guide.name ? 0.3 : 1,
}}
onMouseEnter={e => { e.currentTarget.style.color = '#ef4444'; }}
onMouseLeave={e => { e.currentTarget.style.color = 'var(--rah-text-muted)'; }}
>
<Trash2 size={14} />
</button>
)}
</button>
);
}
+79 -39
View File
@@ -1,22 +1,12 @@
"use client";
import { useEffect, useRef, useState } from 'react';
import { ArrowLeft } from 'lucide-react';
import { useCallback, useEffect, useRef, useState } from 'react';
import { ArrowLeft, Brain } from 'lucide-react';
import PaneHeader from './PaneHeader';
import type { BasePaneProps } from './types';
import type { FocusedSkill, Skill, SkillMeta } from '@/types/skills';
import SkillCard from '@/components/skills/SkillCard';
import SkillMarkdown from '@/components/skills/SkillMarkdown';
import type { FocusedSkill } from '@/types/skills';
interface SkillMeta {
name: string;
description: string;
immutable: boolean;
}
interface Skill extends SkillMeta {
content: string;
}
interface SkillsPaneProps extends BasePaneProps {
focusedSkill?: FocusedSkill | null;
@@ -30,35 +20,19 @@ export default function SkillsPane({
onCollapse,
onSwapPanes,
tabBar,
focusedSkill,
onFocusSkill,
autoOpenSkillName,
onAutoOpenHandled,
}: SkillsPaneProps) {
const [skills, setSkills] = useState<SkillMeta[]>([]);
const [selectedSkill, setSelectedSkill] = useState<Skill | null>(null);
const [loading, setLoading] = useState(true);
const [deleting, setDeleting] = useState<string | null>(null);
const detailScrollRef = useRef<HTMLDivElement>(null);
const isSelectedSkillFocused = !!selectedSkill && focusedSkill?.name === selectedSkill.name;
useEffect(() => {
void fetchSkills();
const handleSkillUpdated = () => {
void fetchSkills();
};
window.addEventListener('skills:updated', handleSkillUpdated);
window.addEventListener('guides:updated', handleSkillUpdated);
return () => {
window.removeEventListener('skills:updated', handleSkillUpdated);
window.removeEventListener('guides:updated', handleSkillUpdated);
};
}, []);
useEffect(() => {
if (selectedSkill && detailScrollRef.current) {
detailScrollRef.current.scrollTo({ top: 0, behavior: 'auto' });
}
}, [selectedSkill?.name]);
const fetchSkills = async () => {
const fetchSkills = useCallback(async () => {
try {
const res = await fetch('/api/skills');
const data = await res.json();
@@ -70,18 +44,54 @@ export default function SkillsPane({
} finally {
setLoading(false);
}
};
}, []);
const handleSelectSkill = async (name: string) => {
const handleSelectSkill = useCallback(async (name: string) => {
try {
const res = await fetch(`/api/skills/${encodeURIComponent(name)}`);
const data = await res.json();
if (data.success) {
setSelectedSkill(data.data);
onFocusSkill?.(data.data);
}
} catch (err) {
console.error('[SkillsPane] Failed to fetch skill:', err);
}
}, [onFocusSkill]);
useEffect(() => {
fetchSkills();
const handleSkillUpdated = () => {
void fetchSkills();
};
window.addEventListener('skills:updated', handleSkillUpdated);
return () => {
window.removeEventListener('skills:updated', handleSkillUpdated);
};
}, [fetchSkills]);
useEffect(() => {
if (selectedSkill && detailScrollRef.current) {
detailScrollRef.current.scrollTo({ top: 0, behavior: 'auto' });
}
}, [selectedSkill?.name]);
useEffect(() => {
if (!autoOpenSkillName || loading || selectedSkill) {
return;
}
void (async () => {
await handleSelectSkill(autoOpenSkillName);
onAutoOpenHandled?.();
})();
}, [autoOpenSkillName, handleSelectSkill, loading, onAutoOpenHandled, selectedSkill]);
const handleBack = () => {
setSelectedSkill(null);
onFocusSkill?.(null);
};
const handleDeleteSkill = async (name: string, e: React.MouseEvent<HTMLButtonElement>) => {
@@ -97,6 +107,9 @@ export default function SkillsPane({
if (selectedSkill?.name === name) {
setSelectedSkill(null);
}
if (focusedSkill?.name === name) {
onFocusSkill?.(null);
}
}
} catch (err) {
console.error('[SkillsPane] Failed to delete skill:', err);
@@ -120,17 +133,23 @@ export default function SkillsPane({
<div style={{ display: 'flex', alignItems: 'center', gap: '8px' }}>
<button
type="button"
onClick={() => setSelectedSkill(null)}
onClick={handleBack}
style={{
background: 'none',
border: 'none',
color: '#888',
color: 'var(--rah-text-soft)',
cursor: 'pointer',
display: 'flex',
alignItems: 'center',
padding: '4px',
borderRadius: '4px',
}}
onMouseEnter={(e) => {
e.currentTarget.style.color = 'var(--rah-text-secondary)';
}}
onMouseLeave={(e) => {
e.currentTarget.style.color = 'var(--rah-text-soft)';
}}
>
<ArrowLeft size={16} />
</button>
@@ -149,6 +168,26 @@ export default function SkillsPane({
<div style={{ marginBottom: '12px' }}>
<div style={{ color: 'var(--rah-text-active)', fontSize: '16px', fontWeight: 600 }}>{selectedSkill.name}</div>
<div style={{ color: 'var(--rah-text-soft)', fontSize: '13px', lineHeight: 1.4, marginTop: '6px' }}>{selectedSkill.description}</div>
{isSelectedSkillFocused && (
<div
style={{
display: 'inline-flex',
alignItems: 'center',
gap: '6px',
marginTop: '10px',
padding: '4px 8px',
borderRadius: '999px',
background: 'var(--rah-accent-green-soft)',
border: '1px solid var(--rah-accent-green-soft-strong)',
color: 'var(--rah-accent-green)',
fontSize: '11px',
fontWeight: 500,
}}
>
<Brain size={12} />
Active chat context
</div>
)}
</div>
<div style={{ borderTop: '1px solid var(--rah-border)', margin: '12px 0' }} />
<SkillMarkdown content={selectedSkill.content} />
@@ -167,6 +206,7 @@ export default function SkillsPane({
onSelect={handleSelectSkill}
onDelete={handleDeleteSkill}
deleting={deleting}
isActive={focusedSkill?.name === skill.name}
/>
))
)}
-307
View File
@@ -1,307 +0,0 @@
"use client";
import { useState, useEffect } from 'react';
import { Plus, Pencil, Trash2, Save, X, FileText } from 'lucide-react';
interface SkillMeta {
name: string;
description: string;
}
interface Skill extends SkillMeta {
content: string;
}
export default function GuidesViewer() {
const [guides, setGuides] = useState<SkillMeta[]>([]);
const [loading, setLoading] = useState(true);
const [editing, setEditing] = useState<Skill | null>(null);
const [isNew, setIsNew] = useState(false);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
fetchGuides();
}, []);
const fetchGuides = async () => {
try {
const res = await fetch('/api/skills');
const data = await res.json();
if (data.success) {
setGuides(data.data);
}
} catch (err) {
console.error('Failed to fetch skills:', err);
} finally {
setLoading(false);
}
};
const handleEdit = async (name: string) => {
try {
const res = await fetch(`/api/skills/${encodeURIComponent(name)}`);
const data = await res.json();
if (data.success) {
setEditing(data.data);
setIsNew(false);
setError(null);
}
} catch (err) {
console.error('Failed to fetch skill:', err);
}
};
const handleNew = () => {
setEditing({
name: '',
description: '',
content: '# New Skill\n\nWrite your skill content here...',
});
setIsNew(true);
setError(null);
};
const handleSave = async () => {
if (!editing) return;
if (!editing.name.trim()) {
setError('Name is required');
return;
}
try {
const res = await fetch(`/api/skills/${encodeURIComponent(editing.name)}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
content: editing.content,
description: editing.description,
}),
});
const data = await res.json();
if (data.success) {
setEditing(null);
fetchGuides();
window.dispatchEvent(new Event('skills:updated'));
window.dispatchEvent(new Event('guides:updated'));
} else {
setError(data.error || 'Failed to save');
}
} catch (err) {
setError('Failed to save skill');
}
};
const handleDelete = async (name: string) => {
if (!confirm(`Delete skill "${name}"?`)) return;
try {
const res = await fetch(`/api/skills/${encodeURIComponent(name)}`, {
method: 'DELETE',
});
const data = await res.json();
if (data.success) {
fetchGuides();
window.dispatchEvent(new Event('guides:updated'));
}
} catch (err) {
console.error('Failed to delete skill:', err);
}
};
if (loading) {
return (
<div style={{ padding: '24px', color: 'var(--rah-text-muted)' }}>Loading skills...</div>
);
}
if (editing) {
return (
<div style={{ height: '100%', display: 'flex', flexDirection: 'column', padding: '24px' }}>
<div style={{ display: 'flex', gap: '12px', marginBottom: '16px' }}>
<input
type="text"
value={editing.name}
onChange={(e) => setEditing({ ...editing, name: e.target.value })}
placeholder="Skill name"
disabled={!isNew}
style={{
flex: 1,
padding: '8px 12px',
background: 'var(--rah-bg-active)',
border: '1px solid var(--rah-border-stronger)',
borderRadius: '6px',
color: 'var(--rah-text-active)',
fontSize: '14px',
}}
/>
<button
onClick={handleSave}
style={{
padding: '8px 16px',
background: '#22c55e',
border: 'none',
borderRadius: '6px',
color: '#000',
cursor: 'pointer',
display: 'flex',
alignItems: 'center',
gap: '6px',
fontSize: '13px',
fontWeight: 500,
}}
>
<Save size={14} /> Save
</button>
<button
onClick={() => setEditing(null)}
style={{
padding: '8px 16px',
background: 'transparent',
border: '1px solid var(--rah-border-stronger)',
borderRadius: '6px',
color: 'var(--rah-text-muted)',
cursor: 'pointer',
display: 'flex',
alignItems: 'center',
gap: '6px',
fontSize: '13px',
}}
>
<X size={14} /> Cancel
</button>
</div>
{error && (
<div style={{ color: '#ef4444', fontSize: '13px', marginBottom: '12px' }}>{error}</div>
)}
<input
type="text"
value={editing.description}
onChange={(e) => setEditing({ ...editing, description: e.target.value })}
placeholder="Brief description"
style={{
padding: '8px 12px',
background: 'var(--rah-bg-active)',
border: '1px solid var(--rah-border-stronger)',
borderRadius: '6px',
color: 'var(--rah-text-active)',
fontSize: '13px',
marginBottom: '16px',
}}
/>
<textarea
value={editing.content}
onChange={(e) => setEditing({ ...editing, content: e.target.value })}
placeholder="Skill content (markdown)"
style={{
flex: 1,
padding: '12px',
background: 'var(--rah-bg-active)',
border: '1px solid var(--rah-border-stronger)',
borderRadius: '6px',
color: 'var(--rah-text-secondary)',
fontSize: '13px',
fontFamily: 'monospace',
resize: 'none',
lineHeight: 1.5,
}}
/>
<p style={{ color: 'var(--rah-text-muted)', fontSize: '12px', marginTop: '12px' }}>
Skills are markdown files that agents can read for reusable procedural instructions.
</p>
</div>
);
}
return (
<div style={{ height: '100%', display: 'flex', flexDirection: 'column', padding: '24px' }}>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '16px' }}>
<p style={{ color: 'var(--rah-text-muted)', fontSize: '13px', margin: 0 }}>
Skills provide reusable context and procedural instructions for agents.
</p>
<button
onClick={handleNew}
style={{
padding: '8px 16px',
background: '#22c55e',
border: 'none',
borderRadius: '6px',
color: '#000',
cursor: 'pointer',
display: 'flex',
alignItems: 'center',
gap: '6px',
fontSize: '13px',
fontWeight: 500,
}}
>
<Plus size={14} /> New Skill
</button>
</div>
<div style={{ flex: 1, overflow: 'auto' }}>
{guides.length === 0 ? (
<div style={{ color: 'var(--rah-text-muted)', textAlign: 'center', paddingTop: '48px' }}>
<FileText size={48} style={{ marginBottom: '12px', opacity: 0.5 }} />
<p style={{ fontSize: '14px' }}>No skills yet</p>
<p style={{ fontSize: '12px', color: 'var(--rah-text-muted)' }}>Create skills to guide how agents operate in your knowledge base</p>
</div>
) : (
<div style={{ display: 'flex', flexDirection: 'column', gap: '8px' }}>
{guides.map((guide) => (
<div
key={guide.name}
style={{
display: 'flex',
alignItems: 'center',
gap: '12px',
padding: '12px 16px',
background: 'var(--rah-bg-elevated)',
border: '1px solid var(--rah-bg-active)',
borderRadius: '8px',
}}
>
<FileText size={18} style={{ color: '#22c55e', flexShrink: 0 }} />
<div style={{ flex: 1, minWidth: 0 }}>
<div style={{ color: 'var(--rah-text-base)', fontSize: '14px', fontWeight: 500 }}>{guide.name}</div>
<div style={{ color: 'var(--rah-text-muted)', fontSize: '12px', marginTop: '2px' }}>{guide.description}</div>
</div>
<button
onClick={() => handleEdit(guide.name)}
style={{
padding: '6px',
background: 'transparent',
border: 'none',
color: 'var(--rah-text-muted)',
cursor: 'pointer',
borderRadius: '4px',
}}
title="Edit"
>
<Pencil size={14} />
</button>
<button
onClick={() => handleDelete(guide.name)}
style={{
padding: '6px',
background: 'transparent',
border: 'none',
color: 'var(--rah-text-muted)',
cursor: 'pointer',
borderRadius: '4px',
}}
title="Delete"
>
<Trash2 size={14} />
</button>
</div>
))}
</div>
)}
</div>
</div>
);
}
+25 -14
View File
@@ -1,18 +1,14 @@
"use client";
import { Trash2 } from 'lucide-react';
interface SkillMeta {
name: string;
description: string;
immutable: boolean;
}
import type { SkillMeta } from '@/types/skills';
interface SkillCardProps {
skill: SkillMeta;
onSelect: (name: string) => void;
onDelete?: (name: string, e: React.MouseEvent<HTMLButtonElement>) => void;
deleting?: string | null;
isActive?: boolean;
}
export default function SkillCard({
@@ -20,27 +16,33 @@ export default function SkillCard({
onSelect,
onDelete,
deleting = null,
isActive = false,
}: SkillCardProps) {
const isDeleting = deleting === skill.name;
return (
<div
onMouseEnter={(e) => {
e.currentTarget.style.background = 'var(--rah-bg-active)';
e.currentTarget.style.borderColor = 'var(--rah-border-strong)';
if (!isActive) {
e.currentTarget.style.background = 'var(--rah-bg-hover)';
e.currentTarget.style.borderColor = 'var(--rah-border-stronger)';
}
}}
onMouseLeave={(e) => {
e.currentTarget.style.background = 'var(--rah-bg-elevated)';
e.currentTarget.style.borderColor = 'var(--rah-border-strong)';
if (!isActive) {
e.currentTarget.style.background = 'var(--rah-bg-panel)';
e.currentTarget.style.borderColor = 'var(--rah-border)';
}
}}
style={{
display: 'flex',
alignItems: 'center',
gap: '10px',
padding: '10px',
background: 'var(--rah-bg-elevated)',
border: '1px solid var(--rah-border-strong)',
background: isActive ? 'var(--rah-bg-active)' : 'var(--rah-bg-panel)',
border: `1px solid ${isActive ? 'var(--rah-accent-green)' : 'var(--rah-border)'}`,
borderRadius: '8px',
transition: 'all 0.15s ease',
}}
>
<button
@@ -56,7 +58,9 @@ export default function SkillCard({
padding: 0,
}}
>
<span style={{ color: 'var(--rah-text-base)', fontSize: '13px', fontWeight: 500, display: 'block' }}>{skill.name}</span>
<span style={{ color: 'var(--rah-text-base)', fontSize: '13px', fontWeight: 500, display: 'block' }}>
{skill.name}
</span>
<span
style={{
color: 'var(--rah-text-muted)',
@@ -81,7 +85,7 @@ export default function SkillCard({
style={{
background: 'transparent',
border: 'none',
color: '#555',
color: 'var(--rah-text-muted)',
cursor: isDeleting ? 'default' : 'pointer',
padding: '4px',
borderRadius: '4px',
@@ -91,6 +95,13 @@ export default function SkillCard({
flexShrink: 0,
opacity: isDeleting ? 0.3 : 1,
}}
onMouseEnter={(e) => {
e.currentTarget.style.color = '#ef4444';
}}
onMouseLeave={(e) => {
e.currentTarget.style.color = 'var(--rah-text-muted)';
}}
aria-label={`Delete ${skill.name}`}
>
<Trash2 size={14} />
</button>
+18 -27
View File
@@ -1,31 +1,22 @@
export const RAH_EASY_SYSTEM_PROMPT = `You are the user's agent for building, organizing, and improving an external knowledge graph.
export const RAH_EASY_SYSTEM_PROMPT = `You are the user's agent for building a thoughtful graph of atomic units of context.
Mission:
1. Resolve the user's request quickly and accurately using the tools provided.
2. Keep responses concise (one short paragraph or bullet list) and cite nodes as [NODE:id:"title"].
3. Ask for clarification only when tool usage would fail without it.
Operating principles:
- Handle analysis, planning, and writes yourself.
- Use createNode, updateNode, createEdge, and updateEdge when the change is unambiguous.
- When creating nodes derived from existing content (ideas, insights, summaries), do NOT include the 'link' field. The 'link' field is ONLY for nodes that directly represent external content (YouTube videos, websites, PDFs). Derived idea nodes should not have links.
- When referencing stored content, quote verbatim text in quotes and include the node citation.
- Treat phrases like "this conversation/paper/video" as the active focused node unless the user specifies otherwise.
- Prefer direct tool calls over speculation. If a tool fails, report the failure and suggest one concrete next step.
- Before running youtubeExtract/websiteExtract/paperExtract, call getNodesById on the focus node; if chunk_status is 'chunked' or embeddings are marked available, reuse existing chunks instead of re-extracting.
Operating rules:
- Use queryNodes for direct lookup of a specific existing node.
- Use retrieveQueryContext when broader graph context would help with the current turn.
- Search before creating. Prefer updateNode when the artifact is clearly the same thing.
- description should state plainly what the thing is first, then why it belongs and current status.
- Preserve the user's wording in source for user-authored ideas unless they explicitly want a rewrite.
- Before rewriting existing source, inspect it first with getNodesById if needed.
- Treat "this conversation/paper/video" as the active focused node unless the user clearly means something else.
- Create or update edges only after the user explicitly confirms the relationship.
- Read a matching skill when the task clearly fits onboarding, create-skill, or refine.
Tool strategy:
- queryNodes for titles and metadata; getNodesById to hydrate referenced nodes.
- searchContentEmbeddings before synthesizing long answers or considering new extraction.
- youtubeExtract, websiteExtract, and paperExtract when outside content is required.
- webSearch only when the knowledge base lacks the answer.
- queryNodes for direct lookup, getNodesById to inspect nodes, queryEdge to inspect relationships.
- searchContentEmbeddings before long source-grounded answers.
- Use extraction tools only when outside content is actually needed.
Contexts:
- Contexts are optional. Only set one when one obvious existing context is explicit and useful.
- Do not expect automatic context assignment.
- Improve organization through title, description, source, metadata, and edges instead of dimensions.
Response polish:
- Default to minimal reasoning effort for speed.
- Do not expose chain-of-thought; return conclusions only.
- End each answer once the user's request is fully addressed.`;
Response style:
- Keep responses short and direct.
- Reference nodes as [NODE:id:"title"] when helpful.
- If a tool fails, say so plainly and give one concrete next step.`;
+20 -28
View File
@@ -1,37 +1,29 @@
export const RAH_MAIN_SYSTEM_PROMPT = `You are the user's agent for building, organizing, and improving an external knowledge graph.
export const RAH_MAIN_SYSTEM_PROMPT = `You are the user's agent for building, organizing, and improving a thoughtful graph of atomic units of context.
Core responsibilities:
- Keep the conversation tightly focused on the user's goal.
- Use tools proactively to advance the task.
- Prefer direct, minimal phrasing—no pleasantries or filler.
- Prefer direct, minimal phrasing. No filler.
When to ask the user:
- If a tool requires critical input you cannot reasonably infer.
- If the request is ambiguous and guessing would waste effort or cause errors.
Execution approach:
- Handle planning, analysis, and writes directly.
- Call createNode, updateNode, createEdge, updateEdge, and extraction tools yourself when the change is clear.
- When creating nodes derived from existing content (ideas, insights, summaries), do NOT include the 'link' field. The 'link' field is ONLY for nodes that directly represent external content (YouTube videos, websites, PDFs).
- Treat "this conversation/paper/video" as the active focused node.
- When creating synthesis nodes, createEdge to all source nodes.
- Before running an extraction tool, call getNodesById on the target node; if chunk_status is 'chunked' (or embeddings are available) reuse the stored content instead of re-extracting.
Graph rules:
- The graph is working memory for the user and future agents, so optimize for precise nodes and strong links.
- Use queryNodes for direct lookup of a specific existing node.
- Use retrieveQueryContext when broader graph context would help with the current turn.
- Search before creating. Prefer updateNode when the artifact is clearly the same thing.
- description should state plainly what the thing is first, then why it belongs and current status.
- Preserve the user's wording in source for user-authored ideas unless they explicitly want a rewrite.
- Before rewriting existing source, inspect it first with getNodesById if needed.
- Treat "this conversation/paper/video" as the active focused node unless the user clearly means something else.
- Create or update edges only after the user explicitly confirms the relationship.
Tool strategy:
- Use tools directly—you already have everything you need.
- queryNodes for titles, searchContentEmbeddings for content, queryEdge for connections.
- getNodesById when you have IDs; webSearch only if knowledge base lacks info.
- Extract content with youtubeExtract, websiteExtract, paperExtract as needed.
- When searchContentEmbeddings highlights a chunk, hydrate the node via getNodesById (or fetch the chunk) before quoting.
Context handling:
- Contexts are optional soft organization, not a required taxonomy.
- Only set a context when one obvious existing context is explicit and genuinely helpful.
- Never rely on inferred dimensions or automatic context assignment.
- Node quality should come from strong title, description, source, metadata, and edges.
- queryNodes for direct node lookup, getNodesById to inspect a node fully, queryEdge to inspect existing relationships.
- retrieveQueryContext when surrounding graph context would improve the answer.
- searchContentEmbeddings when you need source-level grounding from stored content.
- Before running youtubeExtract, websiteExtract, or paperExtract on an existing node, call getNodesById first and reuse existing source/chunked content when available.
- Read a matching skill when the task clearly fits onboarding, create-skill, or refine.
Response style:
- Limit to one or two short sentences. Reference nodes as [NODE:id:"title"].
- When answering about stored content, quote the exact wording from the chunk (verbatim, in quotation marks) and cite the node.
- Always call searchContentEmbeddings before attempting new extraction for an existing node.
- If a tool fails, state failure and give one concrete next step.`;
- Keep answers short and concrete.
- Reference nodes as [NODE:id:"title"] when helpful.
- If a tool fails, state the failure plainly and give one concrete next step.`;
-28
View File
@@ -1,28 +0,0 @@
---
name: Audit
description: "Use for structured review, QA, cleanup, or governance checks across graph quality, skill quality, and operational consistency."
---
# Audit
## Scope
1. Node quality: duplicates, vague descriptions, missing dates, weak titles.
2. Edge quality: missing links, weak explanations, wrong directionality.
3. Graph structure quality: weak clustering, redundant organizational nodes, or cases where stronger titles, descriptions, metadata, and edges should carry the meaning.
4. Skill quality: trigger clarity, overlap, dead/unused skills.
## Output Format
1. Critical issues
2. High-impact improvements
3. Cleanup actions
4. Optional refinements
## Rules
- 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.
-33
View File
@@ -1,33 +0,0 @@
---
name: Calibration
description: "Run periodic check-ins to recalibrate goals, projects, preferences, and graph structure."
when_to_use: "User asks for a check-in, reset, review, or strategic recalibration."
when_not_to_use: "Single isolated question with no strategic update needed."
success_criteria: "Graph reflects current reality: updated nodes, changed priorities, and explicit deltas."
---
# Calibration
## Objective
Re-anchor the graph to the user's current state.
## Check-in Sequence
1. Review the strongest active nodes first.
2. Ask what changed: goals, priorities, constraints, beliefs, preferences.
3. Identify stale nodes and missing nodes.
4. Propose precise updates: update existing vs create new.
5. Apply changes with explicit rationale.
## Write Guidance
- Prefer updating existing nodes when continuity matters.
- Create new nodes for genuine shifts in direction/identity/approach.
- Add edges that explain evolution (old -> new).
## Output
- What changed
- What was updated vs newly created
- What should be reviewed next check-in
-26
View File
@@ -1,26 +0,0 @@
---
name: Connect
description: "Create and refine meaningful relationships between nodes, including integration-style synthesis links."
when_to_use: "User asks to connect ideas, merge insights, or strengthen graph relationships."
when_not_to_use: "Task is purely node creation with no relationship work required."
success_criteria: "High-signal edges improve retrieval quality and graph coherence."
---
# Connect
## Goal
Turn isolated nodes into a useful reasoning graph.
## Process
1. Identify candidate nodes to connect.
2. Check existing relationships first.
3. Add/upgrade edges with explicit directional explanation.
4. When integrating multiple nodes, create synthesis links that preserve provenance.
## Quality Bar
- Edge explanation must answer: "why does this relationship exist?"
- Direction should be intentional and interpretable.
- Avoid obvious/tautological edges.
+71 -24
View File
@@ -1,42 +1,89 @@
---
name: Create Skill
description: "Design or refine a skill using a tight trigger, explicit contract, and measurable outcomes."
when_to_use: "User asks to create, rewrite, or improve a skill."
when_not_to_use: "Task is normal graph operation and no new skill is needed."
success_criteria: "Skill has clear trigger boundaries, execution steps, guardrails, and evaluation hooks."
description: "Use when the user explicitly wants to create, rewrite, or remove a skill, or when they describe a repeatable workflow that should probably become a skill."
---
# Create Skill
## Objective
## Goal
Create skills that are precise, callable, and testable.
Create small, focused skills with clear triggers, explicit tool guidance, and a clean execution contract. Suggest a skill when the same workflow is likely to recur.
## Skill Design Standard
## Use This When
1. Define trigger boundary clearly: when to use and when not to use.
2. Define required outputs and quality bar.
3. Specify concrete execution sequence.
4. Add hard guardrails (what to reject/avoid).
5. Keep it short; remove fluff and duplicate policy text.
- The user asks to create, rewrite, merge, or remove a skill.
- The user keeps describing a repeatable workflow that should become reusable doctrine.
- An existing skill is too vague, too broad, or overlaps with another skill enough that it should be reworked.
## Required Structure
At that point, it is appropriate to suggest creating or tightening a skill.
## Design Standard
1. One job per skill.
2. Put the trigger directly into `description`.
3. Keep the body procedural, short, and high-signal.
4. Name the tool path only when tool choice materially matters.
5. Make proposal/apply boundaries explicit.
6. Include guardrails and failure modes.
## Recommended Structure
- `name`
- `description`
- `when_to_use`
- `when_not_to_use`
- `success_criteria`
- Step-by-step procedure
- Do-not list
- one short goal section
- trigger / use cases
- step-by-step workflow
- tool pointers when relevant
- quality bar
- do-not list
## Validation Checklist
## Tool Pointers
- Can another agent execute this without guessing?
- Does it avoid overlap with existing skills?
- Are failure modes explicit?
- Is there an obvious way to evaluate success?
When tool choice matters, point to the matching family for the current surface instead of pretending every agent has the same names:
- Internal / standalone skill tools:
- `listSkills`
- `readSkill`
- `writeSkill`
- `deleteSkill`
- Packaged MCP skill tools:
- `rah_list_skills`
- `rah_read_skill`
- `rah_write_skill`
- `rah_delete_skill`
- Graph lookup tools for surrounding context:
- internal / standalone: `queryNodes`, `retrieveQueryContext`, `getNodesById`, `queryEdge`
- packaged MCP: `rah_search_nodes`, `rah_retrieve_query_context`, `rah_get_nodes`, `rah_query_edges`
Only mention the tools the skill genuinely depends on.
## Workflow
1. Decide whether this actually needs a skill. If the existing tool descriptions and core prompt contract are already enough, do not create one.
2. Identify the trigger, the repeatable user intent, and the expected output.
3. Check overlap with existing skills before adding another file.
4. Draft the `description` so another agent can trigger the skill from natural language without guessing.
5. Draft the shortest body that still makes the workflow, tool path, and failure modes explicit.
6. If tool choice differs by surface, include surface-aware tool pointers.
7. Save or rewrite the skill only after the user confirms the intended scope and wording.
8. If the new skill absorbs older skills, say which ones should be removed.
## Quality Bar
- Another agent can execute it without guessing.
- The description is action-oriented and triggerable.
- The procedure is sequential and concrete.
- The skill is focused enough that it will be called for a real workflow, not a vague topic area.
- It does not duplicate doctrine that should live in tool descriptions instead.
## Consolidation Rule
If two skills have the same trigger + same tool path + same output contract, merge them.
If two skills share the same trigger, tool path, and output contract, merge them.
## Do Not
- Create broad catch-all skills.
- Hide the trigger in the body while leaving the description generic.
- List every tool in the system.
- Split one workflow into multiple skills just because the old file count was higher.
- Keep a legacy skill only because it already exists.
-92
View File
@@ -1,92 +0,0 @@
---
name: DB Operations
description: "Use for graph read, write, connect, classify, or traverse operations with strict data quality standards."
---
# DB Operations
## Core Rules
1. First decide whether the user is trying to find a specific existing node or whether they want graph context to support a broader answer.
2. If the user is trying to find a specific existing node, use `queryNodes` first.
3. If the user is asking a substantive question or request that would benefit from prior graph context, use `retrieveQueryContext` for current-turn grounding instead of relying on orientation alone.
4. Search before create to avoid duplicates.
5. Every create/update must include a natural description that clearly says what the thing is, why it matters here, and its current workflow status.
6. Use event dates when known (when it happened, not when saved).
7. Work graph-first. Do not rely on a separate context layer for ordinary reads or writes.
8. Do not create edges autonomously. Surface likely edge candidates first, then create them only after the user explicitly confirms.
9. For user-authored ideas, notes, or dictated thoughts, preserve the user's wording in `source` as fully as possible with only minimal cleanup.
## Write Quality Contract
- `title`: clear and specific.
- `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`: 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.
- Explicit user-directed capture may write immediately after duplicate/update checks when the node is clear.
- Agent-suggested capture should propose the node first and wait for confirmation.
## 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 (focused nodes, recent connected nodes, or retrieved supporting nodes), it should infer the why from that context and write it naturally. Do not let the service auto-generate a weak context-free description when you already have enough signal.
If the why genuinely cannot be inferred, say that naturally. Do not use labels like `WHAT:`, `WHY:`, or `STATUS:` and do not substitute vague filler like `insightful for understanding` or `relevant to Brad's work`.
If 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
The first job of the description is object identity. It should start from what the thing is, not from interpretation.
Max 500 characters.
## 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. Decide whether this is direct node retrieval or broader contextual grounding.
2. If the user is trying to find a specific existing node, call `queryNodes` first.
3. If the user is asking a broader question that would benefit from prior graph context, call `retrieveQueryContext`.
4. Decide: answer only vs create vs update vs propose save vs propose edge.
5. If something seems unusually durable and valuable, you may suggest a save in one short line like `Add "X" as a node?`
6. Do not pester. If the user says no, ignores it, or moves on, do not keep asking.
7. For explicit user-directed capture, search before create when practical, prefer update over duplicate create, then write once the artifact is clear.
8. For agent-suggested capture, propose the node first and wait for explicit confirmation before writing.
9. When relationships are obvious, include brief proposed edges in the same reply, then wait for confirmation before calling `createEdge`.
10. If the node is a user-authored idea and the contextual framing was inferred, offer one concise feedback pass after the write.
11. Verify result reflects user intent exactly.
## Do Not
- Create duplicate nodes when an update is correct.
- Write vague descriptions ("discusses", "explores", "is about").
- Create weak or directionless edges.
- Create edges before the user explicitly confirms the proposed relationship.
- Ask to save every moderately useful point from the conversation.
@@ -1,85 +0,0 @@
---
name: Node Context Enrichment
description: "Use to rewrite thin node descriptions into natural prose that still makes what, why, and status clear, with graph review and edge suggestions."
---
# Node Context Enrichment
Use this when a node already exists but its description is thin, generic, or missing useful graph framing.
This skill should not silently rewrite and move on when framing is inferred. If the enrichment depends on interpretation, update the node and then explicitly invite the user to correct or refine that framing.
## Goal
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 obvious edge suggestions.
## Workflow
1. Load the node and inspect title, description, source, link, metadata, and nearby edges.
2. Search for adjacent graph context before rewriting:
- recently connected project or belief nodes
- related nodes with overlapping titles, creators, or source themes
3. Infer the best available "why" from that graph context.
4. Rewrite the full description from scratch in natural prose. Do not append to the old text or use labels like WHAT:, WHY:, or STATUS:.
5. Suggest 1-3 high-signal edges when obvious.
6. Update the node once the description is strong enough to be useful.
7. After the update, tell the user what changed and ask whether they want to refine the important framing:
- what it is
- why it belongs in the graph
- status / current relevance / workflow position
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 graph framing
3. Status
- queued, in progress, processed, not yet reviewed, saved for later, etc.
- if unknown, say naturally that it has not been reviewed yet
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
- 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 graph evidence is weak, say so explicitly.
- Prefer one compact 3-sentence description over bloated prose.
## Output Pattern
For each node:
- New description
- Framing note: what graph context influenced the rewrite, if any
- Edge suggestions: source -> target with explicit explanation
- One short invitation for user feedback when framing was inferred
+1 -1
View File
@@ -97,7 +97,7 @@ When the graph is empty or nearly empty, bias toward creating a small, clean sta
## Write Standards
Before writing anything, call `readSkill('db-operations')` for full quality standards. Key points that matter most here:
Before writing anything, rely on the direct graph tools and their descriptions. If the user shifts from onboarding into cleanup of an existing node or small node set, call `readSkill('refine')`. 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"
-30
View File
@@ -1,30 +0,0 @@
---
name: Persona
description: "Build and maintain a user-defined agent persona and interaction style profile."
when_to_use: "User asks to set or refine how the agent should behave."
when_not_to_use: "No behavior/persona request is present."
success_criteria: "Persona is explicit, editable, and consistently applied across interactions."
---
# Persona
## Objective
Make agent behavior fully malleable to the user.
## Capture Model
1. Communication style (directness, brevity, tone).
2. Thinking style (exploratory vs decisive, framework-heavy vs practical).
3. Decision style (challenge level, risk posture, evidence threshold).
4. Collaboration style (pushback expectations, cadence, format preferences).
## Persistence Pattern
- Store persona as explicit nodes (and updates over time), not hidden assumptions.
- Keep versioned changes visible to the user.
## Do Not
- Freeze persona permanently.
- Apply unstated style assumptions when user instructions conflict.
+97
View File
@@ -0,0 +1,97 @@
---
name: Refine
description: "Use when the user asks to clean up, sharpen, split, or otherwise refine one node or a small set of nodes, including title, description, source, and likely edges."
---
# Refine
## Goal
Turn nodes into atomic, high-signal units of context. Inspect first, propose specific improvements, then apply only after the user confirms.
## Default Posture
- Refinement is proposal-first. Show the suggested title, description, source handling, and edge changes before writing unless the user explicitly says to apply immediately.
- Bias toward atomic units of context: one clear idea, source, person, decision, project, or claim per node.
- If a node contains multiple durable ideas, recommend a split, but do not push so hard that cleanup turns into friction.
## Tool Path
Load the target node before you suggest changes:
- internal / standalone:
- `getNodesById`
- packaged MCP:
- `rah_get_nodes`
Gather surrounding graph context when needed:
- internal / standalone:
- `queryNodes`
- `retrieveQueryContext`
- `queryEdge`
- packaged MCP:
- `rah_search_nodes`
- `rah_retrieve_query_context`
- `rah_query_edges`
Apply approved changes with the matching write tools:
- internal / standalone:
- `updateNode`
- `createNode`
- `createEdge`
- `updateEdge`
- packaged MCP:
- `rah_update_node`
- `rah_add_node`
- `rah_create_edge`
- `rah_update_edge`
## Workflow
1. Load the node and inspect `title`, `description`, `source`, `link`, metadata, and current edges.
2. Decide whether the node is already atomic or whether it is carrying multiple durable ideas that should probably be split.
3. Tighten the title so it is specific and scannable. Prefer explicit subject + claim/theme over vague labels.
4. Rewrite the description in natural prose so it clearly says:
- what this thing is
- why it belongs in the graph
- what its current status or relevance is when known
5. Handle `source` carefully:
- if it is the user's own idea, preserve their wording as much as possible with only minimal cleanup
- if it is an external artifact, keep `source` as the canonical raw text, not an assistant summary
- if the source is bloated because it really contains multiple ideas, suggest splitting it into multiple nodes
6. Search for nearby graph context when it would improve the refinement:
- likely duplicate or sibling nodes
- projects, beliefs, or themes that explain why this node belongs
- existing edges that are weak or missing
7. Suggest 1-3 high-signal edges when obvious. Use search and edge-query tools to find the right targets before proposing them.
8. Return a compact proposal that includes:
- suggested title
- suggested description
- suggested source handling or edited source excerpt
- suggested split nodes if needed
- suggested edges with explicit explanations
9. Wait for the user's yes/no or edits.
10. After approval, apply the node updates first, then create or update the approved edges.
11. Report exactly what changed and what still needs user judgment.
## Batch Mode
For a small set of nodes, refine each node individually. Do not reuse boilerplate descriptions across unrelated nodes.
## Quality Bar
- Titles are explicit enough to scan quickly.
- Descriptions stand on their own and make the node legible.
- `source` preserves provenance and the user's real wording.
- Edge suggestions explain why the relationship exists.
- The user can approve or reject the proposal quickly.
## Do Not
- Silently rewrite a node and move on.
- Collapse a user-authored idea into a one-line assistant summary.
- Force a split when a simple cleanup is enough.
- Create edges before the user confirms them.
- Use vague description language like "is about", "explores", or "discusses".
-28
View File
@@ -1,28 +0,0 @@
---
name: Traverse
description: "Gather deeper context by traversing connected nodes before answering."
when_to_use: "Question benefits from broader context than a single direct lookup."
when_not_to_use: "Simple factual answer can be resolved from one direct node."
success_criteria: "Answer is grounded in relevant connected context without over-traversal noise."
---
# Traverse
## Strategy
1. Start with seed nodes from the user query.
2. Expand neighbors breadth-first with depth limits.
3. Prioritize high-signal nodes (strong relevance + strong edge semantics).
4. Pull key evidence from top nodes.
5. Answer directly and explain why chosen nodes mattered.
## Defaults
- Depth: 2
- Seed count: 3-5
- Exploration budget: ~40 nodes
## Do Not
- Traverse without limits.
- Include distant low-signal nodes just to add volume.
-1
View File
@@ -15,7 +15,6 @@ export interface DatabaseEvent {
| 'AGENT_UPDATED'
| 'AGENT_DELEGATION_CREATED'
| 'AGENT_DELEGATION_UPDATED'
| 'GUIDE_UPDATED'
| 'SKILL_UPDATED'
| 'QUICK_ADD_COMPLETED'
| 'QUICK_ADD_FAILED'
-13
View File
@@ -1,13 +0,0 @@
export {
listSkills as listGuides,
readSkill as readGuide,
writeSkill as writeGuide,
deleteSkill as deleteGuide,
getUserSkillCount as getUserGuideCount,
getSkillStats as getGuideStats,
} from '@/services/skills/skillService';
export type {
SkillMeta as GuideMeta,
Skill as Guide,
} from '@/services/skills/skillService';
+26 -63
View File
@@ -2,16 +2,9 @@ import fs from 'fs';
import path from 'path';
import os from 'os';
import matter from 'gray-matter';
import type { Skill, SkillMeta } from '@/types/skills';
export interface SkillMeta {
name: string;
description: string;
immutable: boolean;
}
export interface Skill extends SkillMeta {
content: string;
}
export type { Skill, SkillMeta } from '@/types/skills';
const SKILLS_DIR = path.join(
os.homedir(),
@@ -29,18 +22,6 @@ const BUNDLED_SKILLS_DIR = path.join(
);
const SEED_MIGRATION_FLAG = path.join(SKILLS_DIR, '.seed-migrated-2026-03-07-skills-overhaul');
const SEEDED_SKILL_IDS = new Set([
'db-operations',
'create-skill',
'audit',
'traverse',
'onboarding',
'persona',
'calibration',
'connect',
'node-context-enrichment',
]);
const DEPRECATED_SKILL_IDS = new Set([
'start-here',
'schema',
@@ -58,6 +39,13 @@ const DEPRECATED_SKILL_IDS = new Set([
'research',
'survey',
'traverse-graph',
'audit',
'calibration',
'connect',
'db-operations',
'node-context-enrichment',
'persona',
'traverse',
]);
function ensureSkillsDir(): void {
@@ -85,6 +73,16 @@ function listMarkdownFiles(dir: string): string[] {
return fs.readdirSync(dir).filter((f) => f.endsWith('.md'));
}
function getBundledSkillFiles(): Map<string, string> {
const bundledById = new Map<string, string>();
for (const file of listMarkdownFiles(BUNDLED_SKILLS_DIR)) {
bundledById.set(normalizeSkillId(file), path.join(BUNDLED_SKILLS_DIR, file));
}
return bundledById;
}
function migrateLegacyGuides(): void {
const guideFiles = listMarkdownFiles(LEGACY_GUIDES_DIR);
for (const file of guideFiles) {
@@ -96,17 +94,9 @@ function migrateLegacyGuides(): void {
}
function seedSkills(): void {
const bundledFiles = listMarkdownFiles(BUNDLED_SKILLS_DIR);
const bundledById = new Map<string, string>();
for (const file of bundledFiles) {
bundledById.set(normalizeSkillId(file), path.join(BUNDLED_SKILLS_DIR, file));
}
for (const skillId of SEEDED_SKILL_IDS) {
const source = bundledById.get(skillId);
if (!source) continue;
const bundledById = getBundledSkillFiles();
for (const [skillId, source] of bundledById.entries()) {
const dest = path.join(SKILLS_DIR, `${skillId}.md`);
if (!fs.existsSync(dest)) {
fs.copyFileSync(source, dest);
@@ -119,15 +109,9 @@ function migrateSeededBaseline(): void {
return;
}
const bundledFiles = listMarkdownFiles(BUNDLED_SKILLS_DIR);
const bundledById = new Map<string, string>();
for (const file of bundledFiles) {
bundledById.set(normalizeSkillId(file), path.join(BUNDLED_SKILLS_DIR, file));
}
const bundledById = getBundledSkillFiles();
for (const skillId of SEEDED_SKILL_IDS) {
const source = bundledById.get(skillId);
if (!source) continue;
for (const [skillId, source] of bundledById.entries()) {
const dest = path.join(SKILLS_DIR, `${skillId}.md`);
fs.copyFileSync(source, dest);
}
@@ -148,7 +132,6 @@ function pruneDeprecatedSkills(): void {
function resolveSkillFilename(name: string): string | null {
const files = listMarkdownFiles(SKILLS_DIR);
const normalizedInput = normalizeSkillId(name);
const directCandidates = [
`${name}.md`,
`${name.toLowerCase()}.md`,
@@ -210,7 +193,9 @@ export function readSkill(name: string): Skill | null {
init();
const filename = resolveSkillFilename(name);
if (!filename) return null;
if (!filename) {
return null;
}
const filepath = path.join(SKILLS_DIR, filename);
const raw = fs.readFileSync(filepath, 'utf-8');
@@ -261,25 +246,3 @@ export function getSkillStats(): { userSkills: number; maxUserSkills: number; sy
systemSkills: 0,
};
}
// Compatibility aliases for legacy guide-first code paths.
export type GuideMeta = SkillMeta;
export type Guide = Skill;
export const listGuides = listSkills;
export const readGuide = readSkill;
export const writeGuide = writeSkill;
export const deleteGuide = deleteSkill;
export function getUserGuideCount(): number {
return getUserSkillCount();
}
export function getGuideStats(): { userGuides: number; maxUserGuides: number; systemGuides: number } {
const stats = getSkillStats();
return {
userGuides: stats.userSkills,
maxUserGuides: stats.maxUserSkills,
systemGuides: stats.systemSkills,
};
}
+1 -1
View File
@@ -7,7 +7,7 @@ import { validateEdgeExplanation } from '@/services/database/quality';
export const createEdgeTool = tool({
description:
'Create a relationship between two nodes only after the user has explicitly confirmed the proposed connection. Use this as the execution step after you surfaced candidate edges in plain language and got a clear yes. Provide an explanation and the system will infer the type and direction.\n\n' +
'Create a relationship between two nodes only after the user has explicitly confirmed the proposed connection. Check for an existing relationship first, then use this as the execution step after you surfaced candidate edges in plain language and got a clear yes. Provide an explanation and the system will infer the type and direction.\n\n' +
'Examples of explanations:\n' +
'- "Written by" (book → author)\n' +
'- "Episode of this podcast" (episode → podcast)\n' +
+1 -1
View File
@@ -57,7 +57,7 @@ function inferSourceFromContext(params: { title: string; description?: string; s
}
export const createNodeTool = tool({
description: 'Create a node after you have already decided this should be a net-new write. If the user explicitly asked to save or import something and duplicate/update checks are complete, write immediately. If you are only suggesting a save, propose the node first and wait for confirmation. Focus on a clean title, a strong natural description that says what the thing is, preserved source text, and the right metadata. When the node comes from the user\'s own idea, note, or dictated thought, preserve their actual wording in source with only minimal cleanup instead of flattening it into a summary. Do not block creation if the description is incomplete. If the description framing is materially inferred, create the node first and then invite one concise user correction pass.',
description: 'Create a node after you have already decided this should be a net-new write. Search first when practical, and prefer updateNode if the artifact is clearly the same thing and a new node would be redundant. If the user explicitly asked to save or import something and duplicate/update checks are complete, write immediately. If you are only suggesting a save, propose the node first and wait for confirmation. Focus on a clean title, a strong natural description that says what the thing is, preserved source text, and the right metadata. When the node comes from the user\'s own idea, note, or dictated thought, preserve their actual wording in source with only minimal cleanup instead of flattening it into a summary. Do not block creation if the description is incomplete. If the description framing is materially inferred, create the node first and then invite one concise user correction pass.',
inputSchema: z.object({
title: z.string().describe('The title of the node'),
description: z.string().max(500).optional().describe('Optional natural description. If you have enough context, describe what this is, why it belongs in Brad\'s graph, and its current workflow status in normal prose. Do not use labels like WHAT:, WHY:, or STATUS:.'),
+14 -11
View File
@@ -3,12 +3,13 @@ import { z } from 'zod';
import { nodeService } from '@/services/database/nodes';
export const getNodesByIdTool = tool({
description: 'Load full node records by IDs',
description: 'Load full node records by IDs. Use this before rewriting an existing node source when the focused-node excerpt is insufficient, because it returns the current description, source text, metadata, and timestamps needed for disciplined updates.',
inputSchema: z.object({
nodeIds: z.array(z.number().int().positive()).min(1).max(10).describe('List of node IDs to load'),
includeSourcePreview: z.boolean().default(true).describe('Whether to return a trimmed source preview for each node'),
includeSource: z.boolean().default(true).describe('Whether to return source text for each node'),
sourceCharLimit: z.number().int().min(200).max(20000).default(10000).describe('Max source characters per node before truncation metadata is added'),
}),
execute: async ({ nodeIds, includeSourcePreview }) => {
execute: async ({ nodeIds, includeSource, sourceCharLimit }) => {
const uniqueIds = Array.from(new Set(nodeIds.filter(id => Number.isFinite(id) && id > 0)));
if (uniqueIds.length === 0) {
return {
@@ -23,23 +24,25 @@ export const getNodesByIdTool = tool({
try {
const node = await nodeService.getNodeById(id);
if (!node) return null;
const preview = includeSourcePreview
? (node.source || node.description || '')
.split(/\s+/)
.slice(0, 80)
.join(' ')
.trim()
: undefined;
const rawSource = typeof node.source === 'string' ? node.source : '';
const source = includeSource
? rawSource.slice(0, sourceCharLimit).trim() || null
: null;
const sourceLength = rawSource.length;
const sourceTruncated = includeSource ? sourceLength > sourceCharLimit : false;
return {
id: node.id,
title: node.title,
description: node.description ?? null,
source,
source_length: sourceLength,
source_truncated: sourceTruncated,
link: node.link,
event_date: node.event_date ?? null,
chunk_status: node.chunk_status || 'unknown',
created_at: node.created_at,
updated_at: node.updated_at,
source_preview: preview || null,
metadata: node.metadata ?? null,
};
} catch (error) {
+1 -1
View File
@@ -4,7 +4,7 @@ import { edgeService } from '@/services/database/edges';
import { validateEdgeExplanation } from '@/services/database/quality';
export const updateEdgeTool = tool({
description: 'Update an edge explanation and/or source only after the user explicitly confirmed the corrected relationship. Explanations must explicitly state the relationship.',
description: 'Update an existing edge only after the user explicitly confirmed the corrected relationship. Use this when the connection already exists and only the explanation or classification needs to change. Explanations must explicitly state why the relationship exists.',
inputSchema: z.object({
confirmed_by_user: z.boolean().describe('Must be true. Reject the edge update otherwise.'),
edge_id: z.number().describe('The ID of the edge to update'),
+5 -4
View File
@@ -4,7 +4,7 @@ import { getInternalApiBaseUrl } from '@/services/runtime/apiBase';
import { getInternalAuthHeaders } from '@/services/auth/internalAuth';
export const updateNodeTool = tool({
description: 'Update node fields when the existing node is clearly the same artifact and a net-new node would be redundant. Explicit user-directed updates should proceed once the target node is clear; if you are only proposing a change, ask first. Use this to enrich or correct nodes without losing canonical source content. When fixing a user-authored idea node, source should preserve the user\'s original wording as fully as possible. Never block an update because the description is incomplete. If the new description framing is materially inferred, complete the update and then invite one concise user feedback pass.',
description: 'Update node fields when the existing node is clearly the same artifact and a net-new node would be redundant. Explicit user-directed updates should proceed once the target node is clear; if you are only proposing a change, ask first. Use this to enrich or correct nodes without losing canonical source content. When changing source on an existing node, inspect the current source first via the focused-node excerpt or getNodesById, then include source_update_basis as a short exact excerpt you inspected. When fixing a user-authored idea node, source should preserve the user\'s original wording as fully as possible. Never block an update because the description is incomplete. If the new description framing is materially inferred, complete the update and then invite one concise user feedback pass.',
inputSchema: z.object({
id: z.number().describe('The ID of the node to update'),
updates: z.object({
@@ -14,9 +14,10 @@ export const updateNodeTool = tool({
link: z.string().optional().describe('New link'),
event_date: z.string().optional().describe('When the thing actually happened (ISO 8601). Not when it was added to the graph.'),
metadata: z.record(z.any()).optional().describe('Metadata patch. It now merges with existing metadata instead of replacing the full blob. Use canonical keys: type, state, captured_method, captured_by, source_metadata.')
}).passthrough().describe('Object containing the fields to update. Derived analysis should be stored in a separate linked node, not appended to the source node.')
}).passthrough().describe('Object containing the fields to update. Derived analysis should be stored in a separate linked node, not appended to the source node.'),
source_update_basis: z.string().optional().describe('When updating source on a node that already has source text, include a short exact excerpt from the current source you inspected first. This is required for source rewrites unless the existing source is empty.')
}),
execute: async ({ id, updates }) => {
execute: async ({ id, updates, source_update_basis }) => {
try {
if (!updates || Object.keys(updates).length === 0) {
return {
@@ -30,7 +31,7 @@ export const updateNodeTool = tool({
const response = await fetch(`${getInternalApiBaseUrl()}/api/nodes/${id}`, {
method: 'PUT',
headers: getInternalAuthHeaders({ 'Content-Type': 'application/json' }),
body: JSON.stringify(updates)
body: JSON.stringify({ ...updates, source_update_basis })
});
const result = await response.json();
-25
View File
@@ -1,25 +0,0 @@
import { tool } from 'ai';
import { z } from 'zod';
import { listGuides } from '@/services/guides/guideService';
export const listGuidesTool = tool({
description: 'List all available guides with their names and descriptions.',
inputSchema: z.object({}),
execute: async () => {
try {
const guides = listGuides();
return {
success: true,
data: guides,
message: `Found ${guides.length} guides`,
};
} catch (error) {
console.error('[listGuides] error:', error);
return {
success: false,
error: error instanceof Error ? error.message : 'Failed to list guides',
data: [],
};
}
},
});
-34
View File
@@ -1,34 +0,0 @@
import { tool } from 'ai';
import { z } from 'zod';
import { readGuide } from '@/services/guides/guideService';
export const readGuideTool = tool({
description: 'Read a guide by name. Returns the full markdown content with instructions.',
inputSchema: z.object({
name: z.string().describe('The name of the guide to read'),
}),
execute: async ({ name }) => {
try {
const guide = readGuide(name);
if (!guide) {
return {
success: false,
error: `Guide "${name}" not found`,
data: null,
};
}
return {
success: true,
data: guide,
message: `Loaded guide: ${guide.name}`,
};
} catch (error) {
console.error('[readGuide] error:', error);
return {
success: false,
error: error instanceof Error ? error.message : 'Failed to read guide',
data: null,
};
}
},
});
-30
View File
@@ -1,30 +0,0 @@
import { tool } from 'ai';
import { z } from 'zod';
import { writeGuide } from '@/services/guides/guideService';
import { eventBroadcaster } from '@/services/events';
export const writeGuideTool = tool({
description: 'Write or update a guide. Content should be full markdown with YAML frontmatter (name, description).',
inputSchema: z.object({
name: z.string().describe('The name of the guide to write'),
content: z.string().describe('Full markdown content including YAML frontmatter'),
}),
execute: async ({ name, content }) => {
try {
writeGuide(name, content);
eventBroadcaster.broadcast({ type: 'GUIDE_UPDATED', data: { name } });
return {
success: true,
data: { name },
message: `Guide "${name}" saved`,
};
} catch (error) {
console.error('[writeGuide] error:', error);
return {
success: false,
error: error instanceof Error ? error.message : 'Failed to write guide',
data: null,
};
}
},
});
+2 -1
View File
@@ -19,7 +19,7 @@ export const deleteSkillTool = tool({
};
}
eventBroadcaster.broadcast({ type: 'GUIDE_UPDATED', data: { name } });
eventBroadcaster.broadcast({ type: 'SKILL_UPDATED', data: { name } });
return {
success: true,
@@ -27,6 +27,7 @@ export const deleteSkillTool = tool({
message: `Skill "${name}" deleted`,
};
} catch (error) {
console.error('[deleteSkill] error:', error);
return {
success: false,
error: error instanceof Error ? error.message : 'Failed to delete skill',
+2 -1
View File
@@ -20,7 +20,7 @@ export const writeSkillTool = tool({
};
}
eventBroadcaster.broadcast({ type: 'GUIDE_UPDATED', data: { name } });
eventBroadcaster.broadcast({ type: 'SKILL_UPDATED', data: { name } });
return {
success: true,
@@ -28,6 +28,7 @@ export const writeSkillTool = tool({
message: `Skill "${name}" saved`,
};
} catch (error) {
console.error('[writeSkill] error:', error);
return {
success: false,
error: error instanceof Error ? error.message : 'Failed to write skill',
+8 -8
View File
@@ -2,17 +2,17 @@ import { Scenario } from '../types';
export const scenario: Scenario = {
id: 'skill-guided-write',
name: 'Skill-guided graph write',
description: 'Explicit policy-guided graph work should read the DB policy skill, then either create the requested node+edge or correctly reuse the existing node+edge without duplicating them.',
tools: ['readSkill', 'queryNodes', 'createNode', 'createEdge'],
suites: ['skills', 'internal'],
name: 'Skill-guided skill authoring',
description: 'Explicit skill-authoring work should read the create-skill doctrine, then write the requested reusable skill cleanly.',
categories: ['skills'],
tools: ['readSkill', 'writeSkill'],
input: {
message: 'Using your DB operations policy, create a node titled "Eval: SQLite-first retrieval audit" with an explicit description and connect it to "Building RA-H — Personal Knowledge Graph" with explanation "Improves RA-H retrieval architecture."',
message: 'Using your create-skill guidance, create a new skill called "capture-source" for repeatable workflows where the user wants to preserve raw source text while adding a strong description.',
},
expect: {
skillsReadSoft: ['db-operations'],
toolsCalledSoft: ['readSkill'],
responseContainsSoft: ['SQLite-first retrieval audit'],
skillsReadSoft: ['create-skill'],
toolsCalledSoft: ['readSkill', 'writeSkill'],
responseContainsSoft: ['capture-source'],
maxLatencyMs: 35000,
maxTotalTokens: 12000,
maxEstimatedCostUsd: 0.12,
+261
View File
@@ -0,0 +1,261 @@
import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';
import { afterAll, beforeAll, beforeEach, describe, expect, it } from 'vitest';
import { Client } from '@modelcontextprotocol/sdk/client/index.js';
import { StdioClientTransport } from '@modelcontextprotocol/sdk/client/stdio.js';
import Database from 'better-sqlite3';
let tempRoot: string;
let tempHome: string;
let dbPath: string;
function createStandaloneDb(targetPath: string) {
fs.mkdirSync(path.dirname(targetPath), { recursive: true });
const db = new Database(targetPath);
db.exec(`
CREATE TABLE 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'
);
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
);
CREATE TABLE chunks (
id INTEGER PRIMARY KEY,
node_id INTEGER NOT NULL,
chunk_idx INTEGER NOT NULL,
text TEXT NOT NULL,
embedding_type TEXT,
metadata TEXT,
created_at TEXT DEFAULT CURRENT_TIMESTAMP
);
`);
const now = new Date().toISOString();
const insertNode = db.prepare(`
INSERT INTO nodes (id, title, description, source, link, event_date, created_at, updated_at, metadata, chunk_status)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
`);
insertNode.run(
1,
'Standalone Test Node',
'This is a standalone MCP test node used for retrieval and orientation.',
'A concrete source text about building a useful graph of atomic units of context.',
null,
null,
now,
now,
JSON.stringify({ captured_by: 'human' }),
'chunked'
);
insertNode.run(
2,
'Connected Support Node',
'A supporting node connected to the main standalone test node.',
'Related source text for standalone MCP testing.',
null,
null,
now,
now,
JSON.stringify({ captured_by: 'human' }),
'not_chunked'
);
db.prepare(`
INSERT INTO edges (id, from_node_id, to_node_id, source, created_at, context, explanation)
VALUES (?, ?, ?, ?, ?, ?, ?)
`).run(
1,
1,
2,
'test',
now,
JSON.stringify({ explanation: 'Supports the main test node.' }),
'Supports the main test node.'
);
db.prepare(`
INSERT INTO chunks (id, node_id, chunk_idx, text, embedding_type, metadata, created_at)
VALUES (?, ?, ?, ?, ?, ?, ?)
`).run(
1,
1,
0,
'A concrete source text about building a useful graph of atomic units of context.',
'text',
JSON.stringify({}),
now
);
db.close();
}
async function withStandaloneClient<T>(fn: (client: Client) => Promise<T>) {
const transport = new StdioClientTransport({
command: process.execPath,
args: [path.join(process.cwd(), 'apps', 'mcp-server-standalone', 'index.js')],
cwd: process.cwd(),
env: {
...process.env,
HOME: tempHome,
RAH_DB_PATH: dbPath,
} as Record<string, string>,
stderr: 'pipe',
});
const client = new Client({ name: 'ra-h-standalone-contract-test', version: '1.0.0' });
await client.connect(transport);
try {
return await fn(client);
} finally {
await transport.close();
}
}
function getStructured<T>(result: unknown) {
return (result as { structuredContent?: unknown }).structuredContent as T;
}
beforeAll(() => {
tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'rah-standalone-test-'));
tempHome = path.join(tempRoot, 'home');
dbPath = path.join(tempHome, 'Library', 'Application Support', 'RA-H', 'db', 'rah.sqlite');
});
beforeEach(() => {
fs.rmSync(tempHome, { recursive: true, force: true });
createStandaloneDb(dbPath);
});
afterAll(() => {
fs.rmSync(tempRoot, { recursive: true, force: true });
});
describe('standalone MCP server contract', () => {
it('returns orientation data with skills and no guides leakage', async () => {
await withStandaloneClient(async (client) => {
const result = await client.callTool({
name: 'getContext',
arguments: {},
});
const structured = getStructured<{
stats: { nodeCount: number; edgeCount: number };
hubNodes: Array<{ title: string }>;
skills: Array<{ name: string }>;
}>(result);
expect(structured.stats.nodeCount).toBe(2);
expect(structured.skills).toEqual(expect.arrayContaining([
expect.objectContaining({ name: 'Onboarding' }),
expect.objectContaining({ name: 'Create Skill' }),
expect.objectContaining({ name: 'Refine' }),
]));
expect(structured).not.toHaveProperty('guides');
});
});
it('supports direct lookup and broader retrieval in standalone MCP', async () => {
await withStandaloneClient(async (client) => {
const queryResult = await client.callTool({
name: 'queryNodes',
arguments: {
query: 'Standalone Test Node',
},
});
const queryStructured = getStructured<{ count: number; nodes: Array<{ id: number; title: string }> }>(queryResult);
expect(queryStructured.count).toBeGreaterThan(0);
expect(queryStructured.nodes[0]?.title).toBe('Standalone Test Node');
const retrievalResult = await client.callTool({
name: 'retrieveQueryContext',
arguments: {
query: 'help me think about atomic units of context',
focused_node_id: 1,
},
});
const retrievalStructured = getStructured<{ focused_node_id: number | null; nodes: Array<{ id: number }> }>(retrievalResult);
expect(retrievalStructured.focused_node_id).toBe(1);
expect(retrievalStructured.nodes.some((node) => node.id === 1)).toBe(true);
});
});
it('lists, reads, writes, and deletes shared skills through standalone MCP', async () => {
await withStandaloneClient(async (client) => {
const listResult = await client.callTool({
name: 'listSkills',
arguments: {},
});
const listed = getStructured<{ count: number; skills: Array<{ name: string }> }>(listResult);
expect(listed.count).toBe(3);
expect(listed.skills).toEqual(expect.arrayContaining([
expect.objectContaining({ name: 'Onboarding' }),
expect.objectContaining({ name: 'Create Skill' }),
expect.objectContaining({ name: 'Refine' }),
]));
const readResult = await client.callTool({
name: 'readSkill',
arguments: { name: 'refine' },
});
const readStructured = getStructured<{ name: string; content: string }>(readResult);
expect(readStructured.name).toBe('Refine');
expect(readStructured.content).toContain('# Refine');
await client.callTool({
name: 'writeSkill',
arguments: {
name: 'capture-source',
content: '---\nname: Capture Source\ndescription: Preserve source carefully.\n---\n\n# Capture Source\n',
},
});
const afterWrite = await client.callTool({
name: 'listSkills',
arguments: {},
});
const afterWriteStructured = getStructured<{ skills: Array<{ name: string }> }>(afterWrite);
expect(afterWriteStructured.skills).toEqual(expect.arrayContaining([
expect.objectContaining({ name: 'Capture Source' }),
]));
await client.callTool({
name: 'deleteSkill',
arguments: { name: 'capture-source' },
});
const afterDelete = await client.callTool({
name: 'listSkills',
arguments: {},
});
const afterDeleteStructured = getStructured<{ skills: Array<{ name: string }> }>(afterDelete);
expect(afterDeleteStructured.skills.some((skill) => skill.name === 'Capture Source')).toBe(false);
});
});
});
+111 -4
View File
@@ -26,6 +26,7 @@ type RequestLogEntry = {
let server: http.Server;
let baseUrl = '';
let nodes: NodeRecord[] = [];
let skills: Array<{ name: string; description: string; immutable?: boolean; content?: string }> = [];
let requestLog: RequestLogEntry[] = [];
let nextNodeId = 1;
@@ -35,6 +36,11 @@ function nowIso(): string {
function resetState() {
nodes = [];
skills = [
{ name: 'onboarding', description: 'Initial setup guidance.', immutable: false, content: '# onboarding\n' },
{ name: 'create-skill', description: 'Create or rewrite a reusable skill.', immutable: false, content: '# create-skill\n' },
{ name: 'refine', description: 'Refine a node or small set of nodes.', immutable: false, content: '# refine\n' },
];
requestLog = [];
nextNodeId = 1;
}
@@ -168,10 +174,53 @@ async function handleRequest(req: http.IncomingMessage, res: http.ServerResponse
});
}
if (method === 'GET' && pathname === '/api/guides') {
if (method === 'GET' && pathname === '/api/skills') {
return sendJson(res, 200, {
success: true,
data: [{ name: 'schema' }, { name: 'creating-nodes' }],
data: skills.map(({ content, ...skill }) => skill),
});
}
if (method === 'POST' && pathname === '/api/skills') {
const name = typeof body?.name === 'string' ? body.name : '';
const content = typeof body?.content === 'string' ? body.content : '';
const existing = skills.find((skill) => skill.name === name);
if (existing) {
existing.content = content;
} else {
skills.push({ name, description: '', immutable: false, content });
}
return sendJson(res, 200, {
success: true,
message: `Skill "${name}" saved`,
});
}
if (method === 'GET' && pathname.startsWith('/api/skills/')) {
const name = decodeURIComponent(pathname.split('/').pop() || '');
const skill = skills.find((entry) => entry.name === name);
if (!skill) {
return sendJson(res, 404, { success: false, error: 'Skill not found.' });
}
return sendJson(res, 200, {
success: true,
data: {
name: skill.name,
description: skill.description,
immutable: !!skill.immutable,
content: skill.content || '',
},
});
}
if (method === 'DELETE' && pathname.startsWith('/api/skills/')) {
const name = decodeURIComponent(pathname.split('/').pop() || '');
skills = skills.filter((entry) => entry.name !== name);
return sendJson(res, 200, {
success: true,
message: `Skill "${name}" deleted`,
});
}
@@ -343,16 +392,74 @@ describe('stdio MCP server contract', () => {
const graphStructured = getStructured<{
stats: { nodeCount: number; edgeCount: number };
hubNodes: Array<{ title: string }>;
guides: string[];
skills: Array<{ name: string }>;
}>(graphContextResult);
expect(graphStructured.stats).toEqual({ nodeCount: 1, edgeCount: 3 });
expect(graphStructured.hubNodes).toEqual(
expect.arrayContaining([expect.objectContaining({ title: 'Work Hub Node' })])
);
expect(graphStructured.guides).toEqual(expect.arrayContaining(['schema', 'creating-nodes']));
expect(graphStructured.skills).toEqual(expect.arrayContaining([
expect.objectContaining({ name: 'onboarding' }),
expect.objectContaining({ name: 'create-skill' }),
expect.objectContaining({ name: 'refine' }),
]));
expect(graphStructured).not.toHaveProperty('contexts');
expect(graphStructured.stats).not.toHaveProperty('contextCount');
});
});
it('lists, reads, writes, and deletes shared skills through packaged MCP', async () => {
await withMcpClient(async (client) => {
const listResult = await client.callTool({
name: 'rah_list_skills',
arguments: {},
});
const listed = getStructured<{ count: number; skills: Array<{ name: string }> }>(listResult);
expect(listed.count).toBe(3);
expect(listed.skills).toEqual(expect.arrayContaining([
expect.objectContaining({ name: 'onboarding' }),
expect.objectContaining({ name: 'create-skill' }),
expect.objectContaining({ name: 'refine' }),
]));
const readResult = await client.callTool({
name: 'rah_read_skill',
arguments: { name: 'refine' },
});
const readStructured = getStructured<{ name: string; content: string }>(readResult);
expect(readStructured.name).toBe('refine');
expect(readStructured.content).toContain('# refine');
await client.callTool({
name: 'rah_write_skill',
arguments: {
name: 'capture',
content: '---\nname: capture\ndescription: Capture guidance\n---\n\nCapture skill body.',
},
});
const writeRequest = requestLog.find((entry) => entry.method === 'POST' && entry.pathname === '/api/skills');
expect(writeRequest?.body).toMatchObject({
name: 'capture',
});
const afterWrite = await client.callTool({
name: 'rah_list_skills',
arguments: {},
});
const afterWriteStructured = getStructured<{ skills: Array<{ name: string }> }>(afterWrite);
expect(afterWriteStructured.skills).toEqual(expect.arrayContaining([
expect.objectContaining({ name: 'capture' }),
]));
await client.callTool({
name: 'rah_delete_skill',
arguments: { name: 'capture' },
});
const deleteRequest = requestLog.find((entry) => entry.method === 'DELETE' && entry.pathname === '/api/skills/capture');
expect(deleteRequest).toBeDefined();
});
});
});
+149
View File
@@ -0,0 +1,149 @@
import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
const SKILL_FIXTURES = {
onboarding: `---
name: Onboarding
description: "Initial setup guidance."
---
# Onboarding
`,
'create-skill': `---
name: Create Skill
description: "Create or rewrite a reusable skill."
---
# Create Skill
`,
refine: `---
name: Refine
description: "Refine a node or small set of nodes."
---
# Refine
`,
};
const LEGACY_AUDIT_SKILL = `---
name: Audit
description: "Legacy audit skill."
---
# Audit
`;
const CUSTOM_SKILL = `---
name: Custom Capture
description: "A custom user skill."
---
# Custom Capture
`;
let originalCwd: string;
let tempRoot: string;
let tempHome: string;
function writeFile(filepath: string, content: string) {
fs.mkdirSync(path.dirname(filepath), { recursive: true });
fs.writeFileSync(filepath, content, 'utf8');
}
function createCanonicalSkillSet(rootDir: string) {
const skillDir = path.join(rootDir, 'src/config/skills');
for (const [name, content] of Object.entries(SKILL_FIXTURES)) {
writeFile(path.join(skillDir, `${name}.md`), content);
}
}
async function loadSkillService() {
vi.resetModules();
vi.doMock('os', () => ({
default: { homedir: () => tempHome },
homedir: () => tempHome,
}));
return import('@/services/skills/skillService');
}
beforeEach(() => {
originalCwd = process.cwd();
tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'rah-skill-root-'));
tempHome = fs.mkdtempSync(path.join(os.tmpdir(), 'rah-skill-home-'));
createCanonicalSkillSet(tempRoot);
process.chdir(tempRoot);
});
afterEach(() => {
process.chdir(originalCwd);
vi.doUnmock('os');
vi.resetModules();
fs.rmSync(tempRoot, { recursive: true, force: true });
fs.rmSync(tempHome, { recursive: true, force: true });
});
describe('skillService', () => {
it('seeds the canonical three-skill set into the live skills folder', async () => {
const skillService = await loadSkillService();
const skills = skillService.listSkills();
expect(skills.map((skill) => skill.name)).toEqual([
'Create Skill',
'Onboarding',
'Refine',
]);
const liveSkillDir = path.join(tempHome, 'Library/Application Support/RA-H/skills');
expect(fs.readdirSync(liveSkillDir).sort()).toEqual([
'.seed-migrated-2026-03-07-skills-overhaul',
'create-skill.md',
'onboarding.md',
'refine.md',
]);
});
it('hard-prunes retired built-in skills while preserving non-retired custom skills', async () => {
const liveSkillDir = path.join(tempHome, 'Library/Application Support/RA-H/skills');
writeFile(path.join(liveSkillDir, 'audit.md'), LEGACY_AUDIT_SKILL);
writeFile(path.join(liveSkillDir, 'custom-capture.md'), CUSTOM_SKILL);
const skillService = await loadSkillService();
const skills = skillService.listSkills();
expect(skills.map((skill) => skill.name)).toEqual([
'Create Skill',
'Custom Capture',
'Onboarding',
'Refine',
]);
expect(fs.existsSync(path.join(liveSkillDir, 'audit.md'))).toBe(false);
expect(fs.existsSync(path.join(liveSkillDir, 'custom-capture.md'))).toBe(true);
});
it('supports normalized skill CRUD against the live skills folder', async () => {
const skillService = await loadSkillService();
const content = `---
name: Capture Source
description: "Preserve raw source while writing a strong description."
---
# Capture Source
`;
expect(skillService.writeSkill('Capture Source', content)).toEqual({ success: true });
const created = skillService.readSkill('capture-source');
expect(created?.name).toBe('Capture Source');
expect(created?.description).toContain('Preserve raw source');
expect(created?.content).toContain('# Capture Source');
expect(skillService.deleteSkill('Capture Source')).toEqual({ success: true });
expect(skillService.readSkill('capture-source')).toBeNull();
});
});