From 484a9f0495cd95925ee547cdd680b00f725432cb Mon Sep 17 00:00:00 2001 From: Ramnique Singh <30795890+ramnique@users.noreply.github.com> Date: Thu, 9 Jul 2026 22:35:41 +0530 Subject: [PATCH] =?UTF-8?q?refactor(x):=20capability=20record=20=E2=80=94?= =?UTF-8?q?=20skills=20become=20the=20model-activated=20subset?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit capabilities/types.ts introduces the assembly unit: id/title/summary + lazy guidance + owned tools (what skills already were), plus an activation axis ('model' | 'app' | 'always') and an eager promptFragment(ctx) for app/always entries — pure over a persisted composition context so composed prompts stay byte-identical. SkillDefinition is now CapabilityDefinition & {content}: bundled skills are unchanged model-activated entries, the loadSkill catalog filters to that subset, and disk skills remain structurally limited to it (eager fragments and app activation are bundled-only powers — a disk file must never gain a standing system-prompt injection channel). Co-Authored-By: Claude Fable 5 --- .../assistant/capabilities/types.ts | 56 +++++++++++++++++++ .../assistant/skills/index.test.ts | 22 ++++++++ .../src/application/assistant/skills/index.ts | 26 ++++----- 3 files changed, 91 insertions(+), 13 deletions(-) create mode 100644 apps/x/packages/core/src/application/assistant/capabilities/types.ts diff --git a/apps/x/packages/core/src/application/assistant/capabilities/types.ts b/apps/x/packages/core/src/application/assistant/capabilities/types.ts new file mode 100644 index 00000000..f0fee3cd --- /dev/null +++ b/apps/x/packages/core/src/application/assistant/capabilities/types.ts @@ -0,0 +1,56 @@ +// The capability record: the unit of agent assembly. A capability owns what +// it contributes to an agent — lazy guidance the model can pull in, tools, +// and (for app/always activation) an eager system-prompt fragment. +// +// Activation is the axis that distinguishes the historical concepts: +// - 'model' — today's skills: a catalog line in the prompt; the model calls +// loadSkill when it recognizes the task; guidance arrives as a +// tool result and declared tools attach mid-turn. +// - 'app' — today's modes (voice, video, coach, code): the app toggles a +// fact about the world; the fragment is composed into the +// system prompt from token zero and tools attach at assembly. +// - 'always' — unconditional contributions (workspace context traits). +// +// Trust boundary: disk-loaded skills (~/.rowboat/skills, ~/.agents/skills) +// are structurally limited to the 'model' subset — eager prompt fragments +// and app activation are bundled-only powers. A model-activated skill's +// prose lands in conversation because the model chose to read it; a disk +// file injecting into every turn's system prompt would be a standing +// prompt-injection channel. + +export type CapabilityActivation = "model" | "app" | "always"; + +// Inputs an eager fragment may read. All fields mirror persisted +// RequestedAgent.overrides.composition values, resolved before composition — +// a fragment must be a pure function of this context so composed prompts +// stay byte-identical for identical inputs (provider prefix caching and +// agent-snapshot inheritance both depend on it). +export interface CapabilityContext { + voiceInput: boolean; + voiceOutput: "summary" | "full" | null; + searchEnabled: boolean; + codeMode: "claude" | "codex" | null; + codeCwd: string | null; + videoMode: boolean; + coachMode: boolean; +} + +export interface CapabilityDefinition { + id: string; + title: string; + summary: string; + // Defaults to 'model' (the historical skill behavior). + activation?: CapabilityActivation; + // Lazy guidance returned by loadSkill (model activation). + content?: string; + // BuiltinTools keys this capability owns. + tools?: string[]; + // Eager system-prompt fragment (app/always activation). Returns null + // when the capability contributes nothing for this context. MUST be + // pure: same context, same bytes. + promptFragment?: (ctx: CapabilityContext) => string | null; +} + +export function isModelActivated(def: Pick): boolean { + return def.activation === undefined || def.activation === "model"; +} diff --git a/apps/x/packages/core/src/application/assistant/skills/index.test.ts b/apps/x/packages/core/src/application/assistant/skills/index.test.ts index e8b4e6ed..02f38e54 100644 --- a/apps/x/packages/core/src/application/assistant/skills/index.test.ts +++ b/apps/x/packages/core/src/application/assistant/skills/index.test.ts @@ -146,3 +146,25 @@ describe("skills index (disk skill merge layer)", () => { expect(skills.resolveSkill(skillFile)).toBeNull(); }); }); + +describe("capability record (types)", () => { + it("defaults to model activation — every bundled skill is model-activated", async () => { + const { isModelActivated } = await import("../capabilities/types.js"); + // Bundled definitions carry no activation field today; they are the + // model-activated subset by default and must stay in the catalog. + expect(isModelActivated({})).toBe(true); + expect(isModelActivated({ activation: "model" })).toBe(true); + expect(isModelActivated({ activation: "app" })).toBe(false); + expect(isModelActivated({ activation: "always" })).toBe(false); + }); + + it("keeps app/always capabilities out of the loadSkill catalog", async () => { + const { buildSkillCatalog, availableSkills } = await import("./index.js"); + const catalog = buildSkillCatalog(); + // Every advertised id resolves via loadSkill (the catalog and the + // resolver operate on the same model-activated subset). + for (const id of availableSkills) { + expect(catalog).toContain(id); + } + }); +}); diff --git a/apps/x/packages/core/src/application/assistant/skills/index.ts b/apps/x/packages/core/src/application/assistant/skills/index.ts index 2d4e80e1..85b82e56 100644 --- a/apps/x/packages/core/src/application/assistant/skills/index.ts +++ b/apps/x/packages/core/src/application/assistant/skills/index.ts @@ -1,5 +1,6 @@ import path from "node:path"; import { fileURLToPath } from "node:url"; +import { isModelActivated, type CapabilityDefinition } from "../capabilities/types.js"; import { loadDiskSkills } from "./disk-loader.js"; import builtinToolsSkill from "./builtin-tools/skill.js"; import deletionGuardrailsSkill from "./deletion-guardrails/skill.js"; @@ -25,17 +26,12 @@ const CATALOG_PREFIX = "src/application/assistant/skills"; // console.log(liveNoteSkill); -type SkillDefinition = { - id: string; // Also used as folder name - title: string; - summary: string; - content: string; - // BuiltinTools keys this skill owns. Loading the skill attaches these as - // native tool definitions (they are NOT in the copilot's base toolset). - // Names are validated against the live catalog where descriptors are - // built (loadSkill / the agent resolver); unknown names are dropped there. - tools?: string[]; -}; +// A skill is the model-activated subset of the capability record +// (capabilities/types.ts): lazy guidance the model pulls in via loadSkill, +// plus the BuiltinTools it owns. `id` doubles as the folder name. Tool names +// are validated against the live catalog where descriptors are built +// (loadSkill / the agent resolver); unknown names are dropped there. +type SkillDefinition = CapabilityDefinition & { content: string }; type ResolvedSkill = { id: string; @@ -242,9 +238,13 @@ function getSkillEntries(): SkillEntry[] { * Reads the live skill set, so it reflects disk skills added/removed at runtime. */ export function buildSkillCatalog(options?: { excludeIds?: string[] }): string { + // The catalog advertises the model-activated subset only: app/always + // capabilities are composed into the system prompt by the assembly layer, + // not offered for loadSkill. + const modelEntries = getSkillEntries().filter(isModelActivated); const entries = options?.excludeIds - ? getSkillEntries().filter(e => !options.excludeIds!.includes(e.id)) - : getSkillEntries(); + ? modelEntries.filter(e => !options.excludeIds!.includes(e.id)) + : modelEntries; const sections = entries.map((entry) => [ `## ${entry.title}`, `- **Skill file:** \`${entry.catalogPath}\``,