diff --git a/app/api/capabilities/standard/route.ts b/app/api/capabilities/standard/route.ts new file mode 100644 index 00000000..5be03891 --- /dev/null +++ b/app/api/capabilities/standard/route.ts @@ -0,0 +1,17 @@ +/** + * GET /api/capabilities/standard — the machine-readable Capability Standard + * (zero-code onboarding PR-8). Public + cacheable: a capability provider (or + * their coding agent) fetches it to author a compliant descriptor — the same + * standard the generator + validator + ingest use. Derived from the committed + * registry snapshot, so it's safe to cache at the edge. + */ +import { NextResponse } from "next/server"; +import { getCapabilityStandard } from "@/lib/capabilities/standard"; + +export const dynamic = "force-static"; + +export function GET() { + return NextResponse.json(getCapabilityStandard(), { + headers: { "cache-control": "public, max-age=300, stale-while-revalidate=3600" }, + }); +} diff --git a/lib/capabilities/standard.ts b/lib/capabilities/standard.ts new file mode 100644 index 00000000..80d7926e --- /dev/null +++ b/lib/capabilities/standard.ts @@ -0,0 +1,105 @@ +/** + * The Capability Standard — zero-code onboarding PR-8 (plan §3.5). + * + * The single machine-readable statement of storyboard's capability conventions: + * the valid kinds / output-kinds, the modality vocabulary (+ the modalities + * actually in the live registry), the agent-param names an io.input may map + * `from`, the naming grammar, and the supported input transforms. The generator + * (draft_capability), the validator (cap-check), and the discovery→registry sync + * all read the SAME standard, so "authored against the standard" ≡ "accepted on + * ingest". Browser-safe (no server-only imports); served at + * /api/capabilities/standard for providers + their agents. + */ +import { + DESCRIPTOR_KINDS, + DESCRIPTOR_OUTPUT_KINDS, + IO_OUTPUT_KINDS, +} from "./descriptor"; +import { getRegistrySnapshot } from "./registry-store"; + +/** + * The modality vocabulary. Media suffixes (the naming convention) + the + * analysis/understanding modalities that produce text. A provider's modality + * SHOULD be one of these; a new one is allowed but flagged for deliberate review. + */ +export const MODALITY_VOCABULARY = [ + // image + "t2i", "i2i", "edit", "fill", "erase", "bg-remove", "upscale", "vto", + // video + "t2v", "i2v", "ref2v", "a2v", "transition", "hdr", "ingredient", "lipsync", + // audio + "tts", "t2m", "v2m", "inpaint", "extend", "asr", + // 3d + "i3d", "t3d", + // adapters + "lora", + // analysis / understanding (text output) + "video-understanding", "image-understanding", "text", +] as const; + +/** The agent-facing param names an io input may map `from`. */ +export const AGENT_PARAM_NAMES = [ + "prompt", + "source_url", + "audio_url", + "image_url", + "video_url", + "text", +] as const; + +/** Supported declared input transforms (applied by the SDK dispatch). */ +export const INPUT_TRANSFORMS = ["url_to_base64"] as const; + +export const NAMING_GRAMMAR = { + media: "[-]- (e.g. flux-dev-t2i; existing caps keep grandfathered names like 'flux-dev')", + tool_mcp: "- (e.g. ffmpeg-concat)", + semantic_key: "::: (lowercased) — the dedup key; NEVER hand-typed", +} as const; + +export interface CapabilityStandard { + kinds: readonly string[]; + output_kinds: readonly string[]; + io_output_kinds: readonly string[]; + modality_vocabulary: readonly string[]; + /** Modalities present in the committed registry — the empirically-in-use set. */ + live_registry_modalities: string[]; + agent_param_names: readonly string[]; + input_transforms: readonly string[]; + naming_grammar: typeof NAMING_GRAMMAR; +} + +/** The full standard, folding in the modalities the live registry actually uses. */ +export function getCapabilityStandard(): CapabilityStandard { + const live = Array.from( + new Set(getRegistrySnapshot().map((e) => e.modality).filter(Boolean)), + ).sort(); + return { + kinds: DESCRIPTOR_KINDS, + output_kinds: DESCRIPTOR_OUTPUT_KINDS, + io_output_kinds: IO_OUTPUT_KINDS, + modality_vocabulary: MODALITY_VOCABULARY, + live_registry_modalities: live, + agent_param_names: AGENT_PARAM_NAMES, + input_transforms: INPUT_TRANSFORMS, + naming_grammar: NAMING_GRAMMAR, + }; +} + +/** Is a modality known (in the vocabulary or already in the live registry)? */ +export function isKnownModality(modality: string): boolean { + const m = modality.toLowerCase(); + if ((MODALITY_VOCABULARY as readonly string[]).includes(m)) return true; + return getRegistrySnapshot().some((e) => (e.modality ?? "").toLowerCase() === m); +} + +/** Nearest known modalities to a miss (cheap prefix/substring match) — for suggestions. */ +export function suggestModalities(modality: string, limit = 3): string[] { + const m = modality.toLowerCase(); + const pool = new Set([ + ...MODALITY_VOCABULARY, + ...getRegistrySnapshot().map((e) => (e.modality ?? "").toLowerCase()).filter(Boolean), + ]); + return Array.from(pool) + .filter((k) => k.includes(m) || m.includes(k) || k[0] === m[0]) + .slice(0, limit); +} diff --git a/lib/mcp-server/server.ts b/lib/mcp-server/server.ts index 2129f5cb..48b139df 100644 --- a/lib/mcp-server/server.ts +++ b/lib/mcp-server/server.ts @@ -3,6 +3,7 @@ import { listCapabilities, ListCapabilitiesInput } from "./tools/list-capabiliti import { getPricing, GetPricingInput } from "./tools/get-pricing"; import { describeCapability, DescribeCapabilityInput } from "./tools/describe-capability"; import { runCapability, RunCapabilityInput } from "./tools/run-capability"; +import { draftCapability, DraftCapabilityInput } from "./tools/draft-capability"; import { capDeprecate, CapDeprecateInput, @@ -453,6 +454,18 @@ export function buildStoryboardMcpServer( async (args: unknown) => runCapability(args as RunCapabilityInput, auth), ); + registerTool( + server, + "draft_capability", + { + title: "Draft a compliant Capability Descriptor (author-by-derivation)", + description: + "Given the irreducible facts (family, modality, one-line 'does', your runner's io contract + offering), GENERATE a complete, compliant Capability Descriptor: it derives the semantic_key + canonical name + output_kind (never hand-typed), validates against the schema, checks the modality vocabulary, and returns the dedup verdict (NEW / ADD-CAPACITY / SYNONYM). Publish the returned descriptor on your orchestrator's /discovery. See GET /api/capabilities/standard for the vocabulary.", + inputSchema: DraftCapabilityInput, + }, + async (args: unknown) => draftCapability(args as DraftCapabilityInput, auth), + ); + registerTool( server, "cap_deprecate", diff --git a/lib/mcp-server/tools/describe-capability.ts b/lib/mcp-server/tools/describe-capability.ts index 52b8cec9..0a85cd2c 100644 --- a/lib/mcp-server/tools/describe-capability.ts +++ b/lib/mcp-server/tools/describe-capability.ts @@ -37,7 +37,7 @@ const OUTPUT_TO_ACTIONS: Record = { // json analysis caps) via the generic run_capability verb. Used only when the // cap has no authored usage.invoke_via. const MEDIA_OUTPUT_KINDS = new Set(["image", "video", "audio", "3d"]); -function defaultInvokeVia(outputKind: string | undefined): string { +function defaultInvokeVia(outputKind: string | null | undefined): string { return MEDIA_OUTPUT_KINDS.has(outputKind ?? "image") ? "create_media" : "run_capability"; } diff --git a/lib/mcp-server/tools/draft-capability.ts b/lib/mcp-server/tools/draft-capability.ts new file mode 100644 index 00000000..a072e9d4 --- /dev/null +++ b/lib/mcp-server/tools/draft-capability.ts @@ -0,0 +1,153 @@ +/** + * draft_capability — zero-code onboarding PR-8 (plan §3.5 "authoring by derivation"). + * + * A provider (or their agent) supplies only the IRREDUCIBLE facts — family, + * modality, a one-line "what it does", their runner's io contract + offering — + * and this GENERATES a complete, compliant Capability Descriptor: it derives the + * semantic_key + canonical name + output_kind (never hand-typed — that's where + * descriptors go wrong), assembles + VALIDATES against the PR-1 schema, checks + * the modality against the standard, and returns the dedup verdict against the + * live registry (NEW / ADD-CAPACITY / SYNONYM). The output is a ready-to-publish + * descriptor the provider drops into their orchestrator's /discovery. + */ +import { z } from "zod"; +import type { AuthContext } from "../auth"; +import { + validateDescriptor, + computeSemanticKey, + resolveOutputKind, + IoSchema, + OfferingSchema, + DESCRIPTOR_KINDS, + DESCRIPTOR_OUTPUT_KINDS, + type CapabilityDescriptor, +} from "@/lib/capabilities/descriptor"; +import { canonicalName } from "@/lib/capabilities/discovery-sync"; +import { isKnownModality, suggestModalities } from "@/lib/capabilities/standard"; +import { readRegistry } from "@/lib/capabilities/registry-overlay"; +import type { RegistryEntry } from "@/lib/capabilities/generate-registry"; + +export const DraftCapabilityInput = z.object({ + family: z.string().min(1).describe("Model/tool family, e.g. 'screen-agent', 'flux'."), + modality: z.string().min(1).describe("What it does — a modality (t2i/i2v/video-understanding/…)."), + does: z.string().min(1).describe("One line: what it does (becomes good_for)."), + kind: z.enum(DESCRIPTOR_KINDS).default("live-runner"), + variant: z.string().min(1).optional().describe("Tier/variant, e.g. 'quality'."), + output_kind: z.enum(DESCRIPTOR_OUTPUT_KINDS).optional().describe("Omit to infer from modality."), + avoid_for: z.string().optional(), + aliases: z.array(z.string()).optional(), + io: IoSchema, + offering: OfferingSchema, +}); +export type DraftCapabilityInput = z.infer; + +export type DedupVerdict = + | { verdict: "new" } + | { verdict: "add_capacity"; owner: string } + | { verdict: "synonym"; owner: string }; + +/** PURE — the dedup verdict for a drafted identity against the registry view. */ +export function dedupVerdict( + name: string, + key: string, + existing: RegistryEntry[], +): DedupVerdict { + const owner = existing.find( + (e) => + (e.semantic_key ?? + computeSemanticKey({ + kind: e.kind, + modality: e.modality ?? "", + family: e.family ?? "", + variant: e.variant ?? null, + })) === key, + )?.name; + if (!owner) return { verdict: "new" }; + return owner === name ? { verdict: "add_capacity", owner } : { verdict: "synonym", owner }; +} + +export interface DraftResult { + ok: boolean; + descriptor?: CapabilityDescriptor; + name: string; + semantic_key: string; + modality_known: boolean; + modality_suggestions: string[]; + errors?: string[]; +} + +/** PURE — assemble + derive + validate a descriptor from the irreducible facts. */ +export function buildDraft(input: DraftCapabilityInput): DraftResult { + const variant = input.variant ?? null; + const capability = { + kind: input.kind, + family: input.family, + modality: input.modality, + variant, + output_kind: resolveOutputKind({ output_kind: input.output_kind, modality: input.modality }), + aliases: input.aliases ?? [], + good_for: input.does, + ...(input.avoid_for ? { avoid_for: input.avoid_for } : {}), + io: input.io, + offering: input.offering, + }; + const name = canonicalName(capability as never); + const key = computeSemanticKey({ kind: input.kind, modality: input.modality, family: input.family, variant }); + const validated = validateDescriptor({ capability }); + + return { + ok: validated.ok, + descriptor: validated.ok ? validated.value : undefined, + name, + semantic_key: key, + modality_known: isKnownModality(input.modality), + modality_suggestions: isKnownModality(input.modality) ? [] : suggestModalities(input.modality), + errors: validated.ok ? undefined : validated.errors, + }; +} + +export async function draftCapability(input: DraftCapabilityInput, auth: AuthContext) { + const draft = buildDraft(input); + let dedup: DedupVerdict = { verdict: "new" }; + try { + dedup = dedupVerdict(draft.name, draft.semantic_key, await readRegistry()); + } catch { + // fail-open: no registry read → treat as new (the sync re-checks on ingest) + } + + const lines: string[] = []; + if (!draft.ok) { + lines.push(`✗ descriptor invalid — fix and re-draft:`, ...(draft.errors ?? []).map((e) => ` · ${e}`)); + } else { + lines.push(`✓ drafted "${draft.name}" (${draft.semantic_key})`); + if (!draft.modality_known) { + lines.push( + `⚠︎ modality "${input.modality}" isn't in the standard vocabulary${draft.modality_suggestions.length ? ` — did you mean: ${draft.modality_suggestions.join(", ")}?` : ""} (a new modality is allowed, but confirm it's intentional).`, + ); + } + if (dedup.verdict === "synonym") { + lines.push( + `✗ SYNONYM: that identity already belongs to "${dedup.owner}". Reuse "${dedup.owner}" and add your offering under it — don't mint a new name.`, + ); + } else if (dedup.verdict === "add_capacity") { + lines.push(`↑ ADD-CAPACITY: "${dedup.owner}" already exists — you're a new provider of it (offering only, no new identity).`); + } else { + lines.push(`+ NEW identity — publish this descriptor on your /discovery.`); + } + } + + return { + isError: !draft.ok || dedup.verdict === "synonym", + content: [{ type: "text" as const, text: lines.join("\n") }], + structuredContent: { + ok: draft.ok && dedup.verdict !== "synonym", + name: draft.name, + semantic_key: draft.semantic_key, + dedup, + modality_known: draft.modality_known, + modality_suggestions: draft.modality_suggestions, + descriptor: draft.descriptor, + errors: draft.errors, + }, + }; +} diff --git a/tests/unit/capability-standard-draft.test.ts b/tests/unit/capability-standard-draft.test.ts new file mode 100644 index 00000000..1fccbc64 --- /dev/null +++ b/tests/unit/capability-standard-draft.test.ts @@ -0,0 +1,106 @@ +import { describe, it, expect } from "vitest"; +import { + getCapabilityStandard, + isKnownModality, + suggestModalities, +} from "@/lib/capabilities/standard"; +import { buildDraft, dedupVerdict } from "@/lib/mcp-server/tools/draft-capability"; +import type { RegistryEntry } from "@/lib/capabilities/generate-registry"; + +const SCREEN_AGENT_FACTS = { + family: "screen-agent", + modality: "video-understanding", + does: "screen recording → structured bug report", + kind: "live-runner" as const, + variant: undefined, + aliases: ["bug-report"], + io: { + endpoint: "/analyze", + inputs: { + video: { + wire: "video_b64", + from: "source_url", + required: true, + constraints: { formats: ["mp4", "webm"], max_mb: 190 }, + }, + }, + output: { kind: "text" as const, fields: ["report_markdown", "summary"], primary: "report_markdown" }, + }, + offering: { + app: "emran/screen-agent", + mode: "single-shot" as const, + capacity: 1, + price: { price_per_unit: 138889, pixels_per_unit: 1, unit: "WEI" as const, display_usd: 0.000139, display_unit: "second" }, + }, +}; + +describe("Capability Standard (PR-8)", () => { + it("exposes kinds / output-kinds / modality vocabulary / agent params", () => { + const s = getCapabilityStandard(); + expect(s.kinds).toContain("live-runner"); + expect(s.output_kinds).toContain("text"); + expect(s.modality_vocabulary).toContain("video-understanding"); + expect(s.agent_param_names).toContain("source_url"); + expect(s.input_transforms).toContain("url_to_base64"); + // live registry modalities are folded in (t2i is used by flux-dev etc.) + expect(s.live_registry_modalities).toContain("t2i"); + }); + + it("knows / suggests modalities", () => { + expect(isKnownModality("video-understanding")).toBe(true); + expect(isKnownModality("t2i")).toBe(true); + expect(isKnownModality("totally-made-up-xyz")).toBe(false); + // a near-miss gets a suggestion + expect(suggestModalities("understanding").some((m) => m.includes("understanding"))).toBe(true); + }); +}); + +describe("draft_capability buildDraft (PR-8)", () => { + it("derives name + semantic_key + output_kind and validates", () => { + const d = buildDraft(SCREEN_AGENT_FACTS); + expect(d.ok).toBe(true); + expect(d.name).toBe("screen-agent-video-understanding"); + expect(d.semantic_key).toBe("live-runner:video-understanding:screen-agent:-"); + expect(d.modality_known).toBe(true); + // output_kind inferred as text, surfaced on the descriptor + expect(d.descriptor?.capability.output_kind ?? "text").toBe("text"); + expect(d.descriptor?.capability.good_for).toContain("bug report"); + }); + + it("flags an off-vocabulary modality (still drafts, but warns)", () => { + const d = buildDraft({ ...SCREEN_AGENT_FACTS, modality: "screenwatch" }); + expect(d.modality_known).toBe(false); + expect(Array.isArray(d.modality_suggestions)).toBe(true); + }); + + it("returns validation errors for a broken io contract", () => { + const d = buildDraft({ + ...SCREEN_AGENT_FACTS, + io: { ...SCREEN_AGENT_FACTS.io, output: { kind: "text", fields: ["a"], primary: "nope" } }, + }); + expect(d.ok).toBe(false); + expect((d.errors ?? []).join(" ")).toMatch(/primary/); + }); +}); + +describe("dedupVerdict (PR-8)", () => { + const existing: RegistryEntry[] = [ + { + name: "flux-dev", kind: "ai", modality: "t2i", family: "flux", variant: "dev", + semantic_key: "ai:t2i:flux:dev", aliases: [], output_kind: "image", + fallback_chain: null, sla: null, unit_kind: null, display_unit: null, + display_price_usd: null, status: "active", owner_scope: "universal", + }, + ]; + + it("NEW when the key is unseen", () => { + expect(dedupVerdict("screen-agent-video-understanding", "live-runner:video-understanding:screen-agent:-", existing)) + .toEqual({ verdict: "new" }); + }); + it("ADD-CAPACITY when key + name match an existing cap", () => { + expect(dedupVerdict("flux-dev", "ai:t2i:flux:dev", existing)).toEqual({ verdict: "add_capacity", owner: "flux-dev" }); + }); + it("SYNONYM when the key exists under a different name", () => { + expect(dedupVerdict("myco-flux", "ai:t2i:flux:dev", existing)).toEqual({ verdict: "synonym", owner: "flux-dev" }); + }); +});