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
-50
View File
@@ -1,50 +0,0 @@
import { NextRequest, NextResponse } from 'next/server';
import { readGuide, deleteGuide } from '@/services/guides/guideService';
export const runtime = 'nodejs';
export async function GET(
_request: NextRequest,
{ params }: { params: Promise<{ name: string }> }
) {
try {
const { name } = await params;
const guide = readGuide(name);
if (!guide) {
return NextResponse.json(
{ success: false, error: `Guide "${name}" not found` },
{ status: 404 }
);
}
return NextResponse.json({ success: true, data: guide });
} catch (error) {
console.error('[API /guides/[name]] error:', error);
return NextResponse.json(
{ success: false, error: error instanceof Error ? error.message : 'Failed to read guide' },
{ status: 500 }
);
}
}
export async function DELETE(
_request: NextRequest,
{ params }: { params: Promise<{ name: string }> }
) {
try {
const { name } = await params;
const result = deleteGuide(name);
if (!result.success) {
return NextResponse.json(
{ success: false, error: result.error },
{ status: 400 }
);
}
return NextResponse.json({ success: true, message: `Guide "${name}" deleted` });
} catch (error) {
console.error('[API /guides/[name] DELETE] error:', error);
return NextResponse.json(
{ success: false, error: error instanceof Error ? error.message : 'Failed to delete guide' },
{ status: 500 }
);
}
}
-17
View File
@@ -1,17 +0,0 @@
import { NextResponse } from 'next/server';
import { listGuides } from '@/services/guides/guideService';
export const runtime = 'nodejs';
export async function GET() {
try {
const guides = listGuides();
return NextResponse.json({ success: true, data: guides });
} catch (error) {
console.error('[API /guides] error:', error);
return NextResponse.json(
{ success: false, error: error instanceof Error ? error.message : 'Failed to list guides' },
{ status: 500 }
);
}
}
+107 -11
View File
@@ -13,11 +13,15 @@ const SERVER_INFO = {
const instructions = [
'RA-H is a personal knowledge graph — local-first, vendor-neutral.',
'Core concepts: nodes (knowledge units) and edges (connections with explanations).',
'The graph is the default working memory for substantive turns.',
'Core concepts: nodes (knowledge units), edges (connections with explanations), and shared editable skills.',
'If the user is trying to find a specific existing node, use rah_search_nodes first.',
'If the user is asking a broader question or request that would benefit from graph context, use rah_retrieve_query_context.',
'If graph context would help with a broader task, use rah_retrieve_query_context before answering.',
'Use rah_get_context only for orientation when high-level graph state would actually help.',
'Search before creating: use rah_search_nodes to check if content already exists.',
'Do not keep re-running retrieval if you already have enough relevant graph context in play.',
'Search before creating, and prefer rah_update_node when the artifact is clearly the same thing.',
'Use rah_list_skills and rah_read_skill for non-trivial workflows that need operating doctrine. Use rah_write_skill and rah_delete_skill when the user explicitly wants to change that shared skill set.',
'Only suggest saving durable knowledge when it is unusually valuable. Keep the ask brief, for example: Add "X" as a node?',
'Every edge needs an explanation: why does this connection exist?',
'Never create or update an edge unless the user has explicitly confirmed the relationship.',
].join(' ');
@@ -182,9 +186,10 @@ function createServer(request: NextRequest): McpServer {
link: z.string().optional(),
metadata: z.record(z.any()).optional(),
}),
source_update_basis: z.string().optional(),
},
},
async ({ id, updates }) => {
async ({ id, updates, source_update_basis }) => {
if (!updates || Object.keys(updates).length === 0) {
throw new Error('At least one field must be provided in updates.');
}
@@ -201,7 +206,7 @@ function createServer(request: NextRequest): McpServer {
const payload = await callRaHApi(request, `/api/nodes/${id}`, {
method: 'PUT',
body: JSON.stringify(mappedUpdates),
body: JSON.stringify({ ...mappedUpdates, source_update_basis }),
});
return {
@@ -364,6 +369,97 @@ function createServer(request: NextRequest): McpServer {
}
);
server.registerTool(
'rah_list_skills',
{
title: 'List RA-H skills',
description: 'List the shared skills available to internal and external RA-H agents. Use this to see the current operating doctrine before reading or editing a specific skill.',
inputSchema: {},
},
async () => {
const result = await callRaHApi(request, '/api/skills', { method: 'GET' });
const skills = Array.isArray(result.data) ? result.data : [];
return {
content: [{ type: 'text', text: `Found ${skills.length} skill(s).` }],
structuredContent: {
count: skills.length,
skills,
},
};
}
);
server.registerTool(
'rah_read_skill',
{
title: 'Read RA-H skill',
description: 'Read one shared RA-H skill by name. Use this before executing a non-trivial workflow that matches the skill trigger.',
inputSchema: {
name: z.string().min(1),
},
},
async ({ name }) => {
const result = await callRaHApi(request, `/api/skills/${encodeURIComponent(name)}`, { method: 'GET' });
return {
content: [{ type: 'text', text: result.data.content }],
structuredContent: result.data,
};
}
);
server.registerTool(
'rah_write_skill',
{
title: 'Write RA-H skill',
description: 'Create or update a shared RA-H skill when the user explicitly wants to change the doctrine surface. Content should be the full markdown body for that skill.',
inputSchema: {
name: z.string().min(1),
content: z.string().min(1),
},
},
async ({ name, content }) => {
const result = await callRaHApi(request, '/api/skills', {
method: 'POST',
body: JSON.stringify({ name, content }),
});
return {
content: [{ type: 'text', text: `Skill "${name}" saved.` }],
structuredContent: {
success: true,
name,
message: result.message || `Skill "${name}" saved.`,
},
};
}
);
server.registerTool(
'rah_delete_skill',
{
title: 'Delete RA-H skill',
description: 'Delete a shared RA-H skill when the user explicitly wants it removed from the shared skill set.',
inputSchema: {
name: z.string().min(1),
},
},
async ({ name }) => {
const result = await callRaHApi(request, `/api/skills/${encodeURIComponent(name)}`, {
method: 'DELETE',
});
return {
content: [{ type: 'text', text: `Skill "${name}" deleted.` }],
structuredContent: {
success: true,
name,
message: result.message || `Skill "${name}" deleted.`,
},
};
}
);
server.registerTool(
'rah_search_embeddings',
{
@@ -483,13 +579,13 @@ function createServer(request: NextRequest): McpServer {
'rah_get_context',
{
title: 'Get RA-H context',
description: 'Get orientation context: high-level graph state, hub nodes, stats, and available guides.',
description: 'Get orientation context: high-level graph state, hub nodes, stats, and available skills. Use this for orientation only, not as the default retrieval path for substantive requests.',
inputSchema: {},
},
async () => {
const [hubPayload, guidesPayload, countPayload, edgesPayload] = await Promise.all([
const [hubPayload, skillsPayload, countPayload, edgesPayload] = await Promise.all([
callRaHApi(request, '/api/nodes?sortBy=edges&limit=10'),
callRaHApi(request, '/api/guides').catch(() => ({ data: [] })),
callRaHApi(request, '/api/skills').catch(() => ({ data: [] })),
callRaHApi(request, '/api/nodes?limit=1').catch(() => ({ total: 0, count: 0 })),
callRaHApi(request, '/api/edges?limit=1').catch(() => ({ count: 0, total: 0 })),
]);
@@ -501,19 +597,19 @@ function createServer(request: NextRequest): McpServer {
edgeCount: node.edge_count ?? 0,
})) : [];
const guides = Array.isArray(guidesPayload.data) ? guidesPayload.data.map((guide: any) => guide.name) : [];
const skills = Array.isArray(skillsPayload.data) ? skillsPayload.data : [];
const nodeCount = countPayload.total ?? countPayload.count ?? 0;
const edgeCount = edgesPayload.total ?? edgesPayload.count ?? 0;
return {
content: [{ type: 'text', text: `Knowledge graph: ${nodeCount} nodes, ${edgeCount} edges, ${guides.length} guides available.` }],
content: [{ type: 'text', text: `Knowledge graph: ${nodeCount} nodes, ${edgeCount} edges, ${skills.length} skills available.` }],
structuredContent: {
stats: {
nodeCount,
edgeCount,
},
hubNodes,
guides,
skills,
},
};
}
+1 -48
View File
@@ -1,5 +1,5 @@
import { NextRequest, NextResponse } from 'next/server';
import { readSkill, writeSkill, deleteSkill } from '@/services/skills/skillService';
import { readSkill, deleteSkill } from '@/services/skills/skillService';
export const runtime = 'nodejs';
@@ -50,50 +50,3 @@ export async function DELETE(
);
}
}
export async function PUT(
request: NextRequest,
{ params }: { params: Promise<{ name: string }> }
) {
try {
const { name } = await params;
const body = await request.json();
const description = typeof body?.description === 'string' ? body.description.trim() : '';
const content = typeof body?.content === 'string' ? body.content : '';
const safeName = name.trim();
if (!safeName) {
return NextResponse.json(
{ success: false, error: 'Skill name is required' },
{ status: 400 }
);
}
const safeDescription = description.replace(/\r?\n/g, ' ').trim();
const frontmatter = [
'---',
`name: ${safeName}`,
`description: ${safeDescription}`,
'---',
'',
content.trim(),
'',
].join('\n');
const result = writeSkill(safeName, frontmatter);
if (!result.success) {
return NextResponse.json(
{ success: false, error: result.error },
{ status: 400 }
);
}
return NextResponse.json({ success: true, message: `Skill "${safeName}" saved` });
} catch (error) {
console.error('[API /skills/[name] PUT] error:', error);
return NextResponse.json(
{ success: false, error: error instanceof Error ? error.message : 'Failed to save skill' },
{ status: 500 }
);
}
}
+43 -2
View File
@@ -1,5 +1,5 @@
import { NextResponse } from 'next/server';
import { listSkills } from '@/services/skills/skillService';
import { NextRequest, NextResponse } from 'next/server';
import { listSkills, writeSkill } from '@/services/skills/skillService';
export const runtime = 'nodejs';
@@ -15,3 +15,44 @@ export async function GET() {
);
}
}
export async function POST(request: NextRequest) {
try {
const body = await request.json();
const name = typeof body?.name === 'string' ? body.name.trim() : '';
const content = typeof body?.content === 'string' ? body.content : '';
if (!name) {
return NextResponse.json(
{ success: false, error: 'Skill name is required' },
{ status: 400 }
);
}
if (!content.trim()) {
return NextResponse.json(
{ success: false, error: 'Skill content is required' },
{ status: 400 }
);
}
const result = writeSkill(name, content);
if (!result.success) {
return NextResponse.json(
{ success: false, error: result.error || 'Failed to write skill' },
{ status: 400 }
);
}
return NextResponse.json({
success: true,
message: `Skill "${name}" saved`,
});
} catch (error) {
console.error('[API /skills POST] error:', error);
return NextResponse.json(
{ success: false, error: error instanceof Error ? error.message : 'Failed to write skill' },
{ status: 500 }
);
}
}