Four publishers copied a plugin's affordances, notifications and skills into capabilities/ at install. That directory is meant to be forked — core's work plus the owner's — so a fork accumulated files it did not author with nothing on disk saying which, and there was no good answer: commit them and carry another plugin's work, leave them and git status stays dirty. Copying was also wrong in two ways unrelated to git. A copy goes stale, because plugins:update pulls the plugin's directory and the copy keeps describing the old version. And a copy survived plugins:disable, so a disabled plugin's skill stayed in the agent's index — off, and still being announced. Now the loaders take a list of roots: the owner's store, plus each installed AND enabled plugin's own directory. capability-sources.ts resolves that list. No namespace is needed to keep them apart, because separate directories do it structurally rather than by convention. The owner's own win a skill name collision — their root is first and the merge keeps the first of each id — so a plugin cannot take over a name already in use. Affordances are deliberately NOT de-duplicated: several can be true at once. Notifications needed more than a root list, and it would have failed silently. Their codes were derived from the COPY'S PATH, <publisher>/<appName>/file.yaml. Read in place the path is just notifications/catalogue.yaml and carries no identity, so every plugin code would have vanished with nothing raised. loadPluginEntries takes the identity from the manifest instead, which is better provenance anyway — publisher and appName are validated at discovery, where a path was only ever a directory somebody chose. A yaml key still cannot claim another namespace, and that is pinned by test. createSession is async now, only because resolving enabled plugins reads the database. Once per session; its single caller already awaited. Deletes all four publishers including api/tasks/publish.ts from earlier today, superseded before it shipped. app-store/assets stays — images served over HTTP, where the copy is what makes them reachable. NOT done: tasks. That store is read-write, so reads must span sources while writes stay in the owner's directory, and a plugin's task has to be read-only in the UI. Next. Two things the design predicted as orphans turned out not to be, and both were checked before deleting rather than after. capabilities/extensions/tool-loader is written by the platform's own bootstrap every boot, not published by a plugin. And skills/email-db has no source to fall back on — the email plugin ships no skills directory, so that file exists in exactly one place and was hand-written. Neither was touched. 1422 pass, tsgo clean, officer restarts and serves. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Officer-Session: 656f5c85-e49a-4f1f-b9f3-042a04b4d043
129 lines
5.5 KiB
TypeScript
129 lines
5.5 KiB
TypeScript
import { readdirSync, readFileSync } from 'node:fs';
|
|
import { join } from 'node:path';
|
|
import { OFFICER_ITEMS_DIR } from '../data-path';
|
|
import { capabilityRoots } from '../capability-sources';
|
|
|
|
// What the platform tells an agent about the surface it is speaking through.
|
|
//
|
|
// ── The problem ──
|
|
//
|
|
// The platform hosts the agent and never introduces itself. An agent gets a shell, a filesystem and a
|
|
// prompt; everything about the CLIENT on the other end has to be guessed. It writes
|
|
// ``, nothing renders, and nothing tells it why — because an `<img>` carries no
|
|
// bearer token, and it is the USER'S DEVICE that fetches the URL.
|
|
//
|
|
// That knowledge used to live as prose in a hand-written CLAUDE.md on one machine. This reads it from
|
|
// `capabilities/affordances/` instead, where it is versioned, inspectable, and contributable by a plugin.
|
|
//
|
|
// See `plugins/CLAUDE.md` for what an affordance is and what belongs in one.
|
|
//
|
|
// ── Deliberately dumb ──
|
|
//
|
|
// No filtering. Every affordance is compiled into every conversation, because the axis to filter on is
|
|
// not one dimension — for image rendering it is what the CLIENT renders, for gitea what TOOLING exists on
|
|
// the machine. Picking the wrong axis now and writing files against it costs more than loading two extra
|
|
// paragraphs. `TODO.md` carries the argument.
|
|
|
|
const YAML = (Bun as unknown as { YAML: { parse(input: string): unknown } }).YAML;
|
|
|
|
export const AFFORDANCES_DIR = join(OFFICER_ITEMS_DIR, 'affordances');
|
|
|
|
export type Affordance = {
|
|
/** `<publisher>/<plugin>/<file>` — provenance, and unique. */
|
|
id: string;
|
|
name: string;
|
|
description: string | null;
|
|
/** An INTEGER, matching `capabilities/agents/`. Not the semver a plugin carries. */
|
|
version: number;
|
|
/** What is actually sent. */
|
|
body: string;
|
|
};
|
|
|
|
function parse(raw: string): { fm: Record<string, unknown>; body: string } {
|
|
const match = raw.match(/^---\n([\s\S]*?)\n---\n?([\s\S]*)$/);
|
|
if (!match) return { fm: {}, body: raw.trim() };
|
|
try {
|
|
return { fm: (YAML.parse(match[1]!) as Record<string, unknown>) ?? {}, body: match[2]!.trim() };
|
|
} catch {
|
|
// A broken header is not a reason to lose the body. The agent needs the prose far more than the name,
|
|
// and a file that vanished over a stray colon would be very hard to notice.
|
|
return { fm: {}, body: match[2]!.trim() };
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Every `.md` under `affordances/`, at any depth, sorted so the compiled block is stable.
|
|
*
|
|
* SYNCHRONOUS on purpose. `createSession` is not async, and making it so to read a few kilobytes would
|
|
* ripple through every caller for no benefit — this runs once per session, not per request.
|
|
*/
|
|
export function loadAffordances(root: string = AFFORDANCES_DIR): Affordance[] {
|
|
let entries: string[];
|
|
try {
|
|
// `recursive` because the tree is `<publisher>/<plugin>/<file>.md` and neither level is known here.
|
|
entries = (readdirSync(root, { recursive: true }) as string[]).filter((f) => f.endsWith('.md'));
|
|
} catch {
|
|
// No directory yet is the ordinary state of a fresh install, not an error.
|
|
return [];
|
|
}
|
|
|
|
const found = entries.sort().map((rel): Affordance | null => {
|
|
try {
|
|
const { fm, body } = parse(readFileSync(join(root, rel), 'utf8'));
|
|
if (!body) return null;
|
|
const versionRaw = fm.version;
|
|
return {
|
|
id: rel.replace(/\.md$/, ''),
|
|
// Falls back to the path rather than to "Untitled": a heading naming the file beats one that
|
|
// says nothing, and it makes a missing `name` visible without failing the load.
|
|
name: fm.name == null ? rel.replace(/\.md$/, '') : String(fm.name),
|
|
description: fm.description == null ? null : String(fm.description),
|
|
version: typeof versionRaw === 'number' ? versionRaw : Number(versionRaw) || 1,
|
|
body,
|
|
};
|
|
} catch {
|
|
return null;
|
|
}
|
|
});
|
|
|
|
return found.filter((a): a is Affordance => a !== null);
|
|
}
|
|
|
|
/**
|
|
* One block for the system prompt, or null when there is nothing to say.
|
|
*
|
|
* `description` is NOT included — it answers "when should I reach for this", which only matters when an
|
|
* agent chooses what to load. Everything here is already loaded, so it would be paid for and unread.
|
|
*/
|
|
export function compileAffordances(affordances: Affordance[]): string | null {
|
|
if (!affordances.length) return null;
|
|
return [
|
|
'# This environment',
|
|
'',
|
|
'What the surface you are talking through will and will not do. Not instructions — facts about the',
|
|
'client on the other end, which you cannot discover from the filesystem.',
|
|
'',
|
|
...affordances.map((a) => `## ${a.name}\n\n${a.body}`),
|
|
].join('\n');
|
|
}
|
|
|
|
/** Read and compile in one step, for callers that only want the string. */
|
|
/**
|
|
* Every affordance in force: the owner's own, plus each installed and enabled plugin's.
|
|
*
|
|
* Read in place rather than copied in — see `capability-sources.ts`. Unlike skills these are NOT
|
|
* de-duplicated by name: an affordance is a paragraph of guidance, several can be true at once, and two
|
|
* plugins describing the same surface differently is information rather than a collision.
|
|
*/
|
|
export function loadAffordancesAcross(roots: string[]): Affordance[] {
|
|
return roots.flatMap((root) => loadAffordances(root));
|
|
}
|
|
|
|
/**
|
|
* Read and compile in one step. Async because resolving which plugins are enabled reads the database;
|
|
* called once per session, which is what makes that affordable.
|
|
*/
|
|
export async function affordanceBlock(): Promise<string | null> {
|
|
return compileAffordances(loadAffordancesAcross(await capabilityRoots('affordances')));
|
|
}
|