feat: sync shared skills contract and graph-first discipline

This commit is contained in:
“BeeRad”
2026-04-17 20:58:21 +10:00
parent 5f4d1f61c2
commit 7e663abfb0
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 - `README.md` - product overview + quick start
- `docs/README.md` - docs index - `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/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/process.md` - contributor process
- `docs/development/docs-process.md` - docs maintenance process - `docs/development/docs-process.md` - docs maintenance process
@@ -48,4 +48,3 @@ npm run build
- `ra-h_os` accepts direct contributions. - `ra-h_os` accepts direct contributions.
- Maintainers may sync relevant changes between public and private repos. - Maintainers may sync relevant changes between public and private repos.
- Public contributions should remain attributable and not be overwritten. - Public contributions should remain attributable and not be overwritten.
+2 -2
View File
@@ -25,7 +25,7 @@ npm run dev
## Key Paths ## Key Paths
- `src/services/database/` - data layer - `src/services/database/` - data layer
- `src/tools/` - MCP tool implementations - `src/tools/` - MCP tool implementations
- `src/config/guides/` - built-in guide content - `src/config/skills/` - built-in skill content
- `app/api/` - API routes - `app/api/` - API routes
## Docs ## Docs
@@ -34,4 +34,4 @@ npm run dev
- `docs/2_schema.md` - `docs/2_schema.md`
- `docs/4_tools-and-guides.md` - `docs/4_tools-and-guides.md`
- `docs/6_ui.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. **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: Available tools:
| Tool | What it does | | 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 | | `retrieveQueryContext` | Pull relevant graph context for a broader current-turn task |
| `queryNodes` | Find nodes by keyword | | `queryNodes` | Find nodes by keyword |
| `createNode` | Create a new node | | `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 = [ const instructions = [
'RA-H is a personal knowledge graph — local-first, vendor-neutral.', 'RA-H is a personal knowledge graph — local-first, vendor-neutral.',
'Core concepts: 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 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.', '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?', 'Every edge needs an explanation: why does this connection exist?',
'Never create or update an edge unless the user has explicitly confirmed the relationship.', 'Never create or update an edge unless the user has explicitly confirmed the relationship.',
].join(' '); ].join(' ');
@@ -182,9 +186,10 @@ function createServer(request: NextRequest): McpServer {
link: z.string().optional(), link: z.string().optional(),
metadata: z.record(z.any()).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) { if (!updates || Object.keys(updates).length === 0) {
throw new Error('At least one field must be provided in updates.'); 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}`, { const payload = await callRaHApi(request, `/api/nodes/${id}`, {
method: 'PUT', method: 'PUT',
body: JSON.stringify(mappedUpdates), body: JSON.stringify({ ...mappedUpdates, source_update_basis }),
}); });
return { 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( server.registerTool(
'rah_search_embeddings', 'rah_search_embeddings',
{ {
@@ -483,13 +579,13 @@ function createServer(request: NextRequest): McpServer {
'rah_get_context', 'rah_get_context',
{ {
title: 'Get RA-H context', title: 'Get RA-H context',
description: 'Get orientation context: high-level graph state, 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: {}, inputSchema: {},
}, },
async () => { 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/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/nodes?limit=1').catch(() => ({ total: 0, count: 0 })),
callRaHApi(request, '/api/edges?limit=1').catch(() => ({ count: 0, total: 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, 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 nodeCount = countPayload.total ?? countPayload.count ?? 0;
const edgeCount = edgesPayload.total ?? edgesPayload.count ?? 0; const edgeCount = edgesPayload.total ?? edgesPayload.count ?? 0;
return { 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: { structuredContent: {
stats: { stats: {
nodeCount, nodeCount,
edgeCount, edgeCount,
}, },
hubNodes, hubNodes,
guides, skills,
}, },
}; };
} }
+1 -48
View File
@@ -1,5 +1,5 @@
import { NextRequest, NextResponse } from 'next/server'; 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'; 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 { NextRequest, NextResponse } from 'next/server';
import { listSkills } from '@/services/skills/skillService'; import { listSkills, writeSkill } from '@/services/skills/skillService';
export const runtime = 'nodejs'; 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() { function buildInstructions() {
const now = new Date().toISOString().split('T')[0]; 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 { try {
const skills = skillService.listSkills(); const skills = skillService.listSkills();
@@ -61,11 +65,12 @@ function buildInstructions() {
## Quick start ## Quick start
1. If the user is trying to find a specific existing node, call queryNodes first. 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. 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. 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. 5. Search before creating, and prefer updateNode when the artifact is clearly the same thing.
6. For complex tasks, call readSkill("db-operations"). 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 ## Knowledge capture
Only suggest saving durable knowledge when it seems unusually durable and valuable. Only suggest saving durable knowledge when it seems unusually durable and valuable.
@@ -142,7 +147,7 @@ const queryEdgesInputSchema = {
}; };
const readSkillInputSchema = { const readSkillInputSchema = {
name: z.string().min(1).describe('Skill name (e.g. "db-operations", "onboarding", "persona")') name: z.string().min(1).describe('Skill name (e.g. "onboarding", "create-skill", "refine")')
}; };
const writeSkillInputSchema = { const writeSkillInputSchema = {
@@ -246,14 +251,13 @@ async function main() {
'getContext', 'getContext',
{ {
title: 'Get RA-H context', 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: {} inputSchema: {}
}, },
async () => { async () => {
const context = nodeService.getContext(); const context = nodeService.getContext();
const skills = skillService.listSkills(); const skills = skillService.listSkills();
context.skills = skills.map(s => ({ name: s.name, description: s.description, immutable: s.immutable })); context.skills = skills.map(s => ({ name: s.name, description: s.description, immutable: s.immutable }));
context.guides = context.skills;
// First-run welcome message // First-run welcome message
if (context.stats.nodeCount === 0) { 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 { return {
content: [{ type: 'text', text: summary }], content: [{ type: 'text', text: summary }],
structuredContent: context structuredContent: context
@@ -553,15 +557,13 @@ async function main() {
} }
); );
// ========== DIMENSION TOOLS ==========
// ========== SKILL TOOLS ========== // ========== SKILL TOOLS ==========
registerToolWithAliases( registerToolWithAliases(
'listSkills', 'listSkills',
{ {
title: 'List RA-H skills', 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: {} inputSchema: {}
}, },
async () => { async () => {
@@ -571,8 +573,7 @@ async function main() {
content: [{ type: 'text', text: `Found ${skills.length} skill(s).` }], content: [{ type: 'text', text: `Found ${skills.length} skill(s).` }],
structuredContent: { structuredContent: {
count: skills.length, count: skills.length,
skills, skills
guides: skills
} }
}; };
} }
@@ -582,7 +583,7 @@ async function main() {
'readSkill', 'readSkill',
{ {
title: 'Read RA-H skill', 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 inputSchema: readSkillInputSchema
}, },
async ({ name }) => { async ({ name }) => {
@@ -603,7 +604,7 @@ async function main() {
'writeSkill', 'writeSkill',
{ {
title: 'Write RA-H skill', 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 inputSchema: writeSkillInputSchema
}, },
async ({ name, content }) => { async ({ name, content }) => {
@@ -628,7 +629,7 @@ async function main() {
'deleteSkill', 'deleteSkill',
{ {
title: 'Delete RA-H skill', 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 inputSchema: deleteSkillInputSchema
}, },
async ({ name }) => { async ({ name }) => {
@@ -770,7 +771,7 @@ async function main() {
'sqliteQuery', 'sqliteQuery',
{ {
title: 'Execute read-only SQL', title: 'Execute read-only SQL',
description: 'Execute read-only SQL queries against the knowledge graph database. Tables: nodes, 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 inputSchema: sqliteQueryInputSchema
}, },
async ({ sql: userSql, format = 'json' }) => { async ({ sql: userSql, format = 'json' }) => {
+1 -1
View File
@@ -10,7 +10,7 @@
"start": "node index.js", "start": "node index.js",
"validate:syntax": "node --check index.js && node --check services/*.js", "validate:syntax": "node --check index.js && node --check services/*.js",
"validate:publish": "npm run validate:syntax && npm pack --dry-run >/dev/null", "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": [ "keywords": [
"mcp", "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'); 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) { function parseMetadata(metadata) {
if (!metadata) return {}; if (!metadata) return {};
if (typeof metadata === 'string') { 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. * Get nodes with optional filtering.
*/ */
@@ -105,16 +366,6 @@ function getNodes(filters = {}) {
return rows.map(mapNodeRow); 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. * 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 BUNDLED_SKILLS_DIR = path.join(__dirname, '..', 'skills');
const SEED_MIGRATION_FLAG = path.join(SKILLS_DIR, '.seed-migrated-2026-03-07-skills-overhaul'); 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([ const DEPRECATED_SKILL_IDS = new Set([
'start-here', 'start-here',
'schema', 'schema',
@@ -46,6 +34,14 @@ const DEPRECATED_SKILL_IDS = new Set([
'research', 'research',
'survey', 'survey',
'traverse-graph', 'traverse-graph',
'context-capsule',
'audit',
'calibration',
'connect',
'db-operations',
'node-context-enrichment',
'persona',
'traverse',
]); ]);
function parseFrontmatter(raw) { function parseFrontmatter(raw) {
@@ -97,6 +93,16 @@ function listMarkdownFiles(dir) {
return fs.readdirSync(dir).filter((f) => f.endsWith('.md')); 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() { function ensureSkillsDir() {
if (!fs.existsSync(SKILLS_DIR)) { if (!fs.existsSync(SKILLS_DIR)) {
fs.mkdirSync(SKILLS_DIR, { recursive: true }); fs.mkdirSync(SKILLS_DIR, { recursive: true });
@@ -116,15 +122,8 @@ function migrateLegacyGuides() {
function seedSkills() { function seedSkills() {
ensureSkillsDir(); ensureSkillsDir();
const bundledFiles = listMarkdownFiles(BUNDLED_SKILLS_DIR); const bundledById = getBundledSkillFiles();
const bundledById = new Map(); for (const [skillId, source] of bundledById.entries()) {
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 dest = path.join(SKILLS_DIR, `${skillId}.md`); const dest = path.join(SKILLS_DIR, `${skillId}.md`);
if (!fs.existsSync(dest)) { if (!fs.existsSync(dest)) {
fs.copyFileSync(source, dest); fs.copyFileSync(source, dest);
@@ -137,15 +136,8 @@ function migrateSeededBaseline() {
return; return;
} }
const bundledFiles = listMarkdownFiles(BUNDLED_SKILLS_DIR); const bundledById = getBundledSkillFiles();
const bundledById = new Map(); for (const [skillId, source] of bundledById.entries()) {
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 dest = path.join(SKILLS_DIR, `${skillId}.md`); const dest = path.join(SKILLS_DIR, `${skillId}.md`);
fs.copyFileSync(source, dest); 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 name: Create Skill
description: "Design or refine a skill using a tight trigger, explicit contract, and measurable outcomes." 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."
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."
--- ---
# Create 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. - The user asks to create, rewrite, merge, or remove a skill.
2. Define required outputs and quality bar. - The user keeps describing a repeatable workflow that should become reusable doctrine.
3. Specify concrete execution sequence. - An existing skill is too vague, too broad, or overlaps with another skill enough that it should be reworked.
4. Add hard guardrails (what to reject/avoid).
5. Keep it short; remove fluff and duplicate policy text.
## 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` - `name`
- `description` - `description`
- `when_to_use` - one short goal section
- `when_not_to_use` - trigger / use cases
- `success_criteria` - step-by-step workflow
- Step-by-step procedure - tool pointers when relevant
- Do-not list - quality bar
- do-not list
## Validation Checklist ## Tool Pointers
- Can another agent execute this without guessing? When tool choice matters, point to the matching family for the current surface instead of pretending every agent has the same names:
- Does it avoid overlap with existing skills?
- Are failure modes explicit? - Internal / standalone skill tools:
- Is there an obvious way to evaluate success? - `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 ## 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 ## 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. 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. Do your best to build the graph as useful context emerges.
- Add nodes when the user mentions concrete things worth keeping. - Add nodes when the user mentions concrete things worth keeping.
- 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. - Explain what you're adding in plain language so the user understands the structure as it develops.
- During normal conversation outside explicit onboarding capture, do not keep asking to save every useful statement. Only suggest a save when the context is unusually durable and valuable, and keep the prompt brief.
When the graph is empty or nearly empty, bias toward creating a small, clean starter set rather than over-modeling everything. When the graph is empty or nearly empty, bias toward creating a small, clean starter set rather than over-modeling everything.
## Write Standards ## 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 - Search before creating — avoid duplicates from day one
- Every description must be concrete: what it IS and why it matters to them, not what it "explores" or "discusses" - Every description must be concrete: what it IS and why it matters to them, not what it "explores" or "discusses"
- 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 ## 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 = [ const instructions = [
'RA-H is a personal knowledge graph — local-first, vendor-neutral.', 'RA-H is a personal knowledge graph — local-first, vendor-neutral.',
'Core concepts: 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 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.', 'Use rah_get_context only when high-level graph orientation would actually help.',
'Do not keep re-running retrieval if you already have enough relevant graph context in play.', 'Do not keep re-running retrieval if you already have enough relevant graph context in play.',
'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?', '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.', 'Do not create edges autonomously. Surface likely edge candidates briefly, then call edge-write tools only after the user explicitly confirms.',
'Every edge needs an explanation: why does this connection exist?', 'Every edge needs an explanation: why does this connection exist?',
@@ -151,7 +153,8 @@ const updateNodeInputSchema = {
source: z.string().optional().describe('Canonical source text for embedding.'), source: z.string().optional().describe('Canonical source text for embedding.'),
link: z.string().optional().describe('New link'), link: z.string().optional().describe('New link'),
metadata: z.record(z.any()).optional().describe('Metadata patch. This now merges with existing metadata. Prefer canonical keys: type, state, captured_method, captured_by, source_metadata.') metadata: z.record(z.any()).optional().describe('Metadata patch. This now merges with existing metadata. Prefer canonical keys: type, state, captured_method, captured_by, source_metadata.')
}).describe('Fields to update') }).describe('Fields to update'),
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 = { const updateNodeOutputSchema = {
@@ -172,12 +175,56 @@ const getNodesOutputSchema = {
id: z.number(), id: z.number(),
title: z.string(), title: z.string(),
source: z.string().nullable(), source: z.string().nullable(),
description: z.string().nullable(),
link: z.string().nullable(), link: z.string().nullable(),
updated_at: z.string() 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 // rah_create_edge schemas
const createEdgeInputSchema = { const createEdgeInputSchema = {
sourceId: z.number().int().positive().describe('Source node ID'), sourceId: z.number().int().positive().describe('Source node ID'),
@@ -327,7 +374,7 @@ mcpServer.registerTool(
'rah_add_node', 'rah_add_node',
{ {
title: 'Add RA-H node', title: 'Add RA-H node',
description: 'Create a new node in the local RA-H knowledge base after you have already decided a net-new write is correct. If the user explicitly asked to save or import something and the target artifact is clear, write after duplicate/update checks. If you are only suggesting a save, propose the node first and wait for confirmation.', 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, inputSchema: addNodeInputSchema,
outputSchema: addNodeOutputSchema outputSchema: addNodeOutputSchema
}, },
@@ -363,7 +410,7 @@ mcpServer.registerTool(
'rah_search_nodes', 'rah_search_nodes',
{ {
title: 'Search RA-H nodes', title: 'Search RA-H nodes',
description: 'Find existing RA-H entries that mention a topic before adding new ones. 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, inputSchema: searchNodesInputSchema,
outputSchema: searchNodesOutputSchema outputSchema: searchNodesOutputSchema
}, },
@@ -406,7 +453,7 @@ mcpServer.registerTool(
'rah_retrieve_query_context', 'rah_retrieve_query_context',
{ {
title: 'Retrieve RA-H 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, inputSchema: retrieveQueryContextInputSchema,
outputSchema: retrieveQueryContextOutputSchema outputSchema: retrieveQueryContextOutputSchema
}, },
@@ -431,11 +478,11 @@ mcpServer.registerTool(
'rah_update_node', 'rah_update_node',
{ {
title: 'Update RA-H node', title: 'Update RA-H node',
description: 'Update an existing node when it is clearly the same artifact and a net-new node would be redundant. Explicit user-directed updates can proceed once the target node is clear.', 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, inputSchema: updateNodeInputSchema,
outputSchema: updateNodeOutputSchema outputSchema: updateNodeOutputSchema
}, },
async ({ id, updates }) => { async ({ id, updates, source_update_basis }) => {
if (!updates || Object.keys(updates).length === 0) { if (!updates || Object.keys(updates).length === 0) {
throw new McpError(ErrorCode.InvalidParams, 'At least one field must be provided in updates.'); 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}`, { const result = await callRaHApi(`/api/nodes/${id}`, {
method: 'PUT', method: 'PUT',
body: JSON.stringify(mappedUpdates) body: JSON.stringify({ ...mappedUpdates, source_update_basis })
}); });
const node = result.node || result.data; const node = result.node || result.data;
@@ -472,7 +519,7 @@ mcpServer.registerTool(
'rah_get_nodes', 'rah_get_nodes',
{ {
title: 'Get RA-H nodes by ID', 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, inputSchema: getNodesInputSchema,
outputSchema: getNodesOutputSchema outputSchema: getNodesOutputSchema
}, },
@@ -491,6 +538,7 @@ mcpServer.registerTool(
id: result.node.id, id: result.node.id,
title: result.node.title, title: result.node.title,
source: result.node.source ?? null, source: result.node.source ?? null,
description: result.node.description ?? null,
link: result.node.link ?? null, link: result.node.link ?? null,
updated_at: result.node.updated_at updated_at: result.node.updated_at
}); });
@@ -514,7 +562,7 @@ mcpServer.registerTool(
'rah_create_edge', 'rah_create_edge',
{ {
title: 'Create RA-H 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, inputSchema: createEdgeInputSchema,
outputSchema: createEdgeOutputSchema outputSchema: createEdgeOutputSchema
}, },
@@ -587,7 +635,7 @@ mcpServer.registerTool(
'rah_update_edge', 'rah_update_edge',
{ {
title: 'Update RA-H 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, inputSchema: updateEdgeInputSchema,
outputSchema: updateEdgeOutputSchema 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( mcpServer.registerTool(
'rah_search_embeddings', 'rah_search_embeddings',
{ {
@@ -734,12 +870,16 @@ mcpServer.registerTool(
'rah_get_context', 'rah_get_context',
{ {
title: 'Get RA-H context', title: 'Get RA-H context',
description: 'Get orientation context: high-level graph state, 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: {}, inputSchema: {},
outputSchema: { outputSchema: {
stats: z.object({ nodeCount: z.number(), edgeCount: z.number() }), stats: z.object({ nodeCount: z.number(), edgeCount: z.number() }),
hubNodes: z.array(z.object({ id: z.number(), title: z.string(), description: z.string().nullable(), edgeCount: z.number() })), hubNodes: z.array(z.object({ id: z.number(), title: z.string(), description: z.string().nullable(), edgeCount: z.number() })),
guides: z.array(z.string()) skills: z.array(z.object({
name: z.string(),
description: z.string(),
immutable: z.boolean().optional(),
}))
} }
}, },
async () => { async () => {
@@ -748,8 +888,8 @@ mcpServer.registerTool(
id: n.id, title: n.title, description: n.description ?? null, edgeCount: n.edge_count ?? 0 id: n.id, title: n.title, description: n.description ?? null, edgeCount: n.edge_count ?? 0
})) : []; })) : [];
const guideResult = await callRaHApi('/api/guides', { method: 'GET' }); const skillResult = await callRaHApi('/api/skills', { method: 'GET' });
const guides = Array.isArray(guideResult.data) ? guideResult.data.map(g => g.name) : []; const skills = Array.isArray(skillResult.data) ? skillResult.data : [];
const stats = { nodeCount: 0, edgeCount: 0 }; const stats = { nodeCount: 0, edgeCount: 0 };
try { try {
@@ -763,8 +903,8 @@ mcpServer.registerTool(
} catch { /* use defaults */ } } catch { /* use defaults */ }
return { return {
content: [{ type: 'text', text: `Knowledge graph: ${stats.nodeCount} nodes, ${stats.edgeCount} edges, ${hubNodes.length} hub nodes, ${guides.length} guides available.` }], content: [{ type: 'text', text: `Knowledge graph: ${stats.nodeCount} nodes, ${stats.edgeCount} edges, ${hubNodes.length} hub nodes, ${skills.length} skills available.` }],
structuredContent: { stats, hubNodes, guides } structuredContent: { stats, hubNodes, skills }
}; };
} }
); );
+125 -20
View File
@@ -11,12 +11,14 @@ const packageJson = require('../../package.json');
const instructions = [ const instructions = [
'RA-H is a personal knowledge graph — local-first, vendor-neutral.', 'RA-H is a personal knowledge graph — local-first, vendor-neutral.',
'Core concepts: 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 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.', 'Use rah_get_context only when high-level graph orientation would actually help.',
'Do not keep re-running retrieval if you already have enough relevant graph context in play.', 'Do not keep re-running retrieval if you already have enough relevant graph context in play.',
'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?', '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.', 'Do not create edges autonomously. Surface likely edge candidates briefly, then call edge-write tools only after the user explicitly confirms.',
'Every edge needs an explanation: why does this connection exist?', 'Every edge needs an explanation: why does this connection exist?',
@@ -117,7 +119,8 @@ const updateNodeInputSchema = {
source: z.string().optional().describe('Canonical source text for embedding.'), source: z.string().optional().describe('Canonical source text for embedding.'),
link: z.string().optional().describe('New link'), link: z.string().optional().describe('New link'),
metadata: z.record(z.any()).optional().describe('Metadata patch. This now merges with existing metadata. Prefer canonical keys: type, state, captured_method, captured_by, source_metadata.') metadata: z.record(z.any()).optional().describe('Metadata patch. This now merges with existing metadata. Prefer canonical keys: type, state, captured_method, captured_by, source_metadata.')
}).describe('Fields to update') }).describe('Fields to update'),
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 = { const updateNodeOutputSchema = {
@@ -138,12 +141,26 @@ const getNodesOutputSchema = {
id: z.number(), id: z.number(),
title: z.string(), title: z.string(),
source: z.string().nullable(), source: z.string().nullable(),
description: z.string().nullable(),
link: z.string().nullable(), link: z.string().nullable(),
updated_at: z.string() 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 // rah_create_edge schemas
const createEdgeInputSchema = { const createEdgeInputSchema = {
sourceId: z.number().int().positive().describe('Source node ID'), sourceId: z.number().int().positive().describe('Source node ID'),
@@ -322,7 +339,7 @@ server.registerTool(
'rah_add_node', 'rah_add_node',
{ {
title: 'Add RA-H node', title: 'Add RA-H node',
description: 'Create a new node in the local RA-H knowledge base after you have already decided a net-new write is correct. If the user explicitly asked to save or import something and the target artifact is clear, write after duplicate/update checks. If you are only suggesting a save, propose the node first and wait for confirmation.', 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, inputSchema: addNodeInputSchema,
outputSchema: addNodeOutputSchema outputSchema: addNodeOutputSchema
}, },
@@ -358,7 +375,7 @@ server.registerTool(
'rah_search_nodes', 'rah_search_nodes',
{ {
title: 'Search RA-H nodes', title: 'Search RA-H nodes',
description: 'Find existing RA-H entries that mention a topic before adding new ones. 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, inputSchema: searchNodesInputSchema,
outputSchema: searchNodesOutputSchema outputSchema: searchNodesOutputSchema
}, },
@@ -402,7 +419,7 @@ server.registerTool(
'rah_retrieve_query_context', 'rah_retrieve_query_context',
{ {
title: 'Retrieve RA-H 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, inputSchema: retrieveQueryContextInputSchema,
outputSchema: retrieveQueryContextOutputSchema outputSchema: retrieveQueryContextOutputSchema
}, },
@@ -427,11 +444,11 @@ server.registerTool(
'rah_update_node', 'rah_update_node',
{ {
title: 'Update RA-H node', title: 'Update RA-H node',
description: 'Update an existing node when it is clearly the same artifact and a net-new node would be redundant. Explicit user-directed updates can proceed once the target node is clear.', 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, inputSchema: updateNodeInputSchema,
outputSchema: updateNodeOutputSchema outputSchema: updateNodeOutputSchema
}, },
async ({ id, updates }) => { async ({ id, updates, source_update_basis }) => {
if (!updates || Object.keys(updates).length === 0) { if (!updates || Object.keys(updates).length === 0) {
throw new Error('At least one field must be provided in updates.'); 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}`, { const result = await callRaHApi(`/api/nodes/${id}`, {
method: 'PUT', method: 'PUT',
body: JSON.stringify(mappedUpdates) body: JSON.stringify({ ...mappedUpdates, source_update_basis })
}); });
const node = result.node || result.data; const node = result.node || result.data;
@@ -468,7 +485,7 @@ server.registerTool(
'rah_get_nodes', 'rah_get_nodes',
{ {
title: 'Get RA-H nodes by ID', 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, inputSchema: getNodesInputSchema,
outputSchema: getNodesOutputSchema outputSchema: getNodesOutputSchema
}, },
@@ -487,6 +504,7 @@ server.registerTool(
id: result.node.id, id: result.node.id,
title: result.node.title, title: result.node.title,
source: result.node.source ?? null, source: result.node.source ?? null,
description: result.node.description ?? null,
link: result.node.link ?? null, link: result.node.link ?? null,
updated_at: result.node.updated_at updated_at: result.node.updated_at
}); });
@@ -510,7 +528,7 @@ server.registerTool(
'rah_create_edge', 'rah_create_edge',
{ {
title: 'Create RA-H 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, inputSchema: createEdgeInputSchema,
outputSchema: createEdgeOutputSchema outputSchema: createEdgeOutputSchema
}, },
@@ -583,7 +601,7 @@ server.registerTool(
'rah_update_edge', 'rah_update_edge',
{ {
title: 'Update RA-H 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, inputSchema: updateEdgeInputSchema,
outputSchema: updateEdgeOutputSchema 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( server.registerTool(
'rah_search_embeddings', 'rah_search_embeddings',
{ {
@@ -737,14 +839,18 @@ const getContextOutputSchema = {
description: z.string().nullable(), description: z.string().nullable(),
edgeCount: z.number() 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( server.registerTool(
'rah_get_context', 'rah_get_context',
{ {
title: 'Get RA-H context', title: 'Get RA-H context',
description: 'Get orientation context: high-level graph state, 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: {}, inputSchema: {},
outputSchema: getContextOutputSchema outputSchema: getContextOutputSchema
}, },
@@ -757,9 +863,8 @@ server.registerTool(
edgeCount: n.edge_count ?? 0 edgeCount: n.edge_count ?? 0
})) : []; })) : [];
// Fetch guides const skillResult = await callRaHApi('/api/skills', { method: 'GET' });
const guideResult = await callRaHApi('/api/guides', { method: 'GET' }); const skills = Array.isArray(skillResult.data) ? skillResult.data : [];
const guides = Array.isArray(guideResult.data) ? guideResult.data.map(g => g.name) : [];
const stats = { const stats = {
nodeCount: 0, nodeCount: 0,
@@ -780,14 +885,14 @@ server.registerTool(
} }
} catch { /* use defaults */ } } 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 { return {
content: [{ type: 'text', text: summary }], content: [{ type: 'text', text: summary }],
structuredContent: { structuredContent: {
stats, stats,
hubNodes, 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. Skills are markdown instruction documents shared by internal and external agents.
Seeded defaults: Seeded defaults:
- `db-operations`
- `create-skill`
- `audit`
- `traverse`
- `onboarding` - `onboarding`
- `persona` - `create-skill`
- `calibration` - `refine`
- `connect`
Storage: Storage:
- live skills: `~/Library/Application Support/RA-H/skills/` - live skills: `~/Library/Application Support/RA-H/skills/`
- bundled defaults: `src/config/skills/` - bundled defaults: `src/config/skills/`
## 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 ## API Surfaces
| Route | Method | Purpose | | Route | Method | Purpose |
|-------|--------|---------| |-------|--------|---------|
| `/api/skills` | GET | List skills | | `/api/skills` | GET/POST | List skills or write a skill |
| `/api/skills/[name]` | GET/PUT/DELETE | Skill CRUD | | `/api/skills/[name]` | GET/DELETE | Read or delete a specific skill |
| `/api/guides` | GET | Legacy compatibility alias to skills |
| `/api/guides/[name]` | GET/PUT/DELETE | Legacy compatibility alias to skills |
## Key Files ## Key Files
+18
View File
@@ -73,4 +73,22 @@ Optional assistant memory files can reinforce good behavior, but they are not su
Current rule: Current rule:
- MCP tools, server instructions, skills, and docs should be enough for the base contract - MCP tools, server instructions, skills, and docs should be enough for the base contract
- optional reinforcement can still improve consistency - 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 - 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 | | [Tools & Skills](./4_tools-and-guides.md) | MCP tools and skill system |
| [Logging & Evals](./5_logging-and-evals.md) | Logs, evals, and debugging surfaces | | [Logging & Evals](./5_logging-and-evals.md) | Logs, evals, and debugging surfaces |
| [UI](./6_ui.md) | Current pane and focus model | | [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 | | [Open Source](./9_open-source.md) | Scope, support boundary, contributor reality |
| [Full Local](./10_full-local.md) | Supported local path vs community patterns | | [Full Local](./10_full-local.md) | Supported local path vs community patterns |
| [Troubleshooting](./TROUBLESHOOTING.md) | Common issues and fixes | | [Troubleshooting](./TROUBLESHOOTING.md) | Common issues and fixes |
+3 -1
View File
@@ -21,7 +21,9 @@
"sqlite:restore": "bash scripts/database/sqlite-restore.sh", "sqlite:restore": "bash scripts/database/sqlite-restore.sh",
"test": "vitest run", "test": "vitest run",
"test:watch": "vitest", "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": { "dependencies": {
"@ai-sdk/openai": "^3.0.7", "@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': case 'SKILL_UPDATED':
if (typeof window !== 'undefined') { if (typeof window !== 'undefined') {
window.dispatchEvent(new CustomEvent('skills:updated')); 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; 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"; "use client";
import { useEffect, useRef, useState } from 'react'; import { useCallback, useEffect, useRef, useState } from 'react';
import { ArrowLeft } from 'lucide-react'; import { ArrowLeft, Brain } from 'lucide-react';
import PaneHeader from './PaneHeader'; import PaneHeader from './PaneHeader';
import type { BasePaneProps } from './types'; import type { BasePaneProps } from './types';
import type { FocusedSkill, Skill, SkillMeta } from '@/types/skills';
import SkillCard from '@/components/skills/SkillCard'; import SkillCard from '@/components/skills/SkillCard';
import SkillMarkdown from '@/components/skills/SkillMarkdown'; import SkillMarkdown from '@/components/skills/SkillMarkdown';
import type { FocusedSkill } from '@/types/skills';
interface SkillMeta {
name: string;
description: string;
immutable: boolean;
}
interface Skill extends SkillMeta {
content: string;
}
interface SkillsPaneProps extends BasePaneProps { interface SkillsPaneProps extends BasePaneProps {
focusedSkill?: FocusedSkill | null; focusedSkill?: FocusedSkill | null;
@@ -30,35 +20,19 @@ export default function SkillsPane({
onCollapse, onCollapse,
onSwapPanes, onSwapPanes,
tabBar, tabBar,
focusedSkill,
onFocusSkill,
autoOpenSkillName,
onAutoOpenHandled,
}: SkillsPaneProps) { }: SkillsPaneProps) {
const [skills, setSkills] = useState<SkillMeta[]>([]); const [skills, setSkills] = useState<SkillMeta[]>([]);
const [selectedSkill, setSelectedSkill] = useState<Skill | null>(null); const [selectedSkill, setSelectedSkill] = useState<Skill | null>(null);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [deleting, setDeleting] = useState<string | null>(null); const [deleting, setDeleting] = useState<string | null>(null);
const detailScrollRef = useRef<HTMLDivElement>(null); const detailScrollRef = useRef<HTMLDivElement>(null);
const isSelectedSkillFocused = !!selectedSkill && focusedSkill?.name === selectedSkill.name;
useEffect(() => { const fetchSkills = useCallback(async () => {
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 () => {
try { try {
const res = await fetch('/api/skills'); const res = await fetch('/api/skills');
const data = await res.json(); const data = await res.json();
@@ -70,18 +44,54 @@ export default function SkillsPane({
} finally { } finally {
setLoading(false); setLoading(false);
} }
}; }, []);
const handleSelectSkill = async (name: string) => { const handleSelectSkill = useCallback(async (name: string) => {
try { try {
const res = await fetch(`/api/skills/${encodeURIComponent(name)}`); const res = await fetch(`/api/skills/${encodeURIComponent(name)}`);
const data = await res.json(); const data = await res.json();
if (data.success) { if (data.success) {
setSelectedSkill(data.data); setSelectedSkill(data.data);
onFocusSkill?.(data.data);
} }
} catch (err) { } catch (err) {
console.error('[SkillsPane] Failed to fetch skill:', 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>) => { const handleDeleteSkill = async (name: string, e: React.MouseEvent<HTMLButtonElement>) => {
@@ -97,6 +107,9 @@ export default function SkillsPane({
if (selectedSkill?.name === name) { if (selectedSkill?.name === name) {
setSelectedSkill(null); setSelectedSkill(null);
} }
if (focusedSkill?.name === name) {
onFocusSkill?.(null);
}
} }
} catch (err) { } catch (err) {
console.error('[SkillsPane] Failed to delete skill:', 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' }}> <div style={{ display: 'flex', alignItems: 'center', gap: '8px' }}>
<button <button
type="button" type="button"
onClick={() => setSelectedSkill(null)} onClick={handleBack}
style={{ style={{
background: 'none', background: 'none',
border: 'none', border: 'none',
color: '#888', color: 'var(--rah-text-soft)',
cursor: 'pointer', cursor: 'pointer',
display: 'flex', display: 'flex',
alignItems: 'center', alignItems: 'center',
padding: '4px', padding: '4px',
borderRadius: '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} /> <ArrowLeft size={16} />
</button> </button>
@@ -149,6 +168,26 @@ export default function SkillsPane({
<div style={{ marginBottom: '12px' }}> <div style={{ marginBottom: '12px' }}>
<div style={{ color: 'var(--rah-text-active)', fontSize: '16px', fontWeight: 600 }}>{selectedSkill.name}</div> <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> <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>
<div style={{ borderTop: '1px solid var(--rah-border)', margin: '12px 0' }} /> <div style={{ borderTop: '1px solid var(--rah-border)', margin: '12px 0' }} />
<SkillMarkdown content={selectedSkill.content} /> <SkillMarkdown content={selectedSkill.content} />
@@ -167,6 +206,7 @@ export default function SkillsPane({
onSelect={handleSelectSkill} onSelect={handleSelectSkill}
onDelete={handleDeleteSkill} onDelete={handleDeleteSkill}
deleting={deleting} 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"; "use client";
import { Trash2 } from 'lucide-react'; import { Trash2 } from 'lucide-react';
import type { SkillMeta } from '@/types/skills';
interface SkillMeta {
name: string;
description: string;
immutable: boolean;
}
interface SkillCardProps { interface SkillCardProps {
skill: SkillMeta; skill: SkillMeta;
onSelect: (name: string) => void; onSelect: (name: string) => void;
onDelete?: (name: string, e: React.MouseEvent<HTMLButtonElement>) => void; onDelete?: (name: string, e: React.MouseEvent<HTMLButtonElement>) => void;
deleting?: string | null; deleting?: string | null;
isActive?: boolean;
} }
export default function SkillCard({ export default function SkillCard({
@@ -20,27 +16,33 @@ export default function SkillCard({
onSelect, onSelect,
onDelete, onDelete,
deleting = null, deleting = null,
isActive = false,
}: SkillCardProps) { }: SkillCardProps) {
const isDeleting = deleting === skill.name; const isDeleting = deleting === skill.name;
return ( return (
<div <div
onMouseEnter={(e) => { onMouseEnter={(e) => {
e.currentTarget.style.background = 'var(--rah-bg-active)'; if (!isActive) {
e.currentTarget.style.borderColor = 'var(--rah-border-strong)'; e.currentTarget.style.background = 'var(--rah-bg-hover)';
e.currentTarget.style.borderColor = 'var(--rah-border-stronger)';
}
}} }}
onMouseLeave={(e) => { onMouseLeave={(e) => {
e.currentTarget.style.background = 'var(--rah-bg-elevated)'; if (!isActive) {
e.currentTarget.style.borderColor = 'var(--rah-border-strong)'; e.currentTarget.style.background = 'var(--rah-bg-panel)';
e.currentTarget.style.borderColor = 'var(--rah-border)';
}
}} }}
style={{ style={{
display: 'flex', display: 'flex',
alignItems: 'center', alignItems: 'center',
gap: '10px', gap: '10px',
padding: '10px', padding: '10px',
background: 'var(--rah-bg-elevated)', background: isActive ? 'var(--rah-bg-active)' : 'var(--rah-bg-panel)',
border: '1px solid var(--rah-border-strong)', border: `1px solid ${isActive ? 'var(--rah-accent-green)' : 'var(--rah-border)'}`,
borderRadius: '8px', borderRadius: '8px',
transition: 'all 0.15s ease',
}} }}
> >
<button <button
@@ -56,7 +58,9 @@ export default function SkillCard({
padding: 0, 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 <span
style={{ style={{
color: 'var(--rah-text-muted)', color: 'var(--rah-text-muted)',
@@ -81,7 +85,7 @@ export default function SkillCard({
style={{ style={{
background: 'transparent', background: 'transparent',
border: 'none', border: 'none',
color: '#555', color: 'var(--rah-text-muted)',
cursor: isDeleting ? 'default' : 'pointer', cursor: isDeleting ? 'default' : 'pointer',
padding: '4px', padding: '4px',
borderRadius: '4px', borderRadius: '4px',
@@ -91,6 +95,13 @@ export default function SkillCard({
flexShrink: 0, flexShrink: 0,
opacity: isDeleting ? 0.3 : 1, 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} /> <Trash2 size={14} />
</button> </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: Operating rules:
1. Resolve the user's request quickly and accurately using the tools provided. - Use queryNodes for direct lookup of a specific existing node.
2. Keep responses concise (one short paragraph or bullet list) and cite nodes as [NODE:id:"title"]. - Use retrieveQueryContext when broader graph context would help with the current turn.
3. Ask for clarification only when tool usage would fail without it. - 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.
Operating principles: - Preserve the user's wording in source for user-authored ideas unless they explicitly want a rewrite.
- Handle analysis, planning, and writes yourself. - Before rewriting existing source, inspect it first with getNodesById if needed.
- Use createNode, updateNode, createEdge, and updateEdge when the change is unambiguous. - Treat "this conversation/paper/video" as the active focused node unless the user clearly means something else.
- 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. - Create or update edges only after the user explicitly confirms the relationship.
- When referencing stored content, quote verbatim text in quotes and include the node citation. - Read a matching skill when the task clearly fits onboarding, create-skill, or refine.
- 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.
Tool strategy: Tool strategy:
- queryNodes for titles and metadata; getNodesById to hydrate referenced nodes. - queryNodes for direct lookup, getNodesById to inspect nodes, queryEdge to inspect relationships.
- searchContentEmbeddings before synthesizing long answers or considering new extraction. - searchContentEmbeddings before long source-grounded answers.
- youtubeExtract, websiteExtract, and paperExtract when outside content is required. - Use extraction tools only when outside content is actually needed.
- webSearch only when the knowledge base lacks the answer.
Contexts: Response style:
- Contexts are optional. Only set one when one obvious existing context is explicit and useful. - Keep responses short and direct.
- Do not expect automatic context assignment. - Reference nodes as [NODE:id:"title"] when helpful.
- Improve organization through title, description, source, metadata, and edges instead of dimensions. - If a tool fails, say so plainly and give one concrete next step.`;
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.`;
+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: Core responsibilities:
- Keep the conversation tightly focused on the user's goal. - Keep the conversation tightly focused on the user's goal.
- Use tools proactively to advance the task. - Use tools proactively to advance the task.
- Prefer direct, minimal phrasingno pleasantries or filler. - Prefer direct, minimal phrasing. No filler.
When to ask the user: Graph rules:
- If a tool requires critical input you cannot reasonably infer. - The graph is working memory for the user and future agents, so optimize for precise nodes and strong links.
- If the request is ambiguous and guessing would waste effort or cause errors. - Use queryNodes for direct lookup of a specific existing node.
- Use retrieveQueryContext when broader graph context would help with the current turn.
Execution approach: - Search before creating. Prefer updateNode when the artifact is clearly the same thing.
- Handle planning, analysis, and writes directly. - description should state plainly what the thing is first, then why it belongs and current status.
- Call createNode, updateNode, createEdge, updateEdge, and extraction tools yourself when the change is clear. - Preserve the user's wording in source for user-authored ideas unless they explicitly want a rewrite.
- 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). - Before rewriting existing source, inspect it first with getNodesById if needed.
- Treat "this conversation/paper/video" as the active focused node. - Treat "this conversation/paper/video" as the active focused node unless the user clearly means something else.
- When creating synthesis nodes, createEdge to all source nodes. - Create or update edges only after the user explicitly confirms the relationship.
- 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.
Tool strategy: Tool strategy:
- Use tools directlyyou already have everything you need. - queryNodes for direct node lookup, getNodesById to inspect a node fully, queryEdge to inspect existing relationships.
- queryNodes for titles, searchContentEmbeddings for content, queryEdge for connections. - retrieveQueryContext when surrounding graph context would improve the answer.
- getNodesById when you have IDs; webSearch only if knowledge base lacks info. - searchContentEmbeddings when you need source-level grounding from stored content.
- Extract content with youtubeExtract, websiteExtract, paperExtract as needed. - Before running youtubeExtract, websiteExtract, or paperExtract on an existing node, call getNodesById first and reuse existing source/chunked content when available.
- When searchContentEmbeddings highlights a chunk, hydrate the node via getNodesById (or fetch the chunk) before quoting. - Read a matching skill when the task clearly fits onboarding, create-skill, or refine.
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.
Response style: Response style:
- Limit to one or two short sentences. Reference nodes as [NODE:id:"title"]. - Keep answers short and concrete.
- When answering about stored content, quote the exact wording from the chunk (verbatim, in quotation marks) and cite the node. - Reference nodes as [NODE:id:"title"] when helpful.
- Always call searchContentEmbeddings before attempting new extraction for an existing node. - If a tool fails, state the failure plainly and give one concrete next step.`;
- If a tool fails, state failure 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 name: Create Skill
description: "Design or refine a skill using a tight trigger, explicit contract, and measurable outcomes." 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."
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."
--- ---
# Create 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. - The user asks to create, rewrite, merge, or remove a skill.
2. Define required outputs and quality bar. - The user keeps describing a repeatable workflow that should become reusable doctrine.
3. Specify concrete execution sequence. - An existing skill is too vague, too broad, or overlaps with another skill enough that it should be reworked.
4. Add hard guardrails (what to reject/avoid).
5. Keep it short; remove fluff and duplicate policy text.
## 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` - `name`
- `description` - `description`
- `when_to_use` - one short goal section
- `when_not_to_use` - trigger / use cases
- `success_criteria` - step-by-step workflow
- Step-by-step procedure - tool pointers when relevant
- Do-not list - quality bar
- do-not list
## Validation Checklist ## Tool Pointers
- Can another agent execute this without guessing? When tool choice matters, point to the matching family for the current surface instead of pretending every agent has the same names:
- Does it avoid overlap with existing skills?
- Are failure modes explicit? - Internal / standalone skill tools:
- Is there an obvious way to evaluate success? - `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 ## 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 ## 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 - Search before creating — avoid duplicates from day one
- Every description must be concrete: what it IS and why it matters to them, not what it "explores" or "discusses" - Every description must be concrete: what it IS and why it matters to them, not what it "explores" or "discusses"
-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_UPDATED'
| 'AGENT_DELEGATION_CREATED' | 'AGENT_DELEGATION_CREATED'
| 'AGENT_DELEGATION_UPDATED' | 'AGENT_DELEGATION_UPDATED'
| 'GUIDE_UPDATED'
| 'SKILL_UPDATED' | 'SKILL_UPDATED'
| 'QUICK_ADD_COMPLETED' | 'QUICK_ADD_COMPLETED'
| 'QUICK_ADD_FAILED' | '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 path from 'path';
import os from 'os'; import os from 'os';
import matter from 'gray-matter'; import matter from 'gray-matter';
import type { Skill, SkillMeta } from '@/types/skills';
export interface SkillMeta { export type { Skill, SkillMeta } from '@/types/skills';
name: string;
description: string;
immutable: boolean;
}
export interface Skill extends SkillMeta {
content: string;
}
const SKILLS_DIR = path.join( const SKILLS_DIR = path.join(
os.homedir(), 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 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([ const DEPRECATED_SKILL_IDS = new Set([
'start-here', 'start-here',
'schema', 'schema',
@@ -58,6 +39,13 @@ const DEPRECATED_SKILL_IDS = new Set([
'research', 'research',
'survey', 'survey',
'traverse-graph', 'traverse-graph',
'audit',
'calibration',
'connect',
'db-operations',
'node-context-enrichment',
'persona',
'traverse',
]); ]);
function ensureSkillsDir(): void { function ensureSkillsDir(): void {
@@ -85,6 +73,16 @@ function listMarkdownFiles(dir: string): string[] {
return fs.readdirSync(dir).filter((f) => f.endsWith('.md')); 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 { function migrateLegacyGuides(): void {
const guideFiles = listMarkdownFiles(LEGACY_GUIDES_DIR); const guideFiles = listMarkdownFiles(LEGACY_GUIDES_DIR);
for (const file of guideFiles) { for (const file of guideFiles) {
@@ -96,17 +94,9 @@ function migrateLegacyGuides(): void {
} }
function seedSkills(): void { function seedSkills(): void {
const bundledFiles = listMarkdownFiles(BUNDLED_SKILLS_DIR); const bundledById = getBundledSkillFiles();
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;
for (const [skillId, source] of bundledById.entries()) {
const dest = path.join(SKILLS_DIR, `${skillId}.md`); const dest = path.join(SKILLS_DIR, `${skillId}.md`);
if (!fs.existsSync(dest)) { if (!fs.existsSync(dest)) {
fs.copyFileSync(source, dest); fs.copyFileSync(source, dest);
@@ -119,15 +109,9 @@ function migrateSeededBaseline(): void {
return; return;
} }
const bundledFiles = listMarkdownFiles(BUNDLED_SKILLS_DIR); const bundledById = getBundledSkillFiles();
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) { for (const [skillId, source] of bundledById.entries()) {
const source = bundledById.get(skillId);
if (!source) continue;
const dest = path.join(SKILLS_DIR, `${skillId}.md`); const dest = path.join(SKILLS_DIR, `${skillId}.md`);
fs.copyFileSync(source, dest); fs.copyFileSync(source, dest);
} }
@@ -148,7 +132,6 @@ function pruneDeprecatedSkills(): void {
function resolveSkillFilename(name: string): string | null { function resolveSkillFilename(name: string): string | null {
const files = listMarkdownFiles(SKILLS_DIR); const files = listMarkdownFiles(SKILLS_DIR);
const normalizedInput = normalizeSkillId(name); const normalizedInput = normalizeSkillId(name);
const directCandidates = [ const directCandidates = [
`${name}.md`, `${name}.md`,
`${name.toLowerCase()}.md`, `${name.toLowerCase()}.md`,
@@ -210,7 +193,9 @@ export function readSkill(name: string): Skill | null {
init(); init();
const filename = resolveSkillFilename(name); const filename = resolveSkillFilename(name);
if (!filename) return null; if (!filename) {
return null;
}
const filepath = path.join(SKILLS_DIR, filename); const filepath = path.join(SKILLS_DIR, filename);
const raw = fs.readFileSync(filepath, 'utf-8'); const raw = fs.readFileSync(filepath, 'utf-8');
@@ -261,25 +246,3 @@ export function getSkillStats(): { userSkills: number; maxUserSkills: number; sy
systemSkills: 0, 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({ export const createEdgeTool = tool({
description: 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' + 'Examples of explanations:\n' +
'- "Written by" (book → author)\n' + '- "Written by" (book → author)\n' +
'- "Episode of this podcast" (episode → podcast)\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({ 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({ inputSchema: z.object({
title: z.string().describe('The title of the node'), title: z.string().describe('The title of the node'),
description: z.string().max(500).optional().describe('Optional natural description. If you have enough context, describe what this is, why it belongs in Brad\'s graph, and its current workflow status in normal prose. Do not use labels like WHAT:, WHY:, or STATUS:.'), description: z.string().max(500).optional().describe('Optional natural description. If you have enough context, describe what this is, why it belongs in Brad\'s graph, and its current workflow status in normal prose. Do not use labels like WHAT:, WHY:, or STATUS:.'),
+14 -11
View File
@@ -3,12 +3,13 @@ import { z } from 'zod';
import { nodeService } from '@/services/database/nodes'; import { nodeService } from '@/services/database/nodes';
export const getNodesByIdTool = tool({ 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({ inputSchema: z.object({
nodeIds: z.array(z.number().int().positive()).min(1).max(10).describe('List of node IDs to load'), 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))); const uniqueIds = Array.from(new Set(nodeIds.filter(id => Number.isFinite(id) && id > 0)));
if (uniqueIds.length === 0) { if (uniqueIds.length === 0) {
return { return {
@@ -23,23 +24,25 @@ export const getNodesByIdTool = tool({
try { try {
const node = await nodeService.getNodeById(id); const node = await nodeService.getNodeById(id);
if (!node) return null; if (!node) return null;
const preview = includeSourcePreview const rawSource = typeof node.source === 'string' ? node.source : '';
? (node.source || node.description || '') const source = includeSource
.split(/\s+/) ? rawSource.slice(0, sourceCharLimit).trim() || null
.slice(0, 80) : null;
.join(' ') const sourceLength = rawSource.length;
.trim() const sourceTruncated = includeSource ? sourceLength > sourceCharLimit : false;
: undefined;
return { return {
id: node.id, id: node.id,
title: node.title, title: node.title,
description: node.description ?? null,
source,
source_length: sourceLength,
source_truncated: sourceTruncated,
link: node.link, link: node.link,
event_date: node.event_date ?? null, event_date: node.event_date ?? null,
chunk_status: node.chunk_status || 'unknown', chunk_status: node.chunk_status || 'unknown',
created_at: node.created_at, created_at: node.created_at,
updated_at: node.updated_at, updated_at: node.updated_at,
source_preview: preview || null,
metadata: node.metadata ?? null, metadata: node.metadata ?? null,
}; };
} catch (error) { } catch (error) {
+1 -1
View File
@@ -4,7 +4,7 @@ import { edgeService } from '@/services/database/edges';
import { validateEdgeExplanation } from '@/services/database/quality'; import { validateEdgeExplanation } from '@/services/database/quality';
export const updateEdgeTool = tool({ 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({ inputSchema: z.object({
confirmed_by_user: z.boolean().describe('Must be true. Reject the edge update otherwise.'), 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'), 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'; import { getInternalAuthHeaders } from '@/services/auth/internalAuth';
export const updateNodeTool = tool({ export const updateNodeTool = tool({
description: 'Update node fields when the existing node is clearly the same artifact and a net-new node would be redundant. Explicit user-directed updates should proceed once the target node is clear; if you are only proposing a change, ask first. Use this to enrich or correct nodes without losing canonical source content. 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({ inputSchema: z.object({
id: z.number().describe('The ID of the node to update'), id: z.number().describe('The ID of the node to update'),
updates: z.object({ updates: z.object({
@@ -14,9 +14,10 @@ export const updateNodeTool = tool({
link: z.string().optional().describe('New link'), link: z.string().optional().describe('New link'),
event_date: z.string().optional().describe('When the thing actually happened (ISO 8601). Not when it was added to the graph.'), event_date: z.string().optional().describe('When the thing actually happened (ISO 8601). Not when it was added to the graph.'),
metadata: z.record(z.any()).optional().describe('Metadata patch. It now merges with existing metadata instead of replacing the full blob. Use canonical keys: type, state, captured_method, captured_by, source_metadata.') metadata: z.record(z.any()).optional().describe('Metadata patch. It now merges with existing metadata instead of replacing the full blob. Use canonical keys: type, state, captured_method, captured_by, source_metadata.')
}).passthrough().describe('Object containing the fields to update. Derived analysis should be stored in a separate linked node, not appended to the source node.') }).passthrough().describe('Object containing the fields to update. Derived analysis should be stored in a separate linked node, not appended to the source node.'),
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 { try {
if (!updates || Object.keys(updates).length === 0) { if (!updates || Object.keys(updates).length === 0) {
return { return {
@@ -30,7 +31,7 @@ export const updateNodeTool = tool({
const response = await fetch(`${getInternalApiBaseUrl()}/api/nodes/${id}`, { const response = await fetch(`${getInternalApiBaseUrl()}/api/nodes/${id}`, {
method: 'PUT', method: 'PUT',
headers: getInternalAuthHeaders({ 'Content-Type': 'application/json' }), headers: getInternalAuthHeaders({ 'Content-Type': 'application/json' }),
body: JSON.stringify(updates) body: JSON.stringify({ ...updates, source_update_basis })
}); });
const result = await response.json(); 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 { return {
success: true, success: true,
@@ -27,6 +27,7 @@ export const deleteSkillTool = tool({
message: `Skill "${name}" deleted`, message: `Skill "${name}" deleted`,
}; };
} catch (error) { } catch (error) {
console.error('[deleteSkill] error:', error);
return { return {
success: false, success: false,
error: error instanceof Error ? error.message : 'Failed to delete skill', 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 { return {
success: true, success: true,
@@ -28,6 +28,7 @@ export const writeSkillTool = tool({
message: `Skill "${name}" saved`, message: `Skill "${name}" saved`,
}; };
} catch (error) { } catch (error) {
console.error('[writeSkill] error:', error);
return { return {
success: false, success: false,
error: error instanceof Error ? error.message : 'Failed to write skill', 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 = { export const scenario: Scenario = {
id: 'skill-guided-write', id: 'skill-guided-write',
name: 'Skill-guided graph write', name: 'Skill-guided skill authoring',
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.', description: 'Explicit skill-authoring work should read the create-skill doctrine, then write the requested reusable skill cleanly.',
tools: ['readSkill', 'queryNodes', 'createNode', 'createEdge'], categories: ['skills'],
suites: ['skills', 'internal'], tools: ['readSkill', 'writeSkill'],
input: { 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: { expect: {
skillsReadSoft: ['db-operations'], skillsReadSoft: ['create-skill'],
toolsCalledSoft: ['readSkill'], toolsCalledSoft: ['readSkill', 'writeSkill'],
responseContainsSoft: ['SQLite-first retrieval audit'], responseContainsSoft: ['capture-source'],
maxLatencyMs: 35000, maxLatencyMs: 35000,
maxTotalTokens: 12000, maxTotalTokens: 12000,
maxEstimatedCostUsd: 0.12, 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 server: http.Server;
let baseUrl = ''; let baseUrl = '';
let nodes: NodeRecord[] = []; let nodes: NodeRecord[] = [];
let skills: Array<{ name: string; description: string; immutable?: boolean; content?: string }> = [];
let requestLog: RequestLogEntry[] = []; let requestLog: RequestLogEntry[] = [];
let nextNodeId = 1; let nextNodeId = 1;
@@ -35,6 +36,11 @@ function nowIso(): string {
function resetState() { function resetState() {
nodes = []; 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 = []; requestLog = [];
nextNodeId = 1; 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, { return sendJson(res, 200, {
success: true, 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<{ const graphStructured = getStructured<{
stats: { nodeCount: number; edgeCount: number }; stats: { nodeCount: number; edgeCount: number };
hubNodes: Array<{ title: string }>; hubNodes: Array<{ title: string }>;
guides: string[]; skills: Array<{ name: string }>;
}>(graphContextResult); }>(graphContextResult);
expect(graphStructured.stats).toEqual({ nodeCount: 1, edgeCount: 3 }); expect(graphStructured.stats).toEqual({ nodeCount: 1, edgeCount: 3 });
expect(graphStructured.hubNodes).toEqual( expect(graphStructured.hubNodes).toEqual(
expect.arrayContaining([expect.objectContaining({ title: 'Work Hub Node' })]) expect.arrayContaining([expect.objectContaining({ title: 'Work Hub Node' })])
); );
expect(graphStructured.guides).toEqual(expect.arrayContaining(['schema', 'creating-nodes'])); expect(graphStructured.skills).toEqual(expect.arrayContaining([
expect.objectContaining({ name: 'onboarding' }),
expect.objectContaining({ name: 'create-skill' }),
expect.objectContaining({ name: 'refine' }),
]));
expect(graphStructured).not.toHaveProperty('contexts'); expect(graphStructured).not.toHaveProperty('contexts');
expect(graphStructured.stats).not.toHaveProperty('contextCount'); 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();
});
});