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 // `![](/home/me/chart.png)`, nothing renders, and nothing tells it why — because an `` 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 = { /** `//` — 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; 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) ?? {}, 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 `//.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 { return compileAffordances(loadAffordancesAcross(await capabilityRoots('affordances'))); }