feat(server): skills v1 — parser, tools, /api/skills, mount

- /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
This commit is contained in:
2026-05-18 01:10:51 +00:00
parent 9a7b35b677
commit 529a77c959
7 changed files with 638 additions and 14 deletions

View File

@@ -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;

View File

@@ -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/<group>/<skill>/
// 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<string, CachedSkill>();
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/<group>/<skill>/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<CachedSkill[]> {
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<void> {
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<string, string>();
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<Skill[]> {
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<SkillSummary[]> {
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<string | null> {
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<SkillResourceResult> {
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 };
}

View File

@@ -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<GitStatusInputT> = {
},
};
// 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<typeof SkillFindInput>;
export const skillFind: ToolDef<SkillFindInputT> = {
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<typeof SkillUseInput>;
export const skillUse: ToolDef<SkillUseInputT> = {
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<typeof SkillResourceInput>;
export const skillResource: ToolDef<SkillResourceInputT> = {
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<ToolDef<unknown>> = [
viewFile as ToolDef<unknown>,
listDir as ToolDef<unknown>,
grep as ToolDef<unknown>,
findFiles as ToolDef<unknown>,
gitStatus as ToolDef<unknown>,
skillFind as ToolDef<unknown>,
skillUse as ToolDef<unknown>,
skillResource as ToolDef<unknown>,
];
// v1.8.2: forward-compatible read-only whitelist. An agent whose `tools` is
@@ -313,12 +421,16 @@ export const ALL_TOOLS: ReadonlyArray<ToolDef<unknown>> = [
// 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<string, ToolDef<unknown>> = Object.fromEntries(