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:
@@ -1,6 +1,7 @@
|
|||||||
import * as sidecar from '@@/sidecar-registry';
|
import * as sidecar from '@@/sidecar-registry';
|
||||||
import { logger } from '../logger';
|
import { logger } from '../logger';
|
||||||
import { setOpenCodeSession } from './state';
|
import { setOpenCodeSession } from './state';
|
||||||
|
import { invalidateModelCache } from '../list-models';
|
||||||
|
|
||||||
// The OpenCode sidecar (officer-opencode) starts its `opencode serve` on a random port and reports it
|
// The OpenCode sidecar (officer-opencode) starts its `opencode serve` on a random port and reports it
|
||||||
// here on connect. We remember it so the OpenCode harness always routes to the current server. It also
|
// here on connect. We remember it so the OpenCode harness always routes to the current server. It also
|
||||||
@@ -13,6 +14,19 @@ sidecar.on('opencode:server', (msg) => {
|
|||||||
const port = (msg as { port?: number }).port;
|
const port = (msg as { port?: number }).port;
|
||||||
if (typeof port !== 'number') return;
|
if (typeof port !== 'number') return;
|
||||||
serverPort = port;
|
serverPort = port;
|
||||||
|
/*
|
||||||
|
A new serve means a possibly different catalogue, so the cached model list has to go.
|
||||||
|
|
||||||
|
`listOpenCodeModels` caches for the platform process's lifetime, which is right — the catalogue moves
|
||||||
|
about once a release. But it is the PLATFORM that holds it and the SIDECAR that changes, so signing
|
||||||
|
into a provider and restarting `officer-opencode` left the platform serving the list it read before
|
||||||
|
the login: six free models, no paid ones, and nothing anywhere saying why. Reported 2026-08-27, after
|
||||||
|
a successful login that appeared to do nothing.
|
||||||
|
|
||||||
|
The port report is the one event that always accompanies a new serve, which makes it the right place
|
||||||
|
to drop the cache.
|
||||||
|
*/
|
||||||
|
invalidateModelCache();
|
||||||
logger.info('OpenCode sidecar server registered', { port, url: getOpenCodeServerUrl() });
|
logger.info('OpenCode sidecar server registered', { port, url: getOpenCodeServerUrl() });
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -1,33 +1,45 @@
|
|||||||
import { describe, expect, test } from 'bun:test';
|
import { describe, expect, test } from 'bun:test';
|
||||||
import { serveConfigContent, affordancesPath } from './serve-env';
|
import { serveConfigContent, affordancesPath, agentFile } from './serve-env';
|
||||||
|
|
||||||
// The config shape is a contract with the OpenCode binary, and the cost of a wrong key name is SILENCE:
|
// The config shape is a contract with the OpenCode binary, and the cost of a wrong key name is SILENCE:
|
||||||
// no error, no warning, just an agent that never sees a skill. So the keys are asserted literally.
|
// no error, no warning, just an agent that never sees a skill. So the keys are asserted literally.
|
||||||
|
|
||||||
describe('serveConfigContent', () => {
|
describe('serveConfigContent', () => {
|
||||||
test('names skills.paths and instructions exactly', () => {
|
test('allows the skill tool, because nothing here can answer a prompt', () => {
|
||||||
const parsed = JSON.parse(serveConfigContent(['/a/skills', '/b/skills'], ['/tmp/aff.md'], 'PROMPT'));
|
const parsed = JSON.parse(serveConfigContent([], '/tmp/p.md'));
|
||||||
expect(parsed.skills).toEqual({ paths: ['/a/skills', '/b/skills'] });
|
expect(parsed.permission).toEqual({ skill: { '*': 'allow' } });
|
||||||
|
});
|
||||||
|
|
||||||
|
test('names instructions and the prompt file exactly', () => {
|
||||||
|
const parsed = JSON.parse(serveConfigContent(['/tmp/aff.md'], '/tmp/p.md'));
|
||||||
expect(parsed.instructions).toEqual(['/tmp/aff.md']);
|
expect(parsed.instructions).toEqual(['/tmp/aff.md']);
|
||||||
// The agent is what a session is created with; a config without it silently falls back to
|
// The agent is what a session is created with; a config without it silently falls back to
|
||||||
// OpenCode's coding-agent framing, which is the bug this whole file exists to stop.
|
// OpenCode's coding-agent framing, which is the bug this whole file exists to stop.
|
||||||
expect(parsed.default_agent).toBe('officer');
|
expect(parsed.default_agent).toBe('officer');
|
||||||
expect(parsed.agent.officer.prompt).toBe('PROMPT');
|
// `{file:}` — the documented form. Inline prompt text is accepted and never applied.
|
||||||
expect(parsed.agent.officer.mode).toBe('primary');
|
expect(parsed.agent.officer.prompt).toBe('{file:/tmp/p.md}');
|
||||||
});
|
});
|
||||||
|
|
||||||
// An empty array is not the same as an absent key: `skills: {paths: []}` is a claim that there are none,
|
// An empty array is not the same as an absent key: merged over a config that sets some, it erases them.
|
||||||
// and merged over a config that sets some it would erase them.
|
test('omits instructions rather than sending it empty', () => {
|
||||||
test('omits a key rather than sending it empty', () => {
|
expect(JSON.parse(serveConfigContent([], '/tmp/p.md')).instructions).toBeUndefined();
|
||||||
expect(JSON.parse(serveConfigContent([], [], 'P')).skills).toBeUndefined();
|
expect(JSON.parse(serveConfigContent(['/tmp/a.md'], '/tmp/p.md')).instructions).toEqual(['/tmp/a.md']);
|
||||||
expect(JSON.parse(serveConfigContent(['/a'], [], 'P')).skills).toEqual({ paths: ['/a'] });
|
|
||||||
});
|
});
|
||||||
|
|
||||||
test('is valid JSON for an env var — no newlines to be mangled by a shell', () => {
|
test('is valid JSON for an env var — no newlines to be mangled by a shell', () => {
|
||||||
expect(serveConfigContent(['/a/skills'], ['/tmp/aff.md'], 'P')).not.toContain('\n');
|
expect(serveConfigContent(['/tmp/aff.md'], '/tmp/p.md')).not.toContain('\n');
|
||||||
});
|
});
|
||||||
|
|
||||||
test('the affordances file sits beside the serve state, not in a home', () => {
|
test('the affordances file sits beside the serve state, not in a home', () => {
|
||||||
expect(affordancesPath('/data/opencode_server')).toBe('/data/opencode_server/officer-affordances.md');
|
expect(affordancesPath('/data/opencode_server')).toBe('/data/opencode_server/officer-affordances.md');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// The body IS the system prompt — the inline `prompt` key is parsed and ignored, which is the whole
|
||||||
|
// reason this file form exists.
|
||||||
|
test('the agent file puts the prompt in the body, after frontmatter', () => {
|
||||||
|
const out = agentFile('YOU ARE OFFICER');
|
||||||
|
expect(out.startsWith('---\n')).toBe(true);
|
||||||
|
expect(out).toContain('mode: primary');
|
||||||
|
expect(out.split('---')[2]?.trim()).toBe('YOU ARE OFFICER');
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { mkdirSync, writeFileSync } from 'node:fs';
|
import { existsSync, mkdirSync, readdirSync, readlinkSync, rmSync, symlinkSync, writeFileSync } from 'node:fs';
|
||||||
import { existsSync } from 'node:fs';
|
import { homedir } from 'node:os';
|
||||||
import { join } from 'node:path';
|
import { join } from 'node:path';
|
||||||
import { getOwnerUser } from 'officerdb';
|
import { getOwnerUser } from 'officerdb';
|
||||||
import { mintSessionCredential } from '../agent-credential';
|
import { mintSessionCredential } from '../agent-credential';
|
||||||
@@ -22,13 +22,24 @@ import { capabilityRoots } from '../../capability-sources';
|
|||||||
* that have nothing to do with this file and everything to do with one serve running as one user. When
|
* that have nothing to do with this file and everything to do with one serve running as one user. When
|
||||||
* that lifts, this has to become per-session or it is a privilege escalation. See `agent-credential.ts`.
|
* that lifts, this has to become per-session or it is a privilege escalation. See `agent-credential.ts`.
|
||||||
*
|
*
|
||||||
* ── Skills: OpenCode already has the mechanism ──
|
* ── Skills: LINKED into a discovery path, not pointed at ──
|
||||||
*
|
*
|
||||||
* It has a `skill` tool, an index compiled into its system prompt, and `skills.paths` in config taking
|
* It has a `skill` tool and the same `<name>/SKILL.md` layout the platform already stores. What it does
|
||||||
* directories of `<name>/SKILL.md`. That is the same progressive disclosure Claude does and the same
|
* NOT have is a way to be told where else to look: `skills.paths` appears in the binary's own config
|
||||||
* layout the platform already stores, so this points OpenCode at the SAME FILES rather than compiling a
|
* example and is not in the documentation, and the difference showed — skills configured that way were
|
||||||
* second index in a second format. `skills/load.ts` says a skill is a path because every harness can read
|
* LISTED to the agent and then failed to load, with `Unable to load skill calendar-and-contacts`. Being
|
||||||
* a file; this is the better version of that bet — the harness had the feature all along.
|
* offered something you cannot open is worse than not being offered it.
|
||||||
|
*
|
||||||
|
* So each skill directory is SYMLINKED into `~/.config/opencode/skills/`, which is a documented
|
||||||
|
* discovery path. Links rather than copies: one source of truth, and a skill edited in its plugin is
|
||||||
|
* live immediately. Only our own links are removed, and only when their target has gone.
|
||||||
|
*
|
||||||
|
* Two more things the documentation settles, both of which cost an afternoon:
|
||||||
|
*
|
||||||
|
* - `name` in a SKILL.md must match `^[a-z0-9]+(-[a-z0-9]+)*$` AND the directory name. Every skill
|
||||||
|
* here carried a human title there, so every one was listed and none could be loaded.
|
||||||
|
* - The `skill` permission defaults to asking, and a server session has nobody to ask, so the request
|
||||||
|
* errors. `{"skill": {"*": "allow"}}` is what makes it usable headless.
|
||||||
*
|
*
|
||||||
* ── Affordances: one composed file, because they are not optional reading ──
|
* ── Affordances: one composed file, because they are not optional reading ──
|
||||||
*
|
*
|
||||||
@@ -50,6 +61,30 @@ import { capabilityRoots } from '../../capability-sources';
|
|||||||
* So Officer defines its own primary agent and makes it the default. `prompt` REPLACES the built-in
|
* So Officer defines its own primary agent and makes it the default. `prompt` REPLACES the built-in
|
||||||
* system text rather than adding to it, which is the point.
|
* system text rather than adding to it, which is the point.
|
||||||
*
|
*
|
||||||
|
* ── The agent's prompt is a FILE, and that is not a preference ──
|
||||||
|
*
|
||||||
|
* The inline form is documented — `agent: { officer: { prompt: "..." } }` — and it is parsed: the agent
|
||||||
|
* shows up in `/agent` with the prompt on it. It is not APPLIED. Asked whether a sentence unique to that
|
||||||
|
* prompt appeared in its instructions, a turn on 2026-08-27 answered no, and no again for the skills
|
||||||
|
* index, while running under `agent: officer`. OpenCode's own documentation says "use the file form for
|
||||||
|
* anything non-trivial"; this is what that sentence is protecting you from.
|
||||||
|
*
|
||||||
|
* So the prompt is written to `~/.config/opencode/agent/officer.md`, which is the form that works. The
|
||||||
|
* note below about not editing the owner's dotfiles still stands for CONFIG — the skills paths and the
|
||||||
|
* default agent stay in the env var, where they demonstrably work and leave nothing behind. This one
|
||||||
|
* file is the exception, it is entirely ours, and it is rewritten from scratch on every boot.
|
||||||
|
*
|
||||||
|
* ── `prompt` is a FILE REFERENCE, not the text ──
|
||||||
|
*
|
||||||
|
* From the documentation: `"prompt": "{file:./prompts/your-prompt.txt}"`. Passing the prompt itself is
|
||||||
|
* parsed without complaint — the agent appears in `/agent` with the text on it — and never applied. A
|
||||||
|
* turn on 2026-08-27, running under `agent: officer`, was asked whether a sentence unique to that prompt
|
||||||
|
* appeared in its instructions and answered no; and no again for the skills index. Hours went into
|
||||||
|
* treating that as a model problem, a framing problem and a paid-tier problem, and it was a `{file:}`.
|
||||||
|
*
|
||||||
|
* So the prompt is written beside the affordances file and referenced. Nothing is written to the owner's
|
||||||
|
* config directory, which is the point of the note below.
|
||||||
|
*
|
||||||
* ── Why `OPENCODE_CONFIG_CONTENT` rather than editing the owner's config ──
|
* ── Why `OPENCODE_CONFIG_CONTENT` rather than editing the owner's config ──
|
||||||
*
|
*
|
||||||
* It is inline JSON, it MERGES with the config files rather than replacing them (verified in the binary:
|
* It is inline JSON, it MERGES with the config files rather than replacing them (verified in the binary:
|
||||||
@@ -62,6 +97,68 @@ import { capabilityRoots } from '../../capability-sources';
|
|||||||
/** Where the composed affordances land. Beside the serve's own state, not in anyone's home. */
|
/** Where the composed affordances land. Beside the serve's own state, not in anyone's home. */
|
||||||
export const affordancesPath = (serveCwd: string) => join(serveCwd, 'officer-affordances.md');
|
export const affordancesPath = (serveCwd: string) => join(serveCwd, 'officer-affordances.md');
|
||||||
|
|
||||||
|
/** A documented discovery path, and the only kind the `skill` tool can actually load from. */
|
||||||
|
export const skillLinkDir = () => join(homedir(), '.config', 'opencode', 'skills');
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Link every skill into OpenCode's own discovery directory.
|
||||||
|
*
|
||||||
|
* Returns how many are linked. Existing links to the same target are left alone; ours that now point at
|
||||||
|
* nothing are removed. A real directory someone put there by hand is never touched — this owns links,
|
||||||
|
* not the folder.
|
||||||
|
*/
|
||||||
|
export function linkSkills(roots: string[]): number {
|
||||||
|
const dir = skillLinkDir();
|
||||||
|
mkdirSync(dir, { recursive: true });
|
||||||
|
|
||||||
|
const wanted = new Map<string, string>();
|
||||||
|
for (const root of roots) {
|
||||||
|
for (const entry of readdirSync(root, { withFileTypes: true })) {
|
||||||
|
if (!entry.isDirectory()) continue;
|
||||||
|
if (!existsSync(join(root, entry.name, 'SKILL.md'))) continue;
|
||||||
|
// First root wins, matching `loadSkillsAcross`: the owner's own skills are not displaced by a
|
||||||
|
// plugin that picked the same directory name.
|
||||||
|
if (!wanted.has(entry.name)) wanted.set(entry.name, join(root, entry.name));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const entry of readdirSync(dir, { withFileTypes: true })) {
|
||||||
|
if (!entry.isSymbolicLink()) continue;
|
||||||
|
const link = join(dir, entry.name);
|
||||||
|
if (wanted.get(entry.name) === readlinkSync(link)) continue;
|
||||||
|
rmSync(link, { force: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const [name, target] of wanted) {
|
||||||
|
const link = join(dir, name);
|
||||||
|
if (existsSync(link)) continue;
|
||||||
|
try {
|
||||||
|
symlinkSync(target, link, 'dir');
|
||||||
|
} catch (err) {
|
||||||
|
console.error(`[opencode] could not link skill ${name}`, err);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return wanted.size;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Where the agent's system prompt is written, for `{file:}` to reference. */
|
||||||
|
export const agentPromptPath = (serveCwd: string) => join(serveCwd, 'officer-agent-prompt.md');
|
||||||
|
|
||||||
|
/** The agent definition OpenCode actually reads. Ours entirely — rewritten from scratch each boot. */
|
||||||
|
export const agentFilePath = () => join(homedir(), '.config', 'opencode', 'agent', `${OFFICER_AGENT}.md`);
|
||||||
|
|
||||||
|
/** Frontmatter, then the body — and the body IS the system prompt. */
|
||||||
|
export const agentFile = (prompt: string) =>
|
||||||
|
[
|
||||||
|
'---',
|
||||||
|
"description: Officer's own assistant: this machine, not just its code.",
|
||||||
|
'mode: primary',
|
||||||
|
'---',
|
||||||
|
'',
|
||||||
|
prompt,
|
||||||
|
'',
|
||||||
|
].join('\n');
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The config OpenCode is handed. Exported for the test: the shape is a contract with the binary, and the
|
* The config OpenCode is handed. Exported for the test: the shape is a contract with the binary, and the
|
||||||
* cost of getting a key name wrong is silence — no error, just an agent that never sees a skill.
|
* cost of getting a key name wrong is silence — no error, just an agent that never sees a skill.
|
||||||
@@ -107,15 +204,18 @@ export function officerAgentPrompt(skills: string | null): string {
|
|||||||
return skills ? `${OFFICER_AGENT_PROMPT}\n\n${skills}` : OFFICER_AGENT_PROMPT;
|
return skills ? `${OFFICER_AGENT_PROMPT}\n\n${skills}` : OFFICER_AGENT_PROMPT;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function serveConfigContent(skillRoots: string[], instructions: string[], agentPrompt: string): string {
|
export function serveConfigContent(instructions: string[], promptFile: string): string {
|
||||||
return JSON.stringify({
|
return JSON.stringify({
|
||||||
...(skillRoots.length ? { skills: { paths: skillRoots } } : {}),
|
// Nothing here may stop to ask a person: there is nobody at this end of the socket, and a `skill`
|
||||||
|
// permission left at its default of "ask" fails the call instead of waiting.
|
||||||
|
permission: { skill: { '*': 'allow' } },
|
||||||
...(instructions.length ? { instructions } : {}),
|
...(instructions.length ? { instructions } : {}),
|
||||||
agent: {
|
agent: {
|
||||||
[OFFICER_AGENT]: {
|
[OFFICER_AGENT]: {
|
||||||
description: "Officer's own assistant: this machine, not just its code.",
|
description: "Officer's own assistant: this machine, not just its code.",
|
||||||
mode: 'primary',
|
mode: 'primary',
|
||||||
prompt: agentPrompt,
|
// `{file:}`, per the documentation. Inline prompt text is parsed and never applied.
|
||||||
|
prompt: `{file:${promptFile}}`,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
default_agent: OFFICER_AGENT,
|
default_agent: OFFICER_AGENT,
|
||||||
@@ -160,10 +260,28 @@ export async function buildServeEnv(serveCwd: string): Promise<Record<string, st
|
|||||||
for (const root of await capabilityRoots('skills')) if (existsSync(root)) skillRoots.push(root);
|
for (const root of await capabilityRoots('skills')) if (existsSync(root)) skillRoots.push(root);
|
||||||
// The same index a Claude session is given, compiled by the same function.
|
// The same index a Claude session is given, compiled by the same function.
|
||||||
skills = await skillsBlock();
|
skills = await skillsBlock();
|
||||||
|
const linked = linkSkills(skillRoots);
|
||||||
|
console.log(`[opencode] linked ${linked} skill(s) into ${skillLinkDir()}`);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('[opencode] could not resolve the skill roots', err);
|
console.error('[opencode] could not resolve the skill roots', err);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const file = agentFilePath();
|
||||||
|
mkdirSync(join(file, '..'), { recursive: true });
|
||||||
|
writeFileSync(file, agentFile(officerAgentPrompt(skills)));
|
||||||
|
console.log(`[opencode] wrote the officer agent to ${file}`);
|
||||||
|
} catch (err) {
|
||||||
|
console.error('[opencode] could not write the agent file — turns will use the built-in agent', err);
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
mkdirSync(serveCwd, { recursive: true });
|
||||||
|
writeFileSync(agentPromptPath(serveCwd), officerAgentPrompt(skills));
|
||||||
|
} catch (err) {
|
||||||
|
console.error('[opencode] could not write the agent prompt — turns will use the built-in agent', err);
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const affordances = await affordanceBlock();
|
const affordances = await affordanceBlock();
|
||||||
if (affordances) {
|
if (affordances) {
|
||||||
@@ -175,7 +293,7 @@ export async function buildServeEnv(serveCwd: string): Promise<Record<string, st
|
|||||||
console.error('[opencode] could not write the affordances file', err);
|
console.error('[opencode] could not write the affordances file', err);
|
||||||
}
|
}
|
||||||
|
|
||||||
env.OPENCODE_CONFIG_CONTENT = serveConfigContent(skillRoots, instructions, officerAgentPrompt(skills));
|
env.OPENCODE_CONFIG_CONTENT = serveConfigContent(instructions, agentPromptPath(serveCwd));
|
||||||
console.log(
|
console.log(
|
||||||
`[opencode] offering ${skillRoots.length} skill root(s), ${instructions.length} instruction file(s), ` +
|
`[opencode] offering ${skillRoots.length} skill root(s), ${instructions.length} instruction file(s), ` +
|
||||||
`${skills ? 'index compiled into the agent prompt' : 'NO skills index'}`,
|
`${skills ? 'index compiled into the agent prompt' : 'NO skills index'}`,
|
||||||
|
|||||||
@@ -32,7 +32,18 @@ export const SKILLS_DIR = join(OFFICER_ITEMS_DIR, 'skills');
|
|||||||
export type Skill = {
|
export type Skill = {
|
||||||
/** Directory name — `email-db`. Unique within the store. */
|
/** Directory name — `email-db`. Unique within the store. */
|
||||||
id: string;
|
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;
|
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. */
|
/** WHEN to reach for this. The only thing the agent sees before deciding to open the body. */
|
||||||
description: string;
|
description: string;
|
||||||
/** Absolute path to SKILL.md. The agent cannot guess it, so the platform supplies it. */
|
/** 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 fm = frontmatter(readFileSync(path, 'utf8'));
|
||||||
const description = fm.description == null ? '' : String(fm.description).trim();
|
const description = fm.description == null ? '' : String(fm.description).trim();
|
||||||
if (!description) return null;
|
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 {
|
} catch {
|
||||||
return null;
|
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',
|
'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.',
|
'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');
|
].join('\n');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user