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
+18 -17
View File
@@ -44,7 +44,11 @@ const serverInfo = {
function buildInstructions() {
const now = new Date().toISOString().split('T')[0];
let skillIndex = '- db-operations: Core graph read/write operating policy.';
let skillIndex = [
'- onboarding: Bootstrap a useful starter graph quickly and with low friction.',
'- create-skill: Create or rewrite a reusable skill when a workflow is repeatable.',
'- refine: Clean up or sharpen one node or a small set of nodes before writing changes.'
].join('\n');
try {
const skills = skillService.listSkills();
@@ -61,11 +65,12 @@ function buildInstructions() {
## Quick start
1. If the user is trying to find a specific existing node, call queryNodes first.
2. If graph context would help with a broader task, call retrieveQueryContext.
2. If graph context would help with a broader task, call retrieveQueryContext before answering.
3. Call getContext only when orientation about the overall graph would actually help.
4. Do not keep re-running retrieval if you already have enough relevant graph context in play.
5. For simple tasks, tool descriptions have everything you need.
6. For complex tasks, call readSkill("db-operations").
5. Search before creating, and prefer updateNode when the artifact is clearly the same thing.
6. For simple tasks, tool descriptions should be enough.
7. For non-trivial workflows or policy detail, call readSkill on the matching skill.
## Knowledge capture
Only suggest saving durable knowledge when it seems unusually durable and valuable.
@@ -142,7 +147,7 @@ const queryEdgesInputSchema = {
};
const readSkillInputSchema = {
name: z.string().min(1).describe('Skill name (e.g. "db-operations", "onboarding", "persona")')
name: z.string().min(1).describe('Skill name (e.g. "onboarding", "create-skill", "refine")')
};
const writeSkillInputSchema = {
@@ -246,14 +251,13 @@ async function main() {
'getContext',
{
title: 'Get RA-H context',
description: 'Get knowledge graph overview: stats, hub nodes, recent activity, and available skills. Use this for orientation only, not as the default retrieval path for substantive requests. For deeper operating policy, follow up with readSkill("db-operations").',
description: 'Get knowledge graph overview: stats, hub nodes, recent activity, and available skills. Use this for orientation only, not as the default retrieval path for substantive requests.',
inputSchema: {}
},
async () => {
const context = nodeService.getContext();
const skills = skillService.listSkills();
context.skills = skills.map(s => ({ name: s.name, description: s.description, immutable: s.immutable }));
context.guides = context.skills;
// First-run welcome message
if (context.stats.nodeCount === 0) {
@@ -267,7 +271,7 @@ async function main() {
};
}
const summary = `Graph: ${context.stats.nodeCount} nodes, ${context.stats.edgeCount} edges, ${skills.length} skills.`;
const summary = `Graph: ${context.stats.nodeCount} nodes, ${context.stats.edgeCount} edges, ${context.hubNodes.length} hub nodes, ${skills.length} skills.`;
return {
content: [{ type: 'text', text: summary }],
structuredContent: context
@@ -553,15 +557,13 @@ async function main() {
}
);
// ========== DIMENSION TOOLS ==========
// ========== SKILL TOOLS ==========
registerToolWithAliases(
'listSkills',
{
title: 'List RA-H skills',
description: 'List all skills available in this workspace. Read "db-operations" first for core graph operating policy.',
description: 'List the shared skills available to internal and external RA-H agents. Use this to see the current operating doctrine before reading or editing a specific skill.',
inputSchema: {}
},
async () => {
@@ -571,8 +573,7 @@ async function main() {
content: [{ type: 'text', text: `Found ${skills.length} skill(s).` }],
structuredContent: {
count: skills.length,
skills,
guides: skills
skills
}
};
}
@@ -582,7 +583,7 @@ async function main() {
'readSkill',
{
title: 'Read RA-H skill',
description: 'Read a skill by name. Returns full markdown with procedural instructions. Read "db-operations" for core policy. Call listSkills to see all available skills.',
description: 'Read one shared RA-H skill by name. Use this before executing a non-trivial workflow that matches the skill trigger.',
inputSchema: readSkillInputSchema
},
async ({ name }) => {
@@ -603,7 +604,7 @@ async function main() {
'writeSkill',
{
title: 'Write RA-H skill',
description: 'Create or update a skill. Content should be markdown with YAML frontmatter (name, description).',
description: 'Create or update a shared RA-H skill when the user explicitly wants to change the doctrine surface. Content should be the full markdown body for that skill.',
inputSchema: writeSkillInputSchema
},
async ({ name, content }) => {
@@ -628,7 +629,7 @@ async function main() {
'deleteSkill',
{
title: 'Delete RA-H skill',
description: 'Delete a skill.',
description: 'Delete a shared RA-H skill when the user explicitly wants it removed from the shared skill set.',
inputSchema: deleteSkillInputSchema
},
async ({ name }) => {
@@ -770,7 +771,7 @@ async function main() {
'sqliteQuery',
{
title: 'Execute read-only SQL',
description: 'Execute read-only SQL queries against the knowledge graph database. Tables: nodes, edges, chunks, chats, voice_usage, logs, and migration snapshots. Use PRAGMA table_info(tablename) for schema. Only SELECT/WITH/PRAGMA allowed. Use when structured tools are insufficient — e.g., complex JOINs, aggregations, or custom filtering. Read readSkill("schema") for table definitions and query patterns.',
description: 'Execute read-only SQL queries against the knowledge graph database. Tables include nodes, edges, chunks, and migration snapshots. Use PRAGMA table_info(tablename) for schema. Only SELECT/WITH/PRAGMA allowed. Use when structured tools are insufficient — e.g., complex JOINs, aggregations, or custom filtering. Read readSkill("schema") for table definitions and query patterns.',
inputSchema: sqliteQueryInputSchema
},
async ({ sql: userSql, format = 'json' }) => {
+1 -1
View File
@@ -10,7 +10,7 @@
"start": "node index.js",
"validate:syntax": "node --check index.js && node --check services/*.js",
"validate:publish": "npm run validate:syntax && npm pack --dry-run >/dev/null",
"prepublishOnly": "npm run validate:publish"
"prepublishOnly": "node ../../scripts/dev/sync-skills.mjs --check && npm run validate:publish"
},
"keywords": [
"mcp",
@@ -1,10 +0,0 @@
'use strict';
const skillService = require('./skillService');
module.exports = {
listGuides: skillService.listSkills,
readGuide: skillService.readSkill,
writeGuide: skillService.writeSkill,
deleteGuide: skillService.deleteSkill,
};
@@ -2,6 +2,108 @@
const { query, transaction, getDb } = require('./sqlite-client');
function sanitizeFtsQuery(input) {
return String(input || '')
.toLowerCase()
.replace(/[^a-z0-9\s]+/g, ' ')
.trim()
.split(/\s+/)
.filter(word => word.length > 0 && !/^(AND|OR|NOT|NEAR)$/i.test(word))
.join(' ');
}
function extractRelaxedSearchTerms(queryText) {
const stopWords = new Set([
'a', 'an', 'and', 'are', 'as', 'at', 'be', 'by', 'can', 'do', 'find',
'for', 'from', 'hello', 'i', 'in', 'is', 'it', 'me', 'my', 'of', 'on',
'or', 'recent', 'stuff', 'term', 'that', 'the', 'this', 'to', 'with', 'you'
]);
const rawTerms = String(queryText || '')
.toLowerCase()
.replace(/[^a-z0-9\s]+/g, ' ')
.split(/\s+/)
.map(term => term.trim())
.filter(Boolean);
const expanded = new Set();
for (const term of rawTerms) {
if (!stopWords.has(term) && term.length >= 3) {
expanded.add(term);
}
const alphaParts = term.replace(/\d+/g, ' ').split(/\s+/).filter(Boolean);
for (const part of alphaParts) {
if (!stopWords.has(part) && part.length >= 3) {
expanded.add(part);
}
}
}
return Array.from(expanded).slice(0, 8);
}
function normalizeSearchText(value) {
return String(value || '')
.toLowerCase()
.replace(/[^a-z0-9]+/g, ' ')
.replace(/\s+/g, ' ')
.trim();
}
function getQueryTerms(queryText) {
return normalizeSearchText(queryText).split(' ').filter(term => term.length > 0);
}
function countOccurrences(text, term) {
if (!text || !term) return 0;
const matches = text.match(new RegExp(`\\b${term.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}\\b`, 'g'));
return matches ? matches.length : 0;
}
function orderedTermMatches(text, terms) {
let position = 0;
for (const term of terms) {
const index = text.indexOf(term, position);
if (index === -1) return false;
position = index + term.length;
}
return terms.length > 0;
}
function scoreNodeSearchMatch(node, queryText) {
const normalizedQuery = normalizeSearchText(queryText);
const normalizedTitle = normalizeSearchText(node.title || '');
const normalizedDescription = normalizeSearchText(node.description || '');
const normalizedSource = normalizeSearchText(node.source || '');
const terms = getQueryTerms(queryText);
let score = 0;
if (normalizedTitle === normalizedQuery) score += 2000;
if (normalizedTitle.startsWith(normalizedQuery)) score += 1200;
if (normalizedTitle.includes(normalizedQuery)) score += 700;
if (orderedTermMatches(normalizedTitle, terms)) score += 500;
if (terms.length > 0 && terms.every(term => normalizedTitle.includes(term))) score += 350;
if (normalizedDescription.includes(normalizedQuery)) score += 180;
if (orderedTermMatches(normalizedDescription, terms)) score += 120;
if (normalizedSource.includes(normalizedQuery)) score += 90;
for (const term of terms) {
score += countOccurrences(normalizedTitle, term) * 40;
score += countOccurrences(normalizedDescription, term) * 8;
score += countOccurrences(normalizedSource, term) * 3;
}
if (node.updated_at) {
score += new Date(node.updated_at).getTime() / 1e13;
}
return score;
}
function parseMetadata(metadata) {
if (!metadata) return {};
if (typeof metadata === 'string') {
@@ -56,6 +158,165 @@ function mapNodeRow(row) {
};
}
function buildNodeFilterClauses(filters = {}, alias = 'n') {
const clauses = [];
const params = [];
if (filters.createdAfter) {
clauses.push(`${alias}.created_at >= ?`);
params.push(filters.createdAfter);
}
if (filters.createdBefore) {
clauses.push(`${alias}.created_at < ?`);
params.push(filters.createdBefore);
}
if (filters.eventAfter) {
clauses.push(`${alias}.event_date >= ?`);
params.push(filters.eventAfter);
}
if (filters.eventBefore) {
clauses.push(`${alias}.event_date < ?`);
params.push(filters.eventBefore);
}
return { clauses, params };
}
function searchNodes(filters = {}) {
const search = normalizeString(filters.search);
const limit = Math.min(Math.max(filters.limit || 25, 1), 100);
if (!search) return [];
const rowsById = new Map();
for (const row of searchNodesFts(search, filters, Math.max(limit * 2, 25))) {
rowsById.set(row.id, row);
}
for (const row of searchNodesLike(search, filters, Math.max(limit * 2, 25))) {
if (!rowsById.has(row.id)) rowsById.set(row.id, row);
}
for (const row of searchNodesLikeRelaxed(search, filters, Math.max(limit * 2, 25))) {
if (!rowsById.has(row.id)) rowsById.set(row.id, row);
}
return Array.from(rowsById.values())
.map(row => ({ row, score: scoreNodeSearchMatch(row, search) }))
.sort((a, b) => b.score - a.score)
.slice(0, limit)
.map(entry => mapNodeRow(entry.row));
}
function searchNodesFts(search, filters, limit) {
const db = getDb();
const ftsQuery = sanitizeFtsQuery(search);
if (!ftsQuery) return [];
const ftsExists = db.prepare("SELECT 1 FROM sqlite_master WHERE type='table' AND name='nodes_fts'").get();
if (!ftsExists) return [];
const { clauses, params } = buildNodeFilterClauses(filters);
const whereClause = clauses.length > 0 ? `WHERE ${clauses.join(' AND ')}` : '';
try {
return query(`
WITH fts_matches AS (
SELECT rowid, rank
FROM nodes_fts
WHERE nodes_fts MATCH ?
LIMIT ?
)
SELECT n.id, n.title, n.description, n.source, n.link, n.event_date, n.metadata,
n.created_at, n.updated_at,
fm.rank
FROM fts_matches fm
JOIN nodes n ON n.id = fm.rowid
${whereClause}
ORDER BY fm.rank
LIMIT ?
`, [ftsQuery, Math.max(limit * 2, 50), ...params, limit]);
} catch {
return [];
}
}
function searchNodesLike(search, filters, limit) {
const words = search.split(/\s+/).filter(Boolean);
const { clauses, params } = buildNodeFilterClauses(filters);
let sql = `
SELECT n.id, n.title, n.description, n.source, n.link, n.event_date, n.metadata,
n.created_at, n.updated_at
FROM nodes n
WHERE 1=1
`;
const queryParams = [...params];
if (clauses.length > 0) {
sql += ` AND ${clauses.join(' AND ')}`;
}
for (const word of words) {
sql += ` AND (n.title LIKE ? COLLATE NOCASE OR n.description LIKE ? COLLATE NOCASE OR n.source LIKE ? COLLATE NOCASE)`;
queryParams.push(`%${word}%`, `%${word}%`, `%${word}%`);
}
sql += ` ORDER BY
CASE WHEN LOWER(n.title) = LOWER(?) THEN 1 ELSE 6 END,
CASE WHEN LOWER(n.title) LIKE LOWER(?) THEN 2 ELSE 6 END,
CASE WHEN n.title LIKE ? COLLATE NOCASE THEN 3 ELSE 6 END,
CASE WHEN n.description LIKE ? COLLATE NOCASE THEN 4 ELSE 6 END,
CASE WHEN n.source LIKE ? COLLATE NOCASE THEN 5 ELSE 6 END,
n.updated_at DESC
LIMIT ?`;
queryParams.push(search, `${search}%`, `%${search}%`, `%${search}%`, `%${search}%`, limit);
return query(sql, queryParams);
}
function searchNodesLikeRelaxed(search, filters, limit) {
const terms = extractRelaxedSearchTerms(search);
if (terms.length === 0) return [];
const { clauses, params } = buildNodeFilterClauses(filters);
let sql = `
SELECT n.id, n.title, n.description, n.source, n.link, n.event_date, n.metadata,
n.created_at, n.updated_at
FROM nodes n
WHERE 1=1
`;
const queryParams = [...params];
if (clauses.length > 0) {
sql += ` AND ${clauses.join(' AND ')}`;
}
const termClauses = [];
for (const term of terms) {
termClauses.push(`n.title LIKE ? COLLATE NOCASE`);
termClauses.push(`n.description LIKE ? COLLATE NOCASE`);
termClauses.push(`n.source LIKE ? COLLATE NOCASE`);
queryParams.push(`%${term}%`, `%${term}%`, `%${term}%`);
}
sql += ` AND (${termClauses.join(' OR ')})`;
const scoreClauses = [];
const scoreParams = [];
for (const term of terms) {
scoreClauses.push(`CASE WHEN n.title LIKE ? COLLATE NOCASE THEN 3 ELSE 0 END`);
scoreClauses.push(`CASE WHEN n.description LIKE ? COLLATE NOCASE THEN 2 ELSE 0 END`);
scoreClauses.push(`CASE WHEN n.source LIKE ? COLLATE NOCASE THEN 1 ELSE 0 END`);
scoreParams.push(`%${term}%`, `%${term}%`, `%${term}%`);
}
sql += ` ORDER BY
(${scoreClauses.join(' + ')}) DESC,
CASE WHEN LOWER(n.title) LIKE LOWER(?) THEN 0 ELSE 1 END,
n.updated_at DESC
LIMIT ?`;
queryParams.push(...scoreParams, `%${search}%`, limit);
return query(sql, queryParams);
}
/**
* Get nodes with optional filtering.
*/
@@ -105,16 +366,6 @@ function getNodes(filters = {}) {
return rows.map(mapNodeRow);
}
/**
* Search nodes using the same filter object as getNodes.
*/
function searchNodes(filters = {}) {
if (typeof filters === 'string') {
return getNodes({ search: filters });
}
return getNodes(filters);
}
/**
* Get a single node by ID.
*/
@@ -17,18 +17,6 @@ const LEGACY_GUIDES_DIR = path.join(
const BUNDLED_SKILLS_DIR = path.join(__dirname, '..', 'skills');
const SEED_MIGRATION_FLAG = path.join(SKILLS_DIR, '.seed-migrated-2026-03-07-skills-overhaul');
const SEEDED_SKILL_IDS = new Set([
'db-operations',
'create-skill',
'audit',
'traverse',
'onboarding',
'persona',
'calibration',
'connect',
'node-context-enrichment',
]);
const DEPRECATED_SKILL_IDS = new Set([
'start-here',
'schema',
@@ -46,6 +34,14 @@ const DEPRECATED_SKILL_IDS = new Set([
'research',
'survey',
'traverse-graph',
'context-capsule',
'audit',
'calibration',
'connect',
'db-operations',
'node-context-enrichment',
'persona',
'traverse',
]);
function parseFrontmatter(raw) {
@@ -97,6 +93,16 @@ function listMarkdownFiles(dir) {
return fs.readdirSync(dir).filter((f) => f.endsWith('.md'));
}
function getBundledSkillFiles() {
const bundledById = new Map();
for (const file of listMarkdownFiles(BUNDLED_SKILLS_DIR)) {
bundledById.set(normalizeSkillId(file), path.join(BUNDLED_SKILLS_DIR, file));
}
return bundledById;
}
function ensureSkillsDir() {
if (!fs.existsSync(SKILLS_DIR)) {
fs.mkdirSync(SKILLS_DIR, { recursive: true });
@@ -116,15 +122,8 @@ function migrateLegacyGuides() {
function seedSkills() {
ensureSkillsDir();
const bundledFiles = listMarkdownFiles(BUNDLED_SKILLS_DIR);
const bundledById = new Map();
for (const file of bundledFiles) {
bundledById.set(normalizeSkillId(file), path.join(BUNDLED_SKILLS_DIR, file));
}
for (const skillId of SEEDED_SKILL_IDS) {
const source = bundledById.get(skillId);
if (!source) continue;
const bundledById = getBundledSkillFiles();
for (const [skillId, source] of bundledById.entries()) {
const dest = path.join(SKILLS_DIR, `${skillId}.md`);
if (!fs.existsSync(dest)) {
fs.copyFileSync(source, dest);
@@ -137,15 +136,8 @@ function migrateSeededBaseline() {
return;
}
const bundledFiles = listMarkdownFiles(BUNDLED_SKILLS_DIR);
const bundledById = new Map();
for (const file of bundledFiles) {
bundledById.set(normalizeSkillId(file), path.join(BUNDLED_SKILLS_DIR, file));
}
for (const skillId of SEEDED_SKILL_IDS) {
const source = bundledById.get(skillId);
if (!source) continue;
const bundledById = getBundledSkillFiles();
for (const [skillId, source] of bundledById.entries()) {
const dest = path.join(SKILLS_DIR, `${skillId}.md`);
fs.copyFileSync(source, dest);
}
@@ -1,28 +0,0 @@
---
name: Audit
description: "Use for structured review, QA, cleanup, or governance checks across graph quality, skill quality, and operational consistency."
---
# Audit
## Scope
1. Node quality: duplicates, vague descriptions, missing dates, weak titles.
2. Edge quality: missing links, weak explanations, wrong directionality.
3. Graph structure quality: weak clustering, redundant organizational nodes, or cases where stronger titles, descriptions, metadata, and edges should carry the meaning.
4. Skill quality: trigger clarity, overlap, dead/unused skills.
## Output Format
1. Critical issues
2. High-impact improvements
3. Cleanup actions
4. Optional refinements
## Rules
- Prefer specific evidence over generic commentary.
- Propose the smallest high-leverage fixes first.
- Separate defects from optional polish.
- Node descriptions must read like natural prose while still making what / why / status clear.
- Flag any node description missing a clear why or status component as a high-priority quality issue.
@@ -1,33 +0,0 @@
---
name: Calibration
description: "Run periodic check-ins to recalibrate goals, projects, preferences, and graph structure."
when_to_use: "User asks for a check-in, reset, review, or strategic recalibration."
when_not_to_use: "Single isolated question with no strategic update needed."
success_criteria: "Graph reflects current reality: updated nodes, changed priorities, and explicit deltas."
---
# Calibration
## Objective
Re-anchor the graph to the user's current state.
## Check-in Sequence
1. Review the strongest active nodes first.
2. Ask what changed: goals, priorities, constraints, beliefs, preferences.
3. Identify stale nodes and missing nodes.
4. Propose precise updates: update existing vs create new.
5. Apply changes with explicit rationale.
## Write Guidance
- Prefer updating existing nodes when continuity matters.
- Create new nodes for genuine shifts in direction/identity/approach.
- Add edges that explain evolution (old -> new).
## Output
- What changed
- What was updated vs newly created
- What should be reviewed next check-in
@@ -1,26 +0,0 @@
---
name: Connect
description: "Create and refine meaningful relationships between nodes, including integration-style synthesis links."
when_to_use: "User asks to connect ideas, merge insights, or strengthen graph relationships."
when_not_to_use: "Task is purely node creation with no relationship work required."
success_criteria: "High-signal edges improve retrieval quality and graph coherence."
---
# Connect
## Goal
Turn isolated nodes into a useful reasoning graph.
## Process
1. Identify candidate nodes to connect.
2. Check existing relationships first.
3. Add/upgrade edges with explicit directional explanation.
4. When integrating multiple nodes, create synthesis links that preserve provenance.
## Quality Bar
- Edge explanation must answer: "why does this relationship exist?"
- Direction should be intentional and interpretable.
- Avoid obvious/tautological edges.
@@ -1,42 +1,89 @@
---
name: Create Skill
description: "Design or refine a skill using a tight trigger, explicit contract, and measurable outcomes."
when_to_use: "User asks to create, rewrite, or improve a skill."
when_not_to_use: "Task is normal graph operation and no new skill is needed."
success_criteria: "Skill has clear trigger boundaries, execution steps, guardrails, and evaluation hooks."
description: "Use when the user explicitly wants to create, rewrite, or remove a skill, or when they describe a repeatable workflow that should probably become a skill."
---
# Create Skill
## Objective
## Goal
Create skills that are precise, callable, and testable.
Create small, focused skills with clear triggers, explicit tool guidance, and a clean execution contract. Suggest a skill when the same workflow is likely to recur.
## Skill Design Standard
## Use This When
1. Define trigger boundary clearly: when to use and when not to use.
2. Define required outputs and quality bar.
3. Specify concrete execution sequence.
4. Add hard guardrails (what to reject/avoid).
5. Keep it short; remove fluff and duplicate policy text.
- The user asks to create, rewrite, merge, or remove a skill.
- The user keeps describing a repeatable workflow that should become reusable doctrine.
- An existing skill is too vague, too broad, or overlaps with another skill enough that it should be reworked.
## Required Structure
At that point, it is appropriate to suggest creating or tightening a skill.
## Design Standard
1. One job per skill.
2. Put the trigger directly into `description`.
3. Keep the body procedural, short, and high-signal.
4. Name the tool path only when tool choice materially matters.
5. Make proposal/apply boundaries explicit.
6. Include guardrails and failure modes.
## Recommended Structure
- `name`
- `description`
- `when_to_use`
- `when_not_to_use`
- `success_criteria`
- Step-by-step procedure
- Do-not list
- one short goal section
- trigger / use cases
- step-by-step workflow
- tool pointers when relevant
- quality bar
- do-not list
## Validation Checklist
## Tool Pointers
- Can another agent execute this without guessing?
- Does it avoid overlap with existing skills?
- Are failure modes explicit?
- Is there an obvious way to evaluate success?
When tool choice matters, point to the matching family for the current surface instead of pretending every agent has the same names:
- Internal / standalone skill tools:
- `listSkills`
- `readSkill`
- `writeSkill`
- `deleteSkill`
- Packaged MCP skill tools:
- `rah_list_skills`
- `rah_read_skill`
- `rah_write_skill`
- `rah_delete_skill`
- Graph lookup tools for surrounding context:
- internal / standalone: `queryNodes`, `retrieveQueryContext`, `getNodesById`, `queryEdge`
- packaged MCP: `rah_search_nodes`, `rah_retrieve_query_context`, `rah_get_nodes`, `rah_query_edges`
Only mention the tools the skill genuinely depends on.
## Workflow
1. Decide whether this actually needs a skill. If the existing tool descriptions and core prompt contract are already enough, do not create one.
2. Identify the trigger, the repeatable user intent, and the expected output.
3. Check overlap with existing skills before adding another file.
4. Draft the `description` so another agent can trigger the skill from natural language without guessing.
5. Draft the shortest body that still makes the workflow, tool path, and failure modes explicit.
6. If tool choice differs by surface, include surface-aware tool pointers.
7. Save or rewrite the skill only after the user confirms the intended scope and wording.
8. If the new skill absorbs older skills, say which ones should be removed.
## Quality Bar
- Another agent can execute it without guessing.
- The description is action-oriented and triggerable.
- The procedure is sequential and concrete.
- The skill is focused enough that it will be called for a real workflow, not a vague topic area.
- It does not duplicate doctrine that should live in tool descriptions instead.
## Consolidation Rule
If two skills have the same trigger + same tool path + same output contract, merge them.
If two skills share the same trigger, tool path, and output contract, merge them.
## Do Not
- Create broad catch-all skills.
- Hide the trigger in the body while leaving the description generic.
- List every tool in the system.
- Split one workflow into multiple skills just because the old file count was higher.
- Keep a legacy skill only because it already exists.
@@ -1,92 +0,0 @@
---
name: DB Operations
description: "Use for graph read, write, connect, classify, or traverse operations with strict data quality standards."
---
# DB Operations
## Core Rules
1. First decide whether the user is trying to find a specific existing node or whether they want graph context to support a broader answer.
2. If the user is trying to find a specific existing node, use `queryNodes` first.
3. If the user is asking a substantive question or request that would benefit from prior graph context, use `retrieveQueryContext` for current-turn grounding instead of relying on orientation alone.
4. Search before create to avoid duplicates.
5. Every create/update must include a natural description that clearly says what the thing is, why it matters here, and its current workflow status.
6. Use event dates when known (when it happened, not when saved).
7. Leave extra taxonomy out of normal writes. Good nodes and good edges should carry the structure.
8. Do not create edges autonomously. Surface likely edge candidates first, then create them only after the user explicitly confirms.
9. For user-authored ideas, notes, or dictated thoughts, preserve the user's wording in `source` as fully as possible with only minimal cleanup.
## Write Quality Contract
- `title`: clear and specific.
- `description`: concrete object-level description, not vague summaries.
- `source`: full verbatim or canonical content of the node (transcript, article text, book passage, user's thoughts). The standalone MCP server stores this on the node. The RA-H app later chunks and embeds it for semantic search.
- For idea capture from chat, the `source` should usually be the raw user thought, not a compressed assistant summary.
- `link`: external source URL only.
- `metadata`: use the canonical node metadata contract when metadata is needed:
- `type`
- `state` (`processed` or `not_processed`)
- `captured_method`
- `captured_by`
- `source_metadata`
- `source_metadata`: factual source-specific details only. Keep it compact. No AI summaries or reasoning text.
- metadata updates are merge-safe patches, not full-blob replacements. Do not assume `updateNode.metadata` wipes existing keys.
- Derived analysis, briefs, and research notes should be stored in a separate linked node, not appended to the source node.
- Explicit user-directed capture may write immediately after duplicate/update checks when the node is clear.
- Agent-suggested capture should propose the node first and wait for confirmation.
## Description Standard
Every node description should read like natural prose, not a template or checklist.
It must still make three things clear:
1. What — what the artifact is in simple explicit terms (format + creator + core claim)
2. Why — why it is in the graph; what Brad is interested in; what it connects to
3. Status — where it sits in his workflow (queued, in progress, processed, unknown)
If the agent has graph context (focused nodes, recent connected nodes, or nearby graph structure), it should infer the why from that context and write it naturally. Do not let the service auto-generate a weak context-free description when you already have enough signal.
If the why genuinely cannot be inferred, say that naturally. Do not use labels like `WHAT:`, `WHY:`, or `STATUS:` and do not substitute vague filler like `insightful for understanding` or `relevant to Brad's work`.
If status is unknown, say naturally that it has not been reviewed yet.
Ask a clarification question only when a missing detail would materially change the node being created. If the user has already given enough substance to infer the artifact, title, and likely why, do the work instead of bouncing it back.
For user-authored idea capture, do not treat the inferred description as final if the "why" or status was mostly inferred. Save the node first, then tell the user what description framing you inferred and invite one short correction pass on:
- what this is
- why it belongs here
- where it sits in their workflow
The first job of the description is object identity. It should start from what the thing is, not from interpretation.
Max 500 characters.
## Metadata Semantics
- Direct user creation, quick add, and user-requested agent capture should default to `captured_by = "human"`.
- Only autonomous/background creation without direct user instruction should use `captured_by = "agent"`.
- Prefer leaving `type` blank over forcing a weak label.
- `state` is the user-visible processed flag. If no state is known, default to `not_processed`.
## Execution Pattern
1. Decide whether this is direct node retrieval or broader contextual grounding.
2. If the user is trying to find a specific existing node, call `queryNodes` first.
3. If the user is asking a broader question that would benefit from prior graph context, call `retrieveQueryContext`.
4. Decide: answer only vs create vs update vs propose save vs propose edge.
5. If something seems unusually durable and valuable, you may suggest a save in one short line like `Add "X" as a node?`
6. Do not pester. If the user says no, ignores it, or moves on, do not keep asking.
7. For explicit user-directed capture, search before create when practical, prefer update over duplicate create, then write once the artifact is clear.
8. For agent-suggested capture, propose the node first and wait for explicit confirmation before writing.
9. When relationships are obvious, include brief proposed edges in the same reply, then wait for confirmation before calling `createEdge`.
10. If the node is a user-authored idea and the contextual framing was inferred, offer one concise feedback pass after the write.
11. Verify result reflects user intent exactly.
## Do Not
- Create duplicate nodes when an update is correct.
- Write vague descriptions ("discusses", "explores", "is about").
- Create weak or directionless edges.
- Create edges before the user explicitly confirms the proposed relationship.
- Ask to save every moderately useful point from the conversation.
@@ -1,85 +0,0 @@
---
name: Node Context Enrichment
description: "Use to rewrite thin node descriptions into natural prose that still makes what, why, and status clear, with graph review and edge suggestions."
---
# Node Context Enrichment
Use this when a node already exists but its description is thin, generic, or missing useful graph framing.
This skill should not silently rewrite and move on when framing is inferred. If the enrichment depends on interpretation, update the node and then explicitly invite the user to correct or refine that framing.
## Goal
Replace weak descriptions with a single clean natural description that captures:
1. What the artifact literally is
2. Why it is in Brad's graph
3. Status in Brad's workflow
Also review whether the node needs obvious edge suggestions.
## Workflow
1. Load the node and inspect title, description, source, link, metadata, and nearby edges.
2. Search for adjacent graph context before rewriting:
- recently connected project or belief nodes
- related nodes with overlapping titles, creators, or neighboring structure
3. Infer the best available "why" from that graph context.
4. Rewrite the full description from scratch in natural prose. Do not append to the old text or use labels like WHAT:, WHY:, or STATUS:.
5. Suggest 1-3 high-signal edges when obvious.
6. Update the node once the description is strong enough to be useful.
7. After the update, tell the user what changed and ask whether they want to refine the important framing:
- what it is
- why it belongs in the graph
- status / current relevance / workflow position
The user feedback pass is required whenever the enriched "why" or status was inferred rather than directly stated in the node/source.
## Description Standard
Every rewritten description must naturally cover:
1. What
- explicit artifact type
- creator/author/speaker when known
- core subject, claim, or function
2. Why
- why Brad saved it
- what project, belief, question, or theme it connects to
- if genuinely unknown, say that naturally without inventing graph framing
3. Status
- queued, in progress, processed, not yet reviewed, saved for later, etc.
- if unknown, say naturally that it has not been reviewed yet
Max 500 characters.
## Batch Mode
Use batch enrichment when cleaning up many nodes with the same failure mode.
1. Pull a tight node set first.
2. Group by pattern:
- vague imported links
- thin quick-add captures
- old source nodes missing workflow state
3. Enrich each node individually. Do not reuse boilerplate "why" text across unrelated nodes.
4. Return a compact summary of:
- nodes updated
- edge suggestions not yet created
## Quality Bar
- No filler phrases like `insightful for understanding`, `relevant to`, or `important for`.
- No generic summaries that only restate the topic.
- No invented certainty. If graph evidence is weak, say so explicitly.
- Prefer one compact 3-sentence description over bloated prose.
## Output Pattern
For each node:
- New description
- Framing note: what graph context influenced the rewrite, if any
- Edge suggestions: source -> target with explicit explanation
- One short invitation for user feedback when framing was inferred
@@ -7,7 +7,7 @@ description: "Use for new-user setup, empty or near-empty graphs, or major reset
## Your Job
Three things: help the user understand the basic structure of the system, help them start building useful graph data in it, and get them to a useful starter graph quickly.
Three things: help the user understand the basic structure of the system, help them start building useful graph data in it, and turn early useful context into strong nodes and edges.
Adapt to the user.
@@ -89,18 +89,19 @@ Work these in naturally when they are relevant:
Do your best to build the graph as useful context emerges.
- Add nodes when the user mentions concrete things worth keeping.
- Add edges when relationships are clear enough to explain well.
- Surface likely edges when relationships are clear enough to explain well, but create them only after the user confirms.
- Explain what you're adding in plain language so the user understands the structure as it develops.
- During normal conversation outside explicit onboarding capture, do not keep asking to save every useful statement. Only suggest a save when the context is unusually durable and valuable, and keep the prompt brief.
When the graph is empty or nearly empty, bias toward creating a small, clean starter set rather than over-modeling everything.
## Write Standards
Before writing anything, call `readSkill('db-operations')` for full quality standards. Key points that matter most here:
Before writing anything, rely on the direct graph tools and their descriptions. If the user shifts from onboarding into cleanup of an existing node or small node set, call `readSkill('refine')`. Key points that matter most here:
- Search before creating — avoid duplicates from day one
- Every description must be concrete: what it IS and why it matters to them, not what it "explores" or "discusses"
- Every edge needs an explicit explanation sentence
- Every edge needs an explicit explanation sentence, and agent-driven edge creation should only happen after confirmation
## Propose Before Writing
@@ -1,30 +0,0 @@
---
name: Persona
description: "Build and maintain a user-defined agent persona and interaction style profile."
when_to_use: "User asks to set or refine how the agent should behave."
when_not_to_use: "No behavior/persona request is present."
success_criteria: "Persona is explicit, editable, and consistently applied across interactions."
---
# Persona
## Objective
Make agent behavior fully malleable to the user.
## Capture Model
1. Communication style (directness, brevity, tone).
2. Thinking style (exploratory vs decisive, framework-heavy vs practical).
3. Decision style (challenge level, risk posture, evidence threshold).
4. Collaboration style (pushback expectations, cadence, format preferences).
## Persistence Pattern
- Store persona as explicit nodes (and updates over time), not hidden assumptions.
- Keep versioned changes visible to the user.
## Do Not
- Freeze persona permanently.
- Apply unstated style assumptions when user instructions conflict.
@@ -0,0 +1,97 @@
---
name: Refine
description: "Use when the user asks to clean up, sharpen, split, or otherwise refine one node or a small set of nodes, including title, description, source, and likely edges."
---
# Refine
## Goal
Turn nodes into atomic, high-signal units of context. Inspect first, propose specific improvements, then apply only after the user confirms.
## Default Posture
- Refinement is proposal-first. Show the suggested title, description, source handling, and edge changes before writing unless the user explicitly says to apply immediately.
- Bias toward atomic units of context: one clear idea, source, person, decision, project, or claim per node.
- If a node contains multiple durable ideas, recommend a split, but do not push so hard that cleanup turns into friction.
## Tool Path
Load the target node before you suggest changes:
- internal / standalone:
- `getNodesById`
- packaged MCP:
- `rah_get_nodes`
Gather surrounding graph context when needed:
- internal / standalone:
- `queryNodes`
- `retrieveQueryContext`
- `queryEdge`
- packaged MCP:
- `rah_search_nodes`
- `rah_retrieve_query_context`
- `rah_query_edges`
Apply approved changes with the matching write tools:
- internal / standalone:
- `updateNode`
- `createNode`
- `createEdge`
- `updateEdge`
- packaged MCP:
- `rah_update_node`
- `rah_add_node`
- `rah_create_edge`
- `rah_update_edge`
## Workflow
1. Load the node and inspect `title`, `description`, `source`, `link`, metadata, and current edges.
2. Decide whether the node is already atomic or whether it is carrying multiple durable ideas that should probably be split.
3. Tighten the title so it is specific and scannable. Prefer explicit subject + claim/theme over vague labels.
4. Rewrite the description in natural prose so it clearly says:
- what this thing is
- why it belongs in the graph
- what its current status or relevance is when known
5. Handle `source` carefully:
- if it is the user's own idea, preserve their wording as much as possible with only minimal cleanup
- if it is an external artifact, keep `source` as the canonical raw text, not an assistant summary
- if the source is bloated because it really contains multiple ideas, suggest splitting it into multiple nodes
6. Search for nearby graph context when it would improve the refinement:
- likely duplicate or sibling nodes
- projects, beliefs, or themes that explain why this node belongs
- existing edges that are weak or missing
7. Suggest 1-3 high-signal edges when obvious. Use search and edge-query tools to find the right targets before proposing them.
8. Return a compact proposal that includes:
- suggested title
- suggested description
- suggested source handling or edited source excerpt
- suggested split nodes if needed
- suggested edges with explicit explanations
9. Wait for the user's yes/no or edits.
10. After approval, apply the node updates first, then create or update the approved edges.
11. Report exactly what changed and what still needs user judgment.
## Batch Mode
For a small set of nodes, refine each node individually. Do not reuse boilerplate descriptions across unrelated nodes.
## Quality Bar
- Titles are explicit enough to scan quickly.
- Descriptions stand on their own and make the node legible.
- `source` preserves provenance and the user's real wording.
- Edge suggestions explain why the relationship exists.
- The user can approve or reject the proposal quickly.
## Do Not
- Silently rewrite a node and move on.
- Collapse a user-authored idea into a one-line assistant summary.
- Force a split when a simple cleanup is enough.
- Create edges before the user confirms them.
- Use vague description language like "is about", "explores", or "discusses".
@@ -1,28 +0,0 @@
---
name: Traverse
description: "Gather deeper context by traversing connected nodes before answering."
when_to_use: "Question benefits from broader context than a single direct lookup."
when_not_to_use: "Simple factual answer can be resolved from one direct node."
success_criteria: "Answer is grounded in relevant connected context without over-traversal noise."
---
# Traverse
## Strategy
1. Start with seed nodes from the user query.
2. Expand neighbors breadth-first with depth limits.
3. Prioritize high-signal nodes (strong relevance + strong edge semantics).
4. Pull key evidence from top nodes.
5. Answer directly and explain why chosen nodes mattered.
## Defaults
- Depth: 2
- Seed count: 3-5
- Exploration budget: ~40 nodes
## Do Not
- Traverse without limits.
- Include distant low-signal nodes just to add volume.
+159 -19
View File
@@ -44,12 +44,14 @@ let logger = (message) => console.log(`[mcp] ${message}`);
const instructions = [
'RA-H is a personal knowledge graph — local-first, vendor-neutral.',
'Core concepts: nodes (knowledge units) and edges (connections with explanations).',
'The graph is the default working memory for substantive turns.',
'Core concepts: nodes (knowledge units), edges (connections with explanations), and shared editable skills.',
'If the user is trying to find a specific existing node, use rah_search_nodes first.',
'If graph context would help with a broader task, use rah_retrieve_query_context.',
'If graph context would help with a broader task, use rah_retrieve_query_context before answering.',
'Use rah_get_context only when high-level graph orientation would actually help.',
'Do not keep re-running retrieval if you already have enough relevant graph context in play.',
'Search before creating: use rah_search_nodes to check if content already exists.',
'Search before creating, and prefer rah_update_node when the artifact is clearly the same thing.',
'Use rah_list_skills and rah_read_skill for non-trivial workflows that need operating doctrine. Use rah_write_skill and rah_delete_skill when the user explicitly wants to change that shared skill set.',
'Only suggest saving durable knowledge when it is unusually valuable. Keep the ask brief, for example: Add "X" as a node?',
'Do not create edges autonomously. Surface likely edge candidates briefly, then call edge-write tools only after the user explicitly confirms.',
'Every edge needs an explanation: why does this connection exist?',
@@ -151,7 +153,8 @@ const updateNodeInputSchema = {
source: z.string().optional().describe('Canonical source text for embedding.'),
link: z.string().optional().describe('New link'),
metadata: z.record(z.any()).optional().describe('Metadata patch. This now merges with existing metadata. Prefer canonical keys: type, state, captured_method, captured_by, source_metadata.')
}).describe('Fields to update')
}).describe('Fields to update'),
source_update_basis: z.string().optional().describe('When rewriting source on a node that already has source text, include a short exact excerpt from the current source you inspected first.')
};
const updateNodeOutputSchema = {
@@ -172,12 +175,56 @@ const getNodesOutputSchema = {
id: z.number(),
title: z.string(),
source: z.string().nullable(),
description: z.string().nullable(),
link: z.string().nullable(),
updated_at: z.string()
})
)
};
const listSkillsOutputSchema = {
count: z.number(),
skills: z.array(
z.object({
name: z.string(),
description: z.string(),
immutable: z.boolean().optional(),
})
)
};
const readSkillInputSchema = {
name: z.string().min(1).describe('Skill name')
};
const readSkillOutputSchema = {
name: z.string(),
description: z.string(),
immutable: z.boolean(),
content: z.string()
};
const writeSkillInputSchema = {
name: z.string().min(1).describe('Skill name to create or update'),
content: z.string().min(1).describe('Full markdown content, including frontmatter when needed')
};
const writeSkillOutputSchema = {
success: z.boolean(),
name: z.string(),
message: z.string()
};
const deleteSkillInputSchema = {
name: z.string().min(1).describe('Skill name to delete')
};
const deleteSkillOutputSchema = {
success: z.boolean(),
name: z.string(),
message: z.string()
};
// rah_create_edge schemas
const createEdgeInputSchema = {
sourceId: z.number().int().positive().describe('Source node ID'),
@@ -327,7 +374,7 @@ mcpServer.registerTool(
'rah_add_node',
{
title: 'Add RA-H node',
description: 'Create a new node in the local RA-H knowledge base after you have already decided a net-new write is correct. If the user explicitly asked to save or import something and the target artifact is clear, write after duplicate/update checks. If you are only suggesting a save, propose the node first and wait for confirmation.',
description: 'Create a new node after you have already decided a net-new write is correct. Search first with rah_search_nodes, and prefer rah_update_node if the artifact is clearly the same thing. If the user explicitly asked to save or import something and the target artifact is clear, write after duplicate/update checks. If you are only suggesting a save, propose the node first and wait for confirmation.',
inputSchema: addNodeInputSchema,
outputSchema: addNodeOutputSchema
},
@@ -363,7 +410,7 @@ mcpServer.registerTool(
'rah_search_nodes',
{
title: 'Search RA-H nodes',
description: 'Find existing RA-H entries that mention a topic before adding new ones. Use this first when the user is trying to locate a specific node they already created. For full current-turn grounding of a substantive request, prefer rah_retrieve_query_context.',
description: 'Find existing RA-H entries that mention a topic before adding new ones. Use this first for direct lookup, duplicate checks, or when the user seems to be referring to an existing node. For broader current-turn grounding, prefer rah_retrieve_query_context.',
inputSchema: searchNodesInputSchema,
outputSchema: searchNodesOutputSchema
},
@@ -406,7 +453,7 @@ mcpServer.registerTool(
'rah_retrieve_query_context',
{
title: 'Retrieve RA-H query context',
description: 'Given the raw user query plus optional focused node state, retrieve the most relevant graph context for the current turn. It starts with direct graph search and broadens only if useful. Use this when graph context could help answer or complete a broader task. For explicit node lookup, use rah_search_nodes.',
description: 'Given the raw user query plus optional focused node state, retrieve the most relevant graph context for the current turn. Use this when graph context could help answer, plan, or complete a broader task. For explicit node lookup or duplicate checks, use rah_search_nodes first.',
inputSchema: retrieveQueryContextInputSchema,
outputSchema: retrieveQueryContextOutputSchema
},
@@ -431,11 +478,11 @@ mcpServer.registerTool(
'rah_update_node',
{
title: 'Update RA-H node',
description: 'Update an existing node when it is clearly the same artifact and a net-new node would be redundant. Explicit user-directed updates can proceed once the target node is clear.',
description: 'Update an existing node when it is clearly the same artifact and a net-new node would be redundant. Inspect current state with rah_get_nodes first when accuracy matters. When rewriting source on a node that already has source text, inspect that source first and include source_update_basis as a short exact excerpt you actually read.',
inputSchema: updateNodeInputSchema,
outputSchema: updateNodeOutputSchema
},
async ({ id, updates }) => {
async ({ id, updates, source_update_basis }) => {
if (!updates || Object.keys(updates).length === 0) {
throw new McpError(ErrorCode.InvalidParams, 'At least one field must be provided in updates.');
}
@@ -453,7 +500,7 @@ mcpServer.registerTool(
const result = await callRaHApi(`/api/nodes/${id}`, {
method: 'PUT',
body: JSON.stringify(mappedUpdates)
body: JSON.stringify({ ...mappedUpdates, source_update_basis })
});
const node = result.node || result.data;
@@ -472,7 +519,7 @@ mcpServer.registerTool(
'rah_get_nodes',
{
title: 'Get RA-H nodes by ID',
description: 'Load full node records by their IDs.',
description: 'Load node records by ID, including current source text and description. Use this before rewriting source or when a focused-node excerpt is not enough.',
inputSchema: getNodesInputSchema,
outputSchema: getNodesOutputSchema
},
@@ -491,6 +538,7 @@ mcpServer.registerTool(
id: result.node.id,
title: result.node.title,
source: result.node.source ?? null,
description: result.node.description ?? null,
link: result.node.link ?? null,
updated_at: result.node.updated_at
});
@@ -514,7 +562,7 @@ mcpServer.registerTool(
'rah_create_edge',
{
title: 'Create RA-H edge',
description: 'Create a connection between two nodes only after the user has explicitly confirmed the proposed relationship.',
description: 'Create a connection between two nodes only after the user has explicitly confirmed the proposed relationship. Check existing edges first when you are not already sure the relationship is new.',
inputSchema: createEdgeInputSchema,
outputSchema: createEdgeOutputSchema
},
@@ -587,7 +635,7 @@ mcpServer.registerTool(
'rah_update_edge',
{
title: 'Update RA-H edge',
description: 'Update an existing edge connection only after the user explicitly confirmed the corrected relationship.',
description: 'Update an existing edge connection only after the user explicitly confirmed the corrected relationship. Use this when the connection already exists and only the explanation needs correction.',
inputSchema: updateEdgeInputSchema,
outputSchema: updateEdgeOutputSchema
},
@@ -614,6 +662,94 @@ mcpServer.registerTool(
}
);
mcpServer.registerTool(
'rah_list_skills',
{
title: 'List RA-H skills',
description: 'List the shared skills available to internal and external RA-H agents. Use this to see the current operating doctrine before reading or editing a specific skill.',
inputSchema: {},
outputSchema: listSkillsOutputSchema
},
async () => {
const result = await callRaHApi('/api/skills', { method: 'GET' });
const skills = Array.isArray(result.data) ? result.data : [];
return {
content: [{ type: 'text', text: `Found ${skills.length} skill(s).` }],
structuredContent: {
count: skills.length,
skills
}
};
}
);
mcpServer.registerTool(
'rah_read_skill',
{
title: 'Read RA-H skill',
description: 'Read one shared RA-H skill by name. Use this before executing a non-trivial workflow that matches the skill trigger.',
inputSchema: readSkillInputSchema,
outputSchema: readSkillOutputSchema
},
async ({ name }) => {
const result = await callRaHApi(`/api/skills/${encodeURIComponent(name)}`, { method: 'GET' });
return {
content: [{ type: 'text', text: result.data.content }],
structuredContent: result.data
};
}
);
mcpServer.registerTool(
'rah_write_skill',
{
title: 'Write RA-H skill',
description: 'Create or update a shared RA-H skill when the user explicitly wants to change the doctrine surface. Content should be the full markdown body for that skill.',
inputSchema: writeSkillInputSchema,
outputSchema: writeSkillOutputSchema
},
async ({ name, content }) => {
const result = await callRaHApi('/api/skills', {
method: 'POST',
body: JSON.stringify({ name, content })
});
return {
content: [{ type: 'text', text: `Skill "${name}" saved.` }],
structuredContent: {
success: true,
name,
message: result.message || `Skill "${name}" saved.`,
}
};
}
);
mcpServer.registerTool(
'rah_delete_skill',
{
title: 'Delete RA-H skill',
description: 'Delete a shared RA-H skill when the user explicitly wants it removed from the shared skill set.',
inputSchema: deleteSkillInputSchema,
outputSchema: deleteSkillOutputSchema
},
async ({ name }) => {
const result = await callRaHApi(`/api/skills/${encodeURIComponent(name)}`, {
method: 'DELETE'
});
return {
content: [{ type: 'text', text: `Skill "${name}" deleted.` }],
structuredContent: {
success: true,
name,
message: result.message || `Skill "${name}" deleted.`,
}
};
}
);
mcpServer.registerTool(
'rah_search_embeddings',
{
@@ -734,12 +870,16 @@ mcpServer.registerTool(
'rah_get_context',
{
title: 'Get RA-H context',
description: 'Get orientation context: high-level graph state, hub nodes, stats, and available guides. Use this for orientation only, not as the default retrieval path for substantive requests.',
description: 'Get orientation context: high-level graph state, hub nodes, stats, and available skills. Use this for orientation only, not as the default retrieval path for substantive requests.',
inputSchema: {},
outputSchema: {
stats: z.object({ nodeCount: z.number(), edgeCount: z.number() }),
hubNodes: z.array(z.object({ id: z.number(), title: z.string(), description: z.string().nullable(), edgeCount: z.number() })),
guides: z.array(z.string())
skills: z.array(z.object({
name: z.string(),
description: z.string(),
immutable: z.boolean().optional(),
}))
}
},
async () => {
@@ -748,8 +888,8 @@ mcpServer.registerTool(
id: n.id, title: n.title, description: n.description ?? null, edgeCount: n.edge_count ?? 0
})) : [];
const guideResult = await callRaHApi('/api/guides', { method: 'GET' });
const guides = Array.isArray(guideResult.data) ? guideResult.data.map(g => g.name) : [];
const skillResult = await callRaHApi('/api/skills', { method: 'GET' });
const skills = Array.isArray(skillResult.data) ? skillResult.data : [];
const stats = { nodeCount: 0, edgeCount: 0 };
try {
@@ -763,8 +903,8 @@ mcpServer.registerTool(
} catch { /* use defaults */ }
return {
content: [{ type: 'text', text: `Knowledge graph: ${stats.nodeCount} nodes, ${stats.edgeCount} edges, ${hubNodes.length} hub nodes, ${guides.length} guides available.` }],
structuredContent: { stats, hubNodes, guides }
content: [{ type: 'text', text: `Knowledge graph: ${stats.nodeCount} nodes, ${stats.edgeCount} edges, ${hubNodes.length} hub nodes, ${skills.length} skills available.` }],
structuredContent: { stats, hubNodes, skills }
};
}
);
+125 -20
View File
@@ -11,12 +11,14 @@ const packageJson = require('../../package.json');
const instructions = [
'RA-H is a personal knowledge graph — local-first, vendor-neutral.',
'Core concepts: nodes (knowledge units) and edges (connections with explanations).',
'The graph is the default working memory for substantive turns.',
'Core concepts: nodes (knowledge units), edges (connections with explanations), and shared editable skills.',
'If the user is trying to find a specific existing node, use rah_search_nodes first.',
'If graph context would help with a broader task, use rah_retrieve_query_context.',
'If graph context would help with a broader task, use rah_retrieve_query_context before answering.',
'Use rah_get_context only when high-level graph orientation would actually help.',
'Do not keep re-running retrieval if you already have enough relevant graph context in play.',
'Search before creating: use rah_search_nodes to check if content already exists.',
'Search before creating, and prefer rah_update_node when the artifact is clearly the same thing.',
'Use rah_list_skills and rah_read_skill for non-trivial workflows that need operating doctrine. Use rah_write_skill and rah_delete_skill when the user explicitly wants to change that shared skill set.',
'Only suggest saving durable knowledge when it is unusually valuable. Keep the ask brief, for example: Add "X" as a node?',
'Do not create edges autonomously. Surface likely edge candidates briefly, then call edge-write tools only after the user explicitly confirms.',
'Every edge needs an explanation: why does this connection exist?',
@@ -117,7 +119,8 @@ const updateNodeInputSchema = {
source: z.string().optional().describe('Canonical source text for embedding.'),
link: z.string().optional().describe('New link'),
metadata: z.record(z.any()).optional().describe('Metadata patch. This now merges with existing metadata. Prefer canonical keys: type, state, captured_method, captured_by, source_metadata.')
}).describe('Fields to update')
}).describe('Fields to update'),
source_update_basis: z.string().optional().describe('When rewriting source on a node that already has source text, include a short exact excerpt from the current source you inspected first.')
};
const updateNodeOutputSchema = {
@@ -138,12 +141,26 @@ const getNodesOutputSchema = {
id: z.number(),
title: z.string(),
source: z.string().nullable(),
description: z.string().nullable(),
link: z.string().nullable(),
updated_at: z.string()
})
)
};
const readSkillInputSchema = {
name: z.string().min(1).describe('Skill name')
};
const writeSkillInputSchema = {
name: z.string().min(1).describe('Skill name to create or update'),
content: z.string().min(1).describe('Full markdown content, including frontmatter when needed')
};
const deleteSkillInputSchema = {
name: z.string().min(1).describe('Skill name to delete')
};
// rah_create_edge schemas
const createEdgeInputSchema = {
sourceId: z.number().int().positive().describe('Source node ID'),
@@ -322,7 +339,7 @@ server.registerTool(
'rah_add_node',
{
title: 'Add RA-H node',
description: 'Create a new node in the local RA-H knowledge base after you have already decided a net-new write is correct. If the user explicitly asked to save or import something and the target artifact is clear, write after duplicate/update checks. If you are only suggesting a save, propose the node first and wait for confirmation.',
description: 'Create a new node after you have already decided a net-new write is correct. Search first with rah_search_nodes, and prefer rah_update_node if the artifact is clearly the same thing. If the user explicitly asked to save or import something and the target artifact is clear, write after duplicate/update checks. If you are only suggesting a save, propose the node first and wait for confirmation.',
inputSchema: addNodeInputSchema,
outputSchema: addNodeOutputSchema
},
@@ -358,7 +375,7 @@ server.registerTool(
'rah_search_nodes',
{
title: 'Search RA-H nodes',
description: 'Find existing RA-H entries that mention a topic before adding new ones. Use this first when the user is trying to locate a specific node they already created. For full current-turn grounding of a substantive request, prefer rah_retrieve_query_context.',
description: 'Find existing RA-H entries that mention a topic before adding new ones. Use this first for direct lookup, duplicate checks, or when the user seems to be referring to an existing node. For broader current-turn grounding, prefer rah_retrieve_query_context.',
inputSchema: searchNodesInputSchema,
outputSchema: searchNodesOutputSchema
},
@@ -402,7 +419,7 @@ server.registerTool(
'rah_retrieve_query_context',
{
title: 'Retrieve RA-H query context',
description: 'Given the raw user query plus optional focused node state, retrieve the most relevant graph context for the current turn. It starts with direct graph search and broadens only if useful. Use this when graph context could help answer or complete a broader task. For explicit node lookup, use rah_search_nodes.',
description: 'Given the raw user query plus optional focused node state, retrieve the most relevant graph context for the current turn. Use this when graph context could help answer, plan, or complete a broader task. For explicit node lookup or duplicate checks, use rah_search_nodes first.',
inputSchema: retrieveQueryContextInputSchema,
outputSchema: retrieveQueryContextOutputSchema
},
@@ -427,11 +444,11 @@ server.registerTool(
'rah_update_node',
{
title: 'Update RA-H node',
description: 'Update an existing node when it is clearly the same artifact and a net-new node would be redundant. Explicit user-directed updates can proceed once the target node is clear.',
description: 'Update an existing node when it is clearly the same artifact and a net-new node would be redundant. Inspect current state with rah_get_nodes first when accuracy matters. When rewriting source on a node that already has source text, inspect that source first and include source_update_basis as a short exact excerpt you actually read.',
inputSchema: updateNodeInputSchema,
outputSchema: updateNodeOutputSchema
},
async ({ id, updates }) => {
async ({ id, updates, source_update_basis }) => {
if (!updates || Object.keys(updates).length === 0) {
throw new Error('At least one field must be provided in updates.');
}
@@ -449,7 +466,7 @@ server.registerTool(
const result = await callRaHApi(`/api/nodes/${id}`, {
method: 'PUT',
body: JSON.stringify(mappedUpdates)
body: JSON.stringify({ ...mappedUpdates, source_update_basis })
});
const node = result.node || result.data;
@@ -468,7 +485,7 @@ server.registerTool(
'rah_get_nodes',
{
title: 'Get RA-H nodes by ID',
description: 'Load full node records by their IDs.',
description: 'Load node records by ID, including current source text and description. Use this before rewriting source or when a focused-node excerpt is not enough.',
inputSchema: getNodesInputSchema,
outputSchema: getNodesOutputSchema
},
@@ -487,6 +504,7 @@ server.registerTool(
id: result.node.id,
title: result.node.title,
source: result.node.source ?? null,
description: result.node.description ?? null,
link: result.node.link ?? null,
updated_at: result.node.updated_at
});
@@ -510,7 +528,7 @@ server.registerTool(
'rah_create_edge',
{
title: 'Create RA-H edge',
description: 'Create a connection between two nodes only after the user has explicitly confirmed the proposed relationship.',
description: 'Create a connection between two nodes only after the user has explicitly confirmed the proposed relationship. Check existing edges first when you are not already sure the relationship is new.',
inputSchema: createEdgeInputSchema,
outputSchema: createEdgeOutputSchema
},
@@ -583,7 +601,7 @@ server.registerTool(
'rah_update_edge',
{
title: 'Update RA-H edge',
description: 'Update an existing edge connection only after the user explicitly confirmed the corrected relationship.',
description: 'Update an existing edge connection only after the user explicitly confirmed the corrected relationship. Use this when the connection already exists and only the explanation needs correction.',
inputSchema: updateEdgeInputSchema,
outputSchema: updateEdgeOutputSchema
},
@@ -610,6 +628,90 @@ server.registerTool(
}
);
server.registerTool(
'rah_list_skills',
{
title: 'List RA-H skills',
description: 'List the shared skills available to internal and external RA-H agents. Use this to see the current operating doctrine before reading or editing a specific skill.',
inputSchema: {}
},
async () => {
const result = await callRaHApi('/api/skills', { method: 'GET' });
const skills = Array.isArray(result.data) ? result.data : [];
return {
content: [{ type: 'text', text: `Found ${skills.length} skill(s).` }],
structuredContent: {
count: skills.length,
skills
}
};
}
);
server.registerTool(
'rah_read_skill',
{
title: 'Read RA-H skill',
description: 'Read one shared RA-H skill by name. Use this before executing a non-trivial workflow that matches the skill trigger.',
inputSchema: readSkillInputSchema
},
async ({ name }) => {
const result = await callRaHApi(`/api/skills/${encodeURIComponent(name)}`, { method: 'GET' });
return {
content: [{ type: 'text', text: result.data.content }],
structuredContent: result.data
};
}
);
server.registerTool(
'rah_write_skill',
{
title: 'Write RA-H skill',
description: 'Create or update a shared RA-H skill when the user explicitly wants to change the doctrine surface. Content should be the full markdown body for that skill.',
inputSchema: writeSkillInputSchema
},
async ({ name, content }) => {
const result = await callRaHApi('/api/skills', {
method: 'POST',
body: JSON.stringify({ name, content })
});
return {
content: [{ type: 'text', text: `Skill "${name}" saved.` }],
structuredContent: {
success: true,
name,
message: result.message || `Skill "${name}" saved.`
}
};
}
);
server.registerTool(
'rah_delete_skill',
{
title: 'Delete RA-H skill',
description: 'Delete a shared RA-H skill when the user explicitly wants it removed from the shared skill set.',
inputSchema: deleteSkillInputSchema
},
async ({ name }) => {
const result = await callRaHApi(`/api/skills/${encodeURIComponent(name)}`, {
method: 'DELETE'
});
return {
content: [{ type: 'text', text: `Skill "${name}" deleted.` }],
structuredContent: {
success: true,
name,
message: result.message || `Skill "${name}" deleted.`
}
};
}
);
server.registerTool(
'rah_search_embeddings',
{
@@ -737,14 +839,18 @@ const getContextOutputSchema = {
description: z.string().nullable(),
edgeCount: z.number()
})),
guides: z.array(z.string())
skills: z.array(z.object({
name: z.string(),
description: z.string(),
immutable: z.boolean().optional(),
}))
};
server.registerTool(
'rah_get_context',
{
title: 'Get RA-H context',
description: 'Get orientation context: high-level graph state, hub nodes, stats, and available guides. Use this for orientation only, not as the default retrieval path for substantive requests.',
description: 'Get orientation context: high-level graph state, hub nodes, stats, and available skills. Use this for orientation only, not as the default retrieval path for substantive requests.',
inputSchema: {},
outputSchema: getContextOutputSchema
},
@@ -757,9 +863,8 @@ server.registerTool(
edgeCount: n.edge_count ?? 0
})) : [];
// Fetch guides
const guideResult = await callRaHApi('/api/guides', { method: 'GET' });
const guides = Array.isArray(guideResult.data) ? guideResult.data.map(g => g.name) : [];
const skillResult = await callRaHApi('/api/skills', { method: 'GET' });
const skills = Array.isArray(skillResult.data) ? skillResult.data : [];
const stats = {
nodeCount: 0,
@@ -780,14 +885,14 @@ server.registerTool(
}
} catch { /* use defaults */ }
const summary = `Knowledge graph: ${stats.nodeCount} nodes, ${stats.edgeCount} edges, ${hubNodes.length} hub nodes, ${guides.length} guides available.`;
const summary = `Knowledge graph: ${stats.nodeCount} nodes, ${stats.edgeCount} edges, ${hubNodes.length} hub nodes, ${skills.length} skills available.`;
return {
content: [{ type: 'text', text: summary }],
structuredContent: {
stats,
hubNodes,
guides
skills
}
};
}