import { existsSync } from 'node:fs'; import { join } from 'node:path'; import { itemsDir, type ItemType } from './data-path'; import { snapshotPlugins } from './plugins/mount'; /** * Where capabilities are read from — the owner's own store, plus every installed and enabled plugin's. * * ── Why plugins are read in place rather than copied in ── * * `capabilities/` is a git repository, and the point of it is that anybody can fork it: core's tasks and * skills, plus their own on top. That only holds if the directory contains exactly two things, what core * ships and what its owner wrote. Publishing a plugin's files into it broke that — a fork accumulated * files it did not author, with nothing on disk saying which, so committing them carried another plugin's * work and not committing them left `git status` permanently dirty. * * Copying was wrong a second way that has nothing to do with git. A copy goes STALE: `plugins:update` is * a `git pull` in the plugin's directory, and a capability copied out of it keeps describing the previous * version until something republishes. And a copy survives `plugins:disable`, so a disabled plugin's * skill stayed in the agent's index — the plugin off, the agent still told it was there. * * Reading in place fixes all of that by construction, and needs no namespace to do it. Scoping the paths * would have made provenance READABLE; separate directories make it STRUCTURAL, which is the stronger * property and the cheaper one. * * See `docs/plugin-capabilities.md`. * * ── Enabled, not merely installed ── * * A disabled plugin contributes nothing. That is the whole meaning of disable — routes down, sidecar * stopped — and a capability that outlived it was the one piece that did not comply. * * ── Cost ── * * This reads the database, so it is async, and it must not be called per turn. Its callers resolve it * once per session (`skillsBlock`, `affordanceBlock`) or per request on surfaces that are already doing * database work. A missing directory is skipped rather than erroring: most plugins ship no capabilities * at all, and that is the ordinary case rather than a fault. */ export type CapabilitySource = { dir: string; /** The plugin that ships it, or null for the owner's own store. */ plugin: string | null; }; export async function capabilitySources(type: ItemType | 'affordances' | 'notifications'): Promise { const sources: CapabilitySource[] = [{ dir: itemsDir(type as ItemType), plugin: null }]; let states: Awaited>['states'] = []; try { ({ states } = await snapshotPlugins()); } catch { // The owner's own capabilities still load. A database that cannot be read is a much louder problem // somewhere else, and losing the agent's whole skill index to it would only obscure that. return sources; } for (const state of states) { if (!state.install?.enabled) continue; const dir = join(state.plugin.dir, type); if (existsSync(dir)) sources.push({ dir, plugin: state.plugin.appName }); } return sources; } /** * The same list for a caller that only needs the paths. * * Which is most of them: a skill or an affordance is read, compiled into a preamble and never written * to, so where it came from changes nothing. Tasks are the exception — one can be deleted, and deleting * a plugin's file out of the owner's screen is not a thing the owner asked for — so `task-files.ts` * takes the sources and keeps the provenance. */ export async function capabilityRoots(type: ItemType | 'affordances' | 'notifications'): Promise { return (await capabilitySources(type)).map((s) => s.dir); } /** * Installed and enabled plugins, in the shape the notification catalogue wants. * * Notifications are the one capability whose namespace cannot be read off a path once the file stays in * the plugin's own directory — see `notifications/catalogue.ts` → `loadPluginEntries`. So the source list * carries the identity with it. */ export async function pluginCatalogueSources(): Promise< { appName: string; dir: string; manifest: { publisher: string } }[] > { try { const { states } = await snapshotPlugins(); return states .filter((s) => s.install?.enabled) .map((s) => ({ appName: s.plugin.appName, dir: s.plugin.dir, manifest: { publisher: s.plugin.manifest.publisher }, })); } catch { return []; } }