Files
platform/src/servers/skills/load.ts
T
pastilhasandClaude Opus 5 f0801808c4 read the documentation: four things wrong, none of them the model
an opencode agent could not look at a calendar all afternoon. i treated it as a
framing problem, then a regression, then a free-vs-paid model problem, and it was
none of those. the documentation says all four, plainly.

1. A SKILL'S `name` IS AN IDENTIFIER, not a label. it must match
   ^[a-z0-9]+(-[a-z0-9]+)*$ and equal its directory. every skill here carried a
   human title there — "Calendar and contacts" — so every one was LISTED and none
   could be LOADED. the agent found the skill, called
   skill({name:"calendar-and-contacts"}), and got "Unable to load skill". `title`
   is now a separate field and keeps the prose.

2. THERE IS NO `skills.paths` CONFIG KEY. it is in the binary's own config
   example and not in the docs, and the difference is exactly this: skills
   configured that way are offered to the agent and cannot be opened. skills load
   from fixed discovery paths only, so each directory is now SYMLINKED into
   ~/.config/opencode/skills. links, not copies — one source of truth, and a skill
   edited in its plugin is live immediately.

3. `prompt` IS A FILE REFERENCE — "{file:./path}". passing the text is parsed
   without complaint and never applied. this is what made it look like a model
   problem: the agent existed, the prompt was on its record, and the model had
   never seen a word of it.

4. THE `skill` PERMISSION DEFAULTS TO ASKING, and a server session has nobody to
   ask, so the call errors rather than waiting. {"skill":{"*":"allow"}}.

also: the platform cached opencode's model list for the lifetime of `officer`,
so signing into a provider and restarting the SIDECAR left the old catalogue in
the OTHER process — six free models, no paid ones, nothing saying why. the cache
is dropped when the sidecar reports a new port, which is the one event that
always accompanies a new serve.

verified: the prompt reaches the model — asked who it is, it answered "Officer,
running on your home server at officer-dev under your user account". 10 skills
discovered through the linked path with loadable names. NOT verified end to end:
a turn that opens the calendar skill and answers from it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Officer-Session: 656f5c85-e49a-4f1f-b9f3-042a04b4d043
2026-08-27 13:11:19 +00:00

149 lines
6.6 KiB
TypeScript

import { readdirSync, readFileSync, existsSync } from 'node:fs';
import { join } from 'node:path';
import { OFFICER_ITEMS_DIR } from '../data-path';
import { capabilityRoots } from '../capability-sources';
// Skills: reference the agent reads WHEN IT NEEDS IT, not on every turn.
//
// ── The inverse of an affordance, on purpose ──
//
// affordance body always compiled in, `description` never sent
// skill `description` always compiled in, body read on demand
//
// An affordance is a fact about the surface, it is short, and every conversation needs it. A skill is
// reference material — a database schema, a tool's conventions — that is dead weight in a conversation
// about something else. So what gets compiled into the prompt is the INDEX: name, one line of when to
// reach for it, and the path. The body costs nothing until the agent decides it is worth opening.
//
// ── Why a path and not a protocol ──
//
// Progressive disclosure is normally a harness feature: Claude Code discovers `.claude/skills/`, opencode
// has its own conventions, the next one will have a third. Pointing at a file needs none of that — every
// harness worth running can already read a file, and the index is just text in the prompt.
//
// The cost is that the agent has to CHOOSE to read it, where a tool definition would be pushed into its
// face. That is what the description is for, and it is why a vague one makes a skill useless rather than
// merely verbose.
const YAML = (Bun as unknown as { YAML: { parse(input: string): unknown } }).YAML;
export const SKILLS_DIR = join(OFFICER_ITEMS_DIR, 'skills');
export type Skill = {
/** Directory name — `email-db`. Unique within the store. */
id: string;
/**
* The identifier, and it must equal the directory name.
*
* Not decoration and not a label. A harness with native skill support passes this string back to load
* the skill (`skill({ name: "email-db" })`), and both OpenCode and Claude Code require it to match
* `^[a-z0-9]+(-[a-z0-9]+)*$` and the directory it sits in. Every skill here carried a human title in
* this field until 2026-08-27 — "Calendar and contacts" — so every one was LISTED and none could be
* LOADED. An agent saw the skill, could not open it, and answered as though it did not exist.
*/
name: string;
/** What a person reads. Free text, and purely cosmetic — `name` is the part that is not. */
title: string;
/** WHEN to reach for this. The only thing the agent sees before deciding to open the body. */
description: string;
/** Absolute path to SKILL.md. The agent cannot guess it, so the platform supplies it. */
path: string;
};
function frontmatter(raw: string): Record<string, unknown> {
const match = raw.match(/^---\n([\s\S]*?)\n---/);
if (!match) return {};
try {
return (YAML.parse(match[1]!) as Record<string, unknown>) ?? {};
} catch {
return {};
}
}
/**
* Every skill in the store, sorted so the compiled block is stable.
*
* A skill with no `description` is SKIPPED rather than listed with a placeholder. The description is the
* entire mechanism — a line that does not say when to reach for something is worse than an absent entry,
* because it costs context in every conversation and earns nothing.
*
* Synchronous, like `loadAffordances`, and for the same reason: this runs once per session.
*/
export function loadSkills(root: string = SKILLS_DIR): Skill[] {
let entries: string[];
try {
entries = readdirSync(root, { withFileTypes: true })
.filter((e) => e.isDirectory())
.map((e) => e.name);
} catch {
return []; // no directory yet is the ordinary state of a fresh install
}
const found = entries.sort().map((dir): Skill | null => {
const path = join(root, dir, 'SKILL.md');
if (!existsSync(path)) return null;
try {
const fm = frontmatter(readFileSync(path, 'utf8'));
const description = fm.description == null ? '' : String(fm.description).trim();
if (!description) return null;
// `name` falls back to the directory, which is what it must equal anyway.
const name = fm.name == null ? dir : String(fm.name);
return { id: dir, name, title: fm.title == null ? name : String(fm.title), description, path };
} catch {
return null;
}
});
return found.filter((s): s is Skill => s !== null);
}
/**
* The index, for the system prompt. Null when there is nothing to offer.
*
* Says READ THIS FILE explicitly. An agent that is merely told a skill exists tends to answer from
* memory about a schema it has never seen, which is worse than not knowing — the whole point is to stop
* it guessing at a database it could simply look at.
*/
export function compileSkills(skills: Skill[]): string | null {
if (!skills.length) return null;
return [
'# Skills on this machine',
'',
'Reference written for this machine, kept out of your context until you need it. When one of these',
'covers what you are about to do, READ THE FILE first — it will be more current than anything you',
'remember, and it exists so you do not have to rediscover what is already written down.',
'',
...skills.map((s) => `- **${s.title}** (\`${s.name}\`) — ${s.description}\n Read: \`${s.path}\``),
].join('\n');
}
/** Read and compile in one step, for callers that only want the string. */
/**
* Every skill on offer: the owner's own, plus each installed and enabled plugin's own directory.
*
* A plugin's skills are read WHERE THEY LIVE rather than copied into `capabilities/` — see
* `capability-sources.ts` for why, and `docs/plugin-capabilities.md` for the whole argument.
*
* **The owner's own win a name collision**, because `capabilityRoots` puts their directory first and this
* keeps the first of each id. That is the right precedence: a skill somebody wrote deliberately should
* not be displaced by a plugin that happened to pick the same directory name, and a plugin cannot
* silently take over the meaning of a name already in use.
*/
export function loadSkillsAcross(roots: string[]): Skill[] {
const byId = new Map<string, Skill>();
for (const root of roots) {
for (const skill of loadSkills(root)) if (!byId.has(skill.id)) byId.set(skill.id, skill);
}
return [...byId.values()].sort((a, b) => a.id.localeCompare(b.id));
}
/**
* Read and compile in one step, for callers that only want the string.
*
* Async because resolving which plugins are enabled reads the database. Called once per session, which is
* what makes that affordable — see the note in `capability-sources.ts`.
*/
export async function skillsBlock(): Promise<string | null> {
return compileSkills(loadSkillsAcross(await capabilityRoots('skills')));
}