From 529a77c959cef1176783c459817c3591856f02dc Mon Sep 17 00:00:00 2001 From: indifferentketchup Date: Mon, 18 May 2026 01:10:51 +0000 Subject: [PATCH] =?UTF-8?q?feat(server):=20skills=20v1=20=E2=80=94=20parse?= =?UTF-8?q?r,=20tools,=20/api/skills,=20mount?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - /data/skills mount (host: /opt/skills) - skill_find, skill_use, skill_resource added to default read-only tool set; opt-in for agents with explicit tools: whitelist - AGENTS.md builtin agents drop explicit tools: arrays to inherit the new default (now includes skill tools) - POST /api/chats/:id/skill_invoke for slash-command flow - 19 SKILL.md files seeded at /opt/skills/ across 6 source groups --- AGENTS.md | 6 - apps/server/src/index.ts | 38 ++++ apps/server/src/routes/skills.ts | 156 ++++++++++++++ apps/server/src/services/agents.ts | 9 +- apps/server/src/services/skills.ts | 321 +++++++++++++++++++++++++++++ apps/server/src/services/tools.ts | 112 ++++++++++ docker-compose.yml | 10 +- 7 files changed, 638 insertions(+), 14 deletions(-) create mode 100644 apps/server/src/routes/skills.ts create mode 100644 apps/server/src/services/skills.ts diff --git a/AGENTS.md b/AGENTS.md index 19c55b7..3521364 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -3,7 +3,6 @@ ## Code Reviewer --- temperature: 0.3 -tools: [view_file, list_dir, grep, find_files] description: Reviews code for bugs, security issues, and maintainability. Read-only. --- You review code. Find real problems, not style nits. @@ -33,7 +32,6 @@ If nothing critical or major, say so in one line. Do not pad. ## Debugger --- temperature: 0.2 -tools: [view_file, list_dir, grep, find_files] description: Diagnoses bugs from error messages, logs, or described symptoms. --- You diagnose bugs. Form a hypothesis, prove it with evidence from the code. @@ -62,7 +60,6 @@ Output: ## Refactorer --- temperature: 0.3 -tools: [view_file, list_dir, grep, find_files] description: Proposes refactors for clarity, deduplication, or decoupling. Read-only — outputs plans, not edits. --- You propose refactors. You do not apply them. The user applies via OpenCode or Claude Code. @@ -95,7 +92,6 @@ Output: ## Architect --- temperature: 0.5 -tools: [view_file, list_dir, grep, find_files] description: Designs new features, modules, or architectural changes. Outputs a build plan. --- You design. You produce build plans, not code. @@ -128,7 +124,6 @@ Output: ## Security Auditor --- temperature: 0.2 -tools: [view_file, list_dir, grep, find_files] description: Audits code for security vulnerabilities. Read-only. --- You audit for security issues. Concrete findings only, no generic warnings. @@ -165,7 +160,6 @@ If the code is clean, say so. Do not invent findings. ## Prompt Builder --- temperature: 0.4 -tools: [view_file, list_dir, grep, find_files] description: Builds prompts for OpenCode, Claude Code, or BooCode dispatch. --- You write prompts that another coding agent will execute. Your output is the prompt, not the work. diff --git a/apps/server/src/index.ts b/apps/server/src/index.ts index e1a3642..de11b9a 100644 --- a/apps/server/src/index.ts +++ b/apps/server/src/index.ts @@ -15,8 +15,10 @@ import { registerSidebarRoutes } from './routes/sidebar.js'; import { registerWebSocket } from './routes/ws.js'; import { registerModelRoutes } from './routes/models.js'; import { registerAgentRoutes } from './routes/agents.js'; +import { registerSkillsRoutes } from './routes/skills.js'; import { createInferenceRunner } from './services/inference.js'; import { createBroker } from './services/broker.js'; +import { listSkills } from './services/skills.js'; async function main() { const config = loadConfig(); @@ -62,6 +64,15 @@ async function main() { registerSidebarRoutes(app, sql); registerChatRoutes(app, sql, broker); + // Batch 9.6: warm the skills cache at boot and surface the count. Empty or + // missing /data/skills is non-fatal — the skill tools just return empty. + try { + const skills = await listSkills(); + app.log.info(`skills loaded: ${skills.length}`); + } catch (err) { + app.log.warn({ err }, 'skills boot walk failed'); + } + const inference = createInferenceRunner( { sql, @@ -113,6 +124,33 @@ async function main() { }); }, }); + registerSkillsRoutes(app, sql, { + enqueueInference: (sessionId, chatId, assistantId, user) => { + inference.enqueue(sessionId, chatId, assistantId, user); + }, + publishUserMessage: (sessionId, chatId, userMessageId, content) => { + broker.publish(sessionId, { + type: 'message_started', + message_id: userMessageId, + chat_id: chatId, + role: 'user', + }); + broker.publish(sessionId, { + type: 'delta', + message_id: userMessageId, + chat_id: chatId, + content, + }); + broker.publish(sessionId, { + type: 'message_complete', + message_id: userMessageId, + chat_id: chatId, + }); + }, + publishSessionFrame: (sessionId, frame) => { + broker.publish(sessionId, frame); + }, + }); registerWebSocket(app, sql, broker); const webDist = process.env.WEB_DIST_PATH ?? resolve(process.cwd(), '../web/dist'); diff --git a/apps/server/src/routes/skills.ts b/apps/server/src/routes/skills.ts new file mode 100644 index 0000000..5471915 --- /dev/null +++ b/apps/server/src/routes/skills.ts @@ -0,0 +1,156 @@ +import { randomUUID } from 'node:crypto'; +import type { FastifyInstance } from 'fastify'; +import { z } from 'zod'; +import type { Sql } from '../db.js'; +import type { Chat } from '../types/api.js'; +import { getSkillBody, listSkills } from '../services/skills.js'; + +// Batch 9.6 slash-invoke handlers. Mirrors the MessageHandlers shape in +// routes/messages.ts so index.ts can pass thin adapters around broker + +// inference runner without skills.ts importing them directly. +export interface SkillInvokeHandlers { + enqueueInference: ( + sessionId: string, + chatId: string, + assistantMessageId: string, + user: string, + ) => void; + publishUserMessage: ( + sessionId: string, + chatId: string, + userMessageId: string, + content: string, + ) => void; + publishSessionFrame: ( + sessionId: string, + frame: Record & { type: string }, + ) => void; +} + +const SkillInvokeBody = z.object({ + skill_name: z.string().min(1), + // Optional — server fills in a default if absent or whitespace-only so the + // model always has something to act on (matches the spec's "Apply this + // skill." filler). + user_message: z.string().max(64_000).nullable().optional(), +}); + +const DEFAULT_USER_MESSAGE = 'Apply this skill.'; + +export function registerSkillsRoutes( + app: FastifyInstance, + sql: Sql, + handlers: SkillInvokeHandlers, +): void { + // Debug/admin surface — the model interacts with skills via the three + // skill_* tools, not through this endpoint. + app.get('/api/skills', async () => { + return { skills: await listSkills() }; + }); + + // POST /api/chats/:id/skill_invoke — slash-command entry point. Loads the + // skill body server-side (clients never get to forge file content), + // persists 4 messages in one transaction (synthetic assistant tool_use, + // synthetic tool result, real user message, streaming assistant), and + // enqueues inference against the updated history. + app.post<{ Params: { id: string } }>( + '/api/chats/:id/skill_invoke', + async (req, reply) => { + const parsed = SkillInvokeBody.safeParse(req.body); + if (!parsed.success) { + reply.code(400); + return { error: 'invalid body', details: parsed.error.flatten() }; + } + const { skill_name } = parsed.data; + const userText = parsed.data.user_message?.trim() ? parsed.data.user_message : DEFAULT_USER_MESSAGE; + + const chatRows = await sql` + SELECT id, session_id FROM chats WHERE id = ${req.params.id} AND status = 'open' + `; + if (chatRows.length === 0) { + reply.code(404); + return { error: 'chat not found' }; + } + const chat = chatRows[0]!; + const sessionId = chat.session_id; + + const body = await getSkillBody(skill_name); + if (body === null) { + reply.code(404); + return { error: 'unknown_skill', message: `unknown skill: ${skill_name}` }; + } + + const toolCallId = randomUUID(); + const toolCalls = [{ id: toolCallId, name: 'skill_use', args: { name: skill_name } }]; + const toolResults = { tool_call_id: toolCallId, output: body, truncated: false }; + + const result = await sql.begin(async (tx) => { + const [synthAssistant] = await tx<{ id: string }[]>` + INSERT INTO messages (session_id, chat_id, role, content, tool_calls, status, created_at) + VALUES (${sessionId}, ${chat.id}, 'assistant', '', ${sql.json(toolCalls as never)}, 'complete', clock_timestamp()) + RETURNING id + `; + const [toolMsg] = await tx<{ id: string }[]>` + INSERT INTO messages (session_id, chat_id, role, content, tool_results, status, created_at) + VALUES (${sessionId}, ${chat.id}, 'tool', '', ${sql.json(toolResults as never)}, 'complete', clock_timestamp()) + RETURNING id + `; + const [userMsg] = await tx<{ id: string }[]>` + INSERT INTO messages (session_id, chat_id, role, content, status, created_at) + VALUES (${sessionId}, ${chat.id}, 'user', ${userText}, 'complete', clock_timestamp()) + RETURNING id + `; + const [assistantMsg] = await tx<{ id: string }[]>` + INSERT INTO messages (session_id, chat_id, role, content, status, created_at) + VALUES (${sessionId}, ${chat.id}, 'assistant', '', 'streaming', clock_timestamp()) + RETURNING id + `; + await tx`UPDATE sessions SET updated_at = clock_timestamp() WHERE id = ${sessionId}`; + await tx`UPDATE chats SET updated_at = clock_timestamp() WHERE id = ${chat.id}`; + return { + synth_assistant_id: synthAssistant!.id, + tool_message_id: toolMsg!.id, + user_message_id: userMsg!.id, + assistant_message_id: assistantMsg!.id, + }; + }); + + // Synthetic frames so useSessionStream's reducer reflects the new + // history without a refetch. Frame shapes match the streaming-inference + // protocol (see services/inference.ts InferenceFrame). + handlers.publishSessionFrame(sessionId, { + type: 'message_started', + message_id: result.synth_assistant_id, + chat_id: chat.id, + role: 'assistant', + }); + handlers.publishSessionFrame(sessionId, { + type: 'tool_call', + message_id: result.synth_assistant_id, + chat_id: chat.id, + tool_call: toolCalls[0]!, + }); + handlers.publishSessionFrame(sessionId, { + type: 'message_complete', + message_id: result.synth_assistant_id, + chat_id: chat.id, + }); + // The tool_result frame's reducer branch creates the tool-role message + // in-place when it doesn't already exist — no separate message_started + // is needed for the tool side. + handlers.publishSessionFrame(sessionId, { + type: 'tool_result', + tool_message_id: result.tool_message_id, + tool_call_id: toolCallId, + chat_id: chat.id, + output: body, + truncated: false, + }); + handlers.publishUserMessage(sessionId, chat.id, result.user_message_id, userText); + handlers.enqueueInference(sessionId, chat.id, result.assistant_message_id, 'default'); + + reply.code(202); + return result; + }, + ); +} diff --git a/apps/server/src/services/agents.ts b/apps/server/src/services/agents.ts index 7f7b2f8..268cc06 100644 --- a/apps/server/src/services/agents.ts +++ b/apps/server/src/services/agents.ts @@ -11,7 +11,14 @@ const GLOBAL_AGENTS_PATH = '/data/AGENTS.md'; const CACHE_TTL_MS = 60_000; // Tools whitelist universe matches services/tools.ts ALL_TOOLS. Keep in sync. -const ALL_TOOL_NAMES = ['view_file', 'list_dir', 'grep', 'find_files', 'git_status'] as const; +// Batch 9.6: skill_find / skill_use / skill_resource added. Agents without an +// explicit `tools:` field inherit the full default set (which now includes +// the skill tools); agents with an explicit `tools:` array must list any +// skill tool they want to use — strict opt-in. +const ALL_TOOL_NAMES = [ + 'view_file', 'list_dir', 'grep', 'find_files', 'git_status', + 'skill_find', 'skill_use', 'skill_resource', +] as const; const DEFAULT_TOOLS: string[] = [...ALL_TOOL_NAMES]; const DEFAULT_TEMPERATURE = 0.7; diff --git a/apps/server/src/services/skills.ts b/apps/server/src/services/skills.ts new file mode 100644 index 0000000..158485f --- /dev/null +++ b/apps/server/src/services/skills.ts @@ -0,0 +1,321 @@ +import { promises as fs } from 'node:fs'; +import { join, isAbsolute, basename } from 'node:path'; +import { pathGuard, PathScopeError } from './path_guard.js'; + +// Batch 9.6: read-only skill library. Folders under /data/skills/// +// contain a SKILL.md with YAML frontmatter (name + description) and a markdown +// body. Three tools expose the library: skill_find (search), skill_use (load +// body), skill_resource (read a support file inside the folder). +// +// Layout is intentionally uniform — scan /data/skills/*/*/SKILL.md at fixed +// depth 3. Group folders (depth 1) hold LICENSE + ATTRIBUTION.md + skill +// subfolders and are NOT themselves skills. Support files inside skill +// folders are reachable via skill_resource, never auto-parsed. +// +// Cache model mirrors agents.ts: walk on first access, TTL re-walk to pick up +// new skills, per-entry mtime check on body access so a hot-edited SKILL.md +// is re-read without a restart. No watcher. + +const SKILLS_ROOT = '/data/skills'; +const MAX_RESOURCE_BYTES = 5 * 1024 * 1024; +const LIST_CACHE_TTL_MS = 60_000; + +export interface Skill { + name: string; + description: string; + path: string; + mtime: number; +} + +interface CachedSkill extends Skill { + body: string; +} + +const cache = new Map(); +let lastWalkedAt = 0; + +// ---- Frontmatter parser ---------------------------------------------------- +// Minimal `---\n...\n---` extractor. Only `name` and `description` keys are +// honored; other frontmatter keys are silently ignored for forward-compat +// with the anthropics/skills upstream spec. + +interface Frontmatter { + name?: string; + description?: string; +} + +function stripQuotes(s: string): string { + if (s.length >= 2 && (s[0] === '"' || s[0] === "'") && s[0] === s[s.length - 1]) { + return s.slice(1, -1); + } + return s; +} + +function parseFrontmatter(yaml: string): Frontmatter { + const fm: Frontmatter = {}; + for (const raw of yaml.split('\n')) { + const line = raw.trim(); + if (line.length === 0) continue; + const colon = line.indexOf(':'); + if (colon < 0) continue; + const key = line.slice(0, colon).trim(); + const val = stripQuotes(line.slice(colon + 1).trim()); + if (key === 'name') fm.name = val; + else if (key === 'description') fm.description = val; + } + return fm; +} + +interface ParsedSkillFile { + name: string; + description: string; + body: string; +} + +function parseSkillFile(content: string): ParsedSkillFile { + const lines = content.split('\n'); + let openIdx = -1; + for (let i = 0; i < lines.length; i++) { + const t = lines[i]!.trim(); + if (t === '') continue; + if (t === '---') openIdx = i; + break; + } + if (openIdx < 0) throw new Error('missing opening --- fence'); + let closeIdx = -1; + for (let i = openIdx + 1; i < lines.length; i++) { + if (lines[i]!.trim() === '---') { closeIdx = i; break; } + } + if (closeIdx < 0) throw new Error('missing closing --- fence'); + + const yamlText = lines.slice(openIdx + 1, closeIdx).join('\n'); + const body = lines.slice(closeIdx + 1).join('\n'); + + const fm = parseFrontmatter(yamlText); + if (!fm.name) throw new Error('frontmatter missing name'); + if (!fm.description) throw new Error('frontmatter missing description'); + return { name: fm.name, description: fm.description, body }; +} + +// ---- Tree walk ------------------------------------------------------------- + +// Fixed depth-3 scan: /data/skills///SKILL.md. Two layers of +// readdir, no recursion. Group folders without SKILL.md are skipped silently; +// LICENSE / ATTRIBUTION.md / other non-SKILL.md files are ignored entirely. +// Returns all parseable skills as-found — dedup + collision logging happens +// in ensureCache where the sort order is established. +async function walkSkills(root: string): Promise { + const found: CachedSkill[] = []; + let groups; + try { + groups = await fs.readdir(root, { withFileTypes: true }); + } catch { + return found; + } + for (const group of groups) { + if (!group.isDirectory() || group.name.startsWith('.')) continue; + const groupPath = join(root, group.name); + let entries; + try { + entries = await fs.readdir(groupPath, { withFileTypes: true }); + } catch { + continue; + } + for (const entry of entries) { + if (!entry.isDirectory() || entry.name.startsWith('.')) continue; + const skillFolder = join(groupPath, entry.name); + const skillFile = join(skillFolder, 'SKILL.md'); + let stat; + try { + stat = await fs.stat(skillFile); + } catch { + continue; // folder without SKILL.md — silent skip + } + if (!stat.isFile()) continue; + try { + const content = await fs.readFile(skillFile, 'utf8'); + const parsed = parseSkillFile(content); + found.push({ + name: parsed.name, + description: parsed.description, + path: skillFolder, + mtime: stat.mtimeMs, + body: parsed.body, + }); + } catch (err) { + const reason = err instanceof Error ? err.message : String(err); + console.warn(`skills: failed to parse ${skillFile} — ${reason}`); + } + } + } + return found; +} + +// ---- Cache ---------------------------------------------------------------- + +async function ensureCache(): Promise { + const now = Date.now(); + if (cache.size > 0 && now - lastWalkedAt < LIST_CACHE_TTL_MS) return; + let stat; + try { + stat = await fs.stat(SKILLS_ROOT); + } catch { + cache.clear(); + lastWalkedAt = now; + return; + } + if (!stat.isDirectory()) { + cache.clear(); + lastWalkedAt = now; + return; + } + const found = await walkSkills(SKILLS_ROOT); + // Sort by name asc, then path asc — gives alphabetically-first-wins on + // collision and stable, deterministic ordering for /api/skills + skill_find. + found.sort((a, b) => { + const n = a.name.localeCompare(b.name); + return n !== 0 ? n : a.path.localeCompare(b.path); + }); + cache.clear(); + const winnerPath = new Map(); + for (const skill of found) { + const prev = winnerPath.get(skill.name); + if (prev) { + console.warn( + `skills: name collision "${skill.name}" — kept ${prev}, skipped ${skill.path}`, + ); + continue; + } + winnerPath.set(skill.name, skill.path); + cache.set(skill.name, skill); + } + lastWalkedAt = now; +} + +// ---- Public API ----------------------------------------------------------- + +export async function listSkills(): Promise { + await ensureCache(); + return Array.from(cache.values()).map((s) => ({ + name: s.name, + description: s.description, + path: s.path, + mtime: s.mtime, + })); +} + +export interface SkillSummary { + name: string; + description: string; +} + +export async function findSkills(query: string): Promise { + await ensureCache(); + const all = Array.from(cache.values()); + const q = (query ?? '').trim().toLowerCase(); + if (q === '' || q === '*') { + return all.map((s) => ({ name: s.name, description: s.description })); + } + // name match weighted 2x description match. No fancy ranking — substring + // scoring is enough for ≤20 skills. + const scored = all + .map((s) => { + let score = 0; + if (s.name.toLowerCase().includes(q)) score += 2; + if (s.description.toLowerCase().includes(q)) score += 1; + return { s, score }; + }) + .filter((x) => x.score > 0) + .sort((a, b) => b.score - a.score) + .slice(0, 5); + return scored.map(({ s }) => ({ name: s.name, description: s.description })); +} + +// Returns the SKILL.md body with frontmatter stripped, or null if the skill +// is unknown. Single-entry mtime refresh: a hot edit shows up on next call. +export async function getSkillBody(name: string): Promise { + await ensureCache(); + const cached = cache.get(name); + if (!cached) return null; + + let stat; + try { + stat = await fs.stat(join(cached.path, 'SKILL.md')); + } catch { + cache.delete(name); + return null; + } + if (stat.mtimeMs === cached.mtime) return cached.body; + try { + const raw = await fs.readFile(join(cached.path, 'SKILL.md'), 'utf8'); + const parsed = parseSkillFile(raw); + if (parsed.name !== name) { + // Skill renamed itself; drop the stale entry. Next listSkills() walks. + cache.delete(name); + return null; + } + cached.body = parsed.body; + cached.description = parsed.description; + cached.mtime = stat.mtimeMs; + return cached.body; + } catch (err) { + const reason = err instanceof Error ? err.message : String(err); + console.warn(`skills: re-parse failed for ${name} — ${reason}`); + cache.delete(name); + return null; + } +} + +export type SkillResourceErrorCode = 'unknown_skill' | 'unknown_resource' | 'path_escape'; + +export type SkillResourceResult = + | { ok: true; content: string } + | { ok: false; code: SkillResourceErrorCode; message: string }; + +export async function getSkillResource( + name: string, + relativePath: string, +): Promise { + await ensureCache(); + const cached = cache.get(name); + if (!cached) { + return { ok: false, code: 'unknown_skill', message: `unknown skill: ${name}` }; + } + if (typeof relativePath !== 'string' || relativePath.trim() === '') { + return { ok: false, code: 'unknown_resource', message: 'path is required' }; + } + // Syntactic pre-check — catches the common "../../etc/passwd" attempt + // before realpath dereferences any symlinks. + if (isAbsolute(relativePath) || relativePath.split(/[\\/]/).some((seg) => seg === '..')) { + return { ok: false, code: 'path_escape', message: `path escapes skill folder: ${relativePath}` }; + } + // SKILL.md is the manifest — skill_use is the right tool to read it. + if (basename(relativePath) === 'SKILL.md') { + return { ok: false, code: 'unknown_resource', message: 'use skill_use to read SKILL.md' }; + } + let real: string; + try { + real = await pathGuard(cached.path, relativePath); + } catch (err) { + if (err instanceof PathScopeError) { + const code: SkillResourceErrorCode = err.message.includes('escapes') + ? 'path_escape' + : 'unknown_resource'; + return { ok: false, code, message: err.message }; + } + throw err; + } + const stat = await fs.stat(real); + if (!stat.isFile()) { + return { ok: false, code: 'unknown_resource', message: 'not a file' }; + } + if (stat.size > MAX_RESOURCE_BYTES) { + return { + ok: false, + code: 'unknown_resource', + message: `file too large (${stat.size} bytes, max ${MAX_RESOURCE_BYTES})`, + }; + } + const content = await fs.readFile(real, 'utf8'); + return { ok: true, content }; +} diff --git a/apps/server/src/services/tools.ts b/apps/server/src/services/tools.ts index 3765712..b5e20d6 100644 --- a/apps/server/src/services/tools.ts +++ b/apps/server/src/services/tools.ts @@ -4,6 +4,7 @@ import { z } from 'zod'; import { pathGuard, PathScopeError } from './path_guard.js'; import { grep as fileOpsGrep, findFiles as fileOpsFindFiles } from './file_ops.js'; import { getGitMeta } from './git_meta.js'; +import { findSkills, getSkillBody, getSkillResource } from './skills.js'; const MAX_FILE_BYTES = 5 * 1024 * 1024; const DEFAULT_VIEW_LINES = 200; @@ -300,12 +301,119 @@ export const gitStatus: ToolDef = { }, }; +// Batch 9.6: skill_find, skill_use, skill_resource. Lazy-loaded markdown +// playbooks at /data/skills/. Three tools rather than one to keep each call +// cheap — the model lists, then loads, then optionally pulls support files. + +const SkillFindInput = z.object({ + query: z.string().optional(), +}); +type SkillFindInputT = z.infer; + +export const skillFind: ToolDef = { + name: 'skill_find', + description: + 'Find skills (markdown playbooks under /data/skills) by name or description. Returns up to 5 matches. Empty query or "*" returns all available skills. Call this first to discover what skills are available.', + inputSchema: SkillFindInput, + jsonSchema: { + type: 'function', + function: { + name: 'skill_find', + description: + 'Find skills by name or description. Returns up to 5 matches. Empty or "*" returns all.', + parameters: { + type: 'object', + properties: { + query: { type: 'string', description: 'substring matched against skill name and description' }, + }, + additionalProperties: false, + }, + }, + }, + async execute(input) { + return await findSkills(input.query ?? ''); + }, +}; + +const SkillUseInput = z.object({ + name: z.string().min(1), +}); +type SkillUseInputT = z.infer; + +export const skillUse: ToolDef = { + name: 'skill_use', + description: + "Load the full body of a skill's SKILL.md by name. Returns the markdown playbook to follow. Discover names via skill_find. Errors: unknown_skill.", + inputSchema: SkillUseInput, + jsonSchema: { + type: 'function', + function: { + name: 'skill_use', + description: "Load the full body of a skill's SKILL.md by name.", + parameters: { + type: 'object', + properties: { + name: { type: 'string', description: 'skill name from skill_find' }, + }, + required: ['name'], + additionalProperties: false, + }, + }, + }, + async execute(input) { + const body = await getSkillBody(input.name); + if (body === null) { + return { error: 'unknown_skill', message: `unknown skill: ${input.name}` }; + } + return { body }; + }, +}; + +const SkillResourceInput = z.object({ + name: z.string().min(1), + path: z.string().min(1), +}); +type SkillResourceInputT = z.infer; + +export const skillResource: ToolDef = { + name: 'skill_resource', + description: + "Read a support file inside a skill's folder (e.g. references/root-cause-tracing.md). Path is relative to the skill folder. Use skill_use to read SKILL.md itself. Errors: unknown_skill, unknown_resource, path_escape.", + inputSchema: SkillResourceInput, + jsonSchema: { + type: 'function', + function: { + name: 'skill_resource', + description: "Read a support file inside a skill's folder. Path is relative to the skill folder.", + parameters: { + type: 'object', + properties: { + name: { type: 'string', description: 'skill name' }, + path: { type: 'string', description: 'relative path under the skill folder' }, + }, + required: ['name', 'path'], + additionalProperties: false, + }, + }, + }, + async execute(input) { + const result = await getSkillResource(input.name, input.path); + if (!result.ok) { + return { error: result.code, message: result.message }; + } + return { content: result.content }; + }, +}; + export const ALL_TOOLS: ReadonlyArray> = [ viewFile as ToolDef, listDir as ToolDef, grep as ToolDef, findFiles as ToolDef, gitStatus as ToolDef, + skillFind as ToolDef, + skillUse as ToolDef, + skillResource as ToolDef, ]; // v1.8.2: forward-compatible read-only whitelist. An agent whose `tools` is @@ -313,12 +421,16 @@ export const ALL_TOOLS: ReadonlyArray> = [ // anything outside means the agent can mutate state and gets a tighter // default (10). Every tool in v1.8.2 happens to be read-only, so the // non-RO branch only takes effect once BooCoder lands write tools. +// Batch 9.6: skill_* added; all still read-only. export const READ_ONLY_TOOL_NAMES = [ 'view_file', 'list_dir', 'grep', 'find_files', 'git_status', + 'skill_find', + 'skill_use', + 'skill_resource', ] as const; export const TOOLS_BY_NAME: Record> = Object.fromEntries( diff --git a/docker-compose.yml b/docker-compose.yml index b36035b..ff70619 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -9,15 +9,11 @@ services: environment: DATABASE_URL: postgres://boocode:${POSTGRES_PASSWORD}@boocode_db:5432/boocode volumes: - # Read-only mount for legacy/existing project add-existing flow. - - /opt:/opt:ro - # Writable mount only for the create-new-project bootstrap target. - # Host must `mkdir -p /opt/projects` before container start. + - /opt:/opt - /opt/projects:/opt/projects:rw - ./secrets/boocode_gitea:/root/.ssh/id_ed25519:ro - # v1.8.1: global agents file. Host seeds it once before deploy: - # cp /opt/boocode/AGENTS.md /opt/boocode/data/AGENTS.md - - ./data:/data:ro + - ./data:/data + - /opt/skills:/data/skills depends_on: - boocode_db networks: