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
149 lines
6.6 KiB
TypeScript
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')));
|
|
}
|