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
This commit is contained in:
@@ -32,7 +32,18 @@ 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. */
|
||||
@@ -75,7 +86,9 @@ export function loadSkills(root: string = SKILLS_DIR): Skill[] {
|
||||
const fm = frontmatter(readFileSync(path, 'utf8'));
|
||||
const description = fm.description == null ? '' : String(fm.description).trim();
|
||||
if (!description) return null;
|
||||
return { id: dir, name: fm.name == null ? dir : String(fm.name), description, path };
|
||||
// `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;
|
||||
}
|
||||
@@ -100,7 +113,7 @@ export function compileSkills(skills: Skill[]): string | null {
|
||||
'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.name}** — ${s.description}\n Read: \`${s.path}\``),
|
||||
...skills.map((s) => `- **${s.title}** (\`${s.name}\`) — ${s.description}\n Read: \`${s.path}\``),
|
||||
].join('\n');
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user