diff --git a/pkg/gen/clidocs/clidocs.go b/pkg/gen/clidocs/clidocs.go index 0fc5486d..cb6767e3 100644 --- a/pkg/gen/clidocs/clidocs.go +++ b/pkg/gen/clidocs/clidocs.go @@ -15,6 +15,8 @@ import ( "path/filepath" "sort" "strings" + "unicode" + "unicode/utf8" "github.com/spf13/cobra" "github.com/spf13/pflag" @@ -92,7 +94,7 @@ func overviewPage(root *cobra.Command, fams []*cobra.Command) string { func familyPage(fam *cobra.Command, order int) string { var b bytes.Buffer title := "oap " + fam.Name() - b.WriteString(mdxutil.Frontmatter(title, section, group, order, mdxutil.FirstLine(fam.Short))) + b.WriteString(mdxutil.Frontmatter(title, section, group, order, familyDescription(fam))) fmt.Fprintf(&b, "# %s\n\n", title) if d := describe(fam); d != "" { b.WriteString(d + "\n\n") @@ -102,6 +104,17 @@ func familyPage(fam *cobra.Command, order int) string { return b.String() } +// familyDescription is a family page's meta description. cobra's Short alone +// ("Manage AgentClass resources") names neither the command nor what the page +// holds, so a search result built from it reads as a fragment. +func familyDescription(fam *cobra.Command) string { + short := strings.TrimSuffix(mdxutil.FirstLine(fam.Short), ".") + if r, size := utf8.DecodeRuneInString(short); size > 0 { + short = string(unicode.ToUpper(r)) + short[size:] + ". " + } + return fmt.Sprintf("oap %s: %sEvery subcommand, flag, and example, generated from the CLI.", fam.Name(), short) +} + // writeCommandTree renders each visible subcommand of cmd as a heading section // (usage, description, flags, example), then recurses. level is the markdown // heading level for the direct children (2 = ##), capped at 4. diff --git a/pkg/gen/clidocs/clidocs_test.go b/pkg/gen/clidocs/clidocs_test.go index 156b5086..c2dc8f54 100644 --- a/pkg/gen/clidocs/clidocs_test.go +++ b/pkg/gen/clidocs/clidocs_test.go @@ -4,6 +4,7 @@ import ( "os" "path/filepath" "testing" + "unicode/utf8" "github.com/spf13/cobra" "github.com/stretchr/testify/assert" @@ -42,6 +43,8 @@ func TestGenerate(t *testing.T) { require.NoError(t, err) fam := string(famB) assert.Contains(t, fam, "title: 'oap agent'") + assert.Contains(t, fam, "description: 'oap agent: Manage agents. Every subcommand, flag, and example, generated from the CLI.'", + "description names the command and what the page holds") assert.Contains(t, fam, "`oap agent run `", "usage heading is backtick-wrapped so stay literal") assert.Contains(t, fam, "--prompt string", "local flag rendered") assert.Contains(t, fam, "oap agent run pirate", "example rendered") @@ -51,3 +54,24 @@ func TestGenerate(t *testing.T) { _, statErr := os.Stat(filepath.Join(dir, "oap-secret.mdx")) assert.True(t, os.IsNotExist(statErr), "no page for a hidden family") } + +func TestFamilyDescription(t *testing.T) { + const tail = "Every subcommand, flag, and example, generated from the CLI." + cases := []struct { + name string + short string + want string + }{ + {name: "lowercase: first letter capitalized", short: "verify the install", want: "oap x: Verify the install. " + tail}, + {name: "trailing period: not doubled", short: "Manage agents.", want: "oap x: Manage agents. " + tail}, + {name: "leading multi-byte rune: capitalized whole", short: "élan mode", want: "oap x: Élan mode. " + tail}, + {name: "empty Short: command and tail only", short: "", want: "oap x: " + tail}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + got := familyDescription(&cobra.Command{Use: "x", Short: tc.short}) + assert.Equal(t, tc.want, got) + assert.True(t, utf8.ValidString(got), "valid UTF-8") + }) + } +} diff --git a/pkg/gen/crddocs/crddocs.go b/pkg/gen/crddocs/crddocs.go index 7de360c6..69a9ba57 100644 --- a/pkg/gen/crddocs/crddocs.go +++ b/pkg/gen/crddocs/crddocs.go @@ -142,7 +142,7 @@ func overviewPage(crds []crdInfo) string { func crdPage(c crdInfo, order int) string { var b bytes.Buffer - b.WriteString(mdxutil.Frontmatter(c.kind, section, group, order, mdxutil.FirstLine(c.desc))) + b.WriteString(mdxutil.Frontmatter(c.kind, section, group, order, mdxutil.Summary(c.desc))) fmt.Fprintf(&b, "# %s\n\n", c.kind) fmt.Fprintf(&b, "**Group** `%s` · **Scope** %s", c.apiGroup, c.scope) if len(c.shortNames) > 0 { diff --git a/pkg/gen/mdxutil/mdxutil.go b/pkg/gen/mdxutil/mdxutil.go index ca201569..092788a2 100644 --- a/pkg/gen/mdxutil/mdxutil.go +++ b/pkg/gen/mdxutil/mdxutil.go @@ -6,6 +6,7 @@ package mdxutil import ( "fmt" "strings" + "unicode/utf8" ) // Frontmatter renders the `export const meta = {…}` block every generated doc @@ -44,6 +45,40 @@ func FirstLine(s string) string { return strings.TrimSpace(s) } +// summaryMax is the longest Summary returns: about what a search result shows +// of a meta description before it truncates. +const summaryMax = 160 + +// Summary turns a doc comment into a page's meta description: the first +// sentence of its first paragraph, with the comment's hard line wraps joined. +// FirstLine is wrong for this: Go doc comments wrap at ~80 columns, so their +// first line stops mid-sentence. A sentence over summaryMax is cut at a word +// boundary and ends in an ellipsis. +func Summary(s string) string { + para, _, _ := strings.Cut(strings.TrimSpace(s), "\n\n") + para = strings.Join(strings.Fields(para), " ") + for i := 0; i+2 < len(para); i++ { + // A sentence ends at ". " before a capital, which skips "e.g. x". + if para[i] == '.' && para[i+1] == ' ' && 'A' <= para[i+2] && para[i+2] <= 'Z' { + para = para[:i+1] + break + } + } + if len(para) <= summaryMax { + return para + } + cut := strings.LastIndexByte(para[:summaryMax], ' ') + if cut <= 0 { + // No space to break at: cut at the limit, backed off to a rune + // boundary so a multi-byte character is never split. + cut = summaryMax + for cut > 0 && !utf8.RuneStart(para[cut]) { + cut-- + } + } + return strings.TrimRight(para[:cut], " ,;:-") + "…" +} + // TableText makes a string safe for a single markdown table cell: first line // only, MDX-escaped, with the cell separator escaped. func TableText(s string) string { diff --git a/pkg/gen/mdxutil/mdxutil_test.go b/pkg/gen/mdxutil/mdxutil_test.go new file mode 100644 index 00000000..66fcfcea --- /dev/null +++ b/pkg/gen/mdxutil/mdxutil_test.go @@ -0,0 +1,58 @@ +package mdxutil + +import ( + "strings" + "testing" + "unicode/utf8" + + "github.com/stretchr/testify/assert" +) + +func TestSummary(t *testing.T) { + long := strings.Repeat("word ", 50) + "end." + cases := []struct { + name string + in string + want string + }{ + { + name: "wrapped first sentence: joined, stops at the period", + in: "AgentClass is the template: model, system\nprompt, tools. It never runs\non its own.", + want: "AgentClass is the template: model, system prompt, tools.", + }, + { + name: "single sentence: returned whole", + in: "SkillSource is a git repo that yields one or more Skills.", + want: "SkillSource is a git repo that yields one or more Skills.", + }, + { + name: "second paragraph: ignored", + in: "Widget is a demo\n\nNamespaced. Reconciled elsewhere.", + want: "Widget is a demo", + }, + { + name: "abbreviation before lowercase: not a sentence end", + in: "Channel binds a transport, e.g. a Slack channel. More here.", + want: "Channel binds a transport, e.g. a Slack channel.", + }, + { + name: "over the limit: cut at a word boundary with an ellipsis", + in: long, + want: strings.TrimSpace(strings.Repeat("word ", 32)) + "…", + }, + { + // 159 ASCII bytes, then a 3-byte rune straddling the 160-byte limit. + name: "no space and a rune across the limit: cut before the rune, valid UTF-8", + in: strings.Repeat("a", 159) + "日本語", + want: strings.Repeat("a", 159) + "…", + }, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + got := Summary(tc.in) + assert.True(t, utf8.ValidString(got), "valid UTF-8") + assert.Equal(t, tc.want, got) + assert.LessOrEqual(t, len(strings.TrimSuffix(got, "…")), summaryMax) + }) + } +} diff --git a/site/AGENTS.md b/site/AGENTS.md index b58362e0..5a4c3ca4 100644 --- a/site/AGENTS.md +++ b/site/AGENTS.md @@ -62,6 +62,13 @@ Capture stills and clips with the `showcase/` engine (see `site/public/media/`, add an entry to `site/content/_manifest.json`, and reference it from a page with `` or ``. +### Add an MDX component + +`lib/markdown.ts` turns guides into the Markdown served to agents +(`/docs/.md`, `/llms.txt`, `/llms-full.txt`). A new component or HTML tag +needs a rule there, or `lib/markdown.test.ts` fails on the first guide that uses +it. + ## Rules (non-negotiable) - **Real-not-drift.** Prefer captured fixtures and generated reference over diff --git a/site/README.md b/site/README.md index aa55029f..3d3c408d 100644 --- a/site/README.md +++ b/site/README.md @@ -41,6 +41,29 @@ so treat it as possibly stale until you rebuild. build, the generated `_pagefind/` search index - `scripts/` — `check.ts`, the validator behind `pnpm check` +## Search engines and agents + +- **Metadata.** Site-wide defaults are in `lib/site.ts` and `app/layout.tsx`; + each guide's title and `description` become its ``, meta description, + and Open Graph tags. `pnpm check` fails a guide with no description. +- **Share cards.** `app/opengraph-image.tsx` is the site card; + `app/docs/[slug]/opengraph-image.tsx` renders one per guide. Both use + `components/OgCard.tsx`. +- **Structured data.** JSON-LD from `lib/structured-data.ts`: Organization, + WebSite and SoftwareSourceCode on every page, plus TechArticle and + BreadcrumbList on guides. +- **Sitemap and robots.** `app/sitemap.ts` and `app/robots.ts`. +- **Markdown for agents.** Every guide is also served as Markdown at + `/docs/<slug>.md`, and at `/docs/<slug>` for a request sending + `Accept: text/markdown` (rewrites in `next.config.mjs` to `app/md/[slug]`). + `/llms.txt` indexes them; `/llms-full.txt` is every guide in one file. The + MDX-to-Markdown transform is `lib/markdown.ts`; its test fails if a guide uses + a component the transform does not translate. +- **Copy page.** `components/CopyPage.tsx` sits beside each guide's title: it + copies the page's Markdown to the clipboard, views it, or opens ChatGPT or + Claude prefilled with a prompt to read it (the URLs are in + `lib/copy-page.ts`). + ## Analytics The site records page views and page leaves through PostHog's cookieless mode, diff --git a/site/app/(landing)/Landing.tsx b/site/app/(landing)/Landing.tsx index fd9cf8ee..ab24a4c1 100644 --- a/site/app/(landing)/Landing.tsx +++ b/site/app/(landing)/Landing.tsx @@ -16,13 +16,11 @@ import { OapMark, OAP_MARK_PATHS } from "@/components/OapMark"; import { SiteFooter } from "@/components/SiteFooter"; import { THEME_MODES, type ThemeMode } from "@/components/ThemeToggle"; import { DiscordIcon, GitHubIcon } from "@/components/BrandIcons"; -import { DISCORD_URL } from "@/lib/site"; +import { DISCORD_URL, REPO_URL } from "@/lib/site"; import { getAsset } from "@/lib/manifest"; import { GeoFigure } from "./geo/GeoFigure"; import { OWASP, COVERAGE_LABEL, coverageSummary } from "./owasp"; -const REPO = "https://github.com/authzed/openagentprimitives"; - /* The page's copy follows the repository README: its order, its emphasis and * its claims. When the README's story changes, change this page to match. */ @@ -1143,7 +1141,7 @@ export function Landing() { </a> <a href="#secure">Security</a> <a href="#agent-builder">Agent Builder</a> - <a className="lp-nav-icon" href={REPO} aria-label="GitHub"> + <a className="lp-nav-icon" href={REPO_URL} aria-label="GitHub"> <GitHubIcon /> </a> <a className="lp-nav-icon" href={DISCORD_URL} aria-label="Discord"> @@ -1443,7 +1441,7 @@ export function Landing() { <a className="lp-btn lp-btn--ghost" href="/docs/what-is-oap"> Read the docs </a> - <a className="lp-btn lp-btn--ghost" href={REPO}> + <a className="lp-btn lp-btn--ghost" href={REPO_URL}> Source </a> </div> diff --git a/site/app/(landing)/page.tsx b/site/app/(landing)/page.tsx index d2522d73..aebf1f43 100644 --- a/site/app/(landing)/page.tsx +++ b/site/app/(landing)/page.tsx @@ -1,16 +1,17 @@ import type { Metadata } from "next"; -import { OG_IMAGE, SITE_DESCRIPTION, SITE_NAME } from "@/lib/site"; +import { JsonLd } from "@/components/JsonLd"; +import { OG_IMAGE, SITE_DESCRIPTION, SITE_NAME, SITE_TITLE } from "@/lib/site"; +import { landingGraph } from "@/lib/structured-data"; import { Landing } from "./Landing"; import "./landing.css"; export const metadata: Metadata = { - title: { - absolute: "Open Agent Primitives: a secure way to run enterprise AI agents", - }, + title: { absolute: SITE_TITLE }, description: SITE_DESCRIPTION, - alternates: { canonical: "/" }, + // The docs index for LLMs and agents (app/llms.txt). + alternates: { canonical: "/", types: { "text/plain": "/llms.txt" } }, openGraph: { - title: SITE_NAME, + title: SITE_TITLE, description: SITE_DESCRIPTION, url: "/", siteName: SITE_NAME, @@ -21,5 +22,10 @@ export const metadata: Metadata = { }; export default function Page() { - return <Landing />; + return ( + <> + <JsonLd data={landingGraph()} /> + <Landing /> + </> + ); } diff --git a/site/app/docs/[slug]/opengraph-image.tsx b/site/app/docs/[slug]/opengraph-image.tsx new file mode 100644 index 00000000..e0986615 --- /dev/null +++ b/site/app/docs/[slug]/opengraph-image.tsx @@ -0,0 +1,66 @@ +import { ImageResponse } from "next/og"; +import { nbWrap, OG_INK, OG_SIZE, OgCard } from "@/components/OgCard"; +import { guideSlugs, loadGuide } from "@/lib/guides"; +import { plainText } from "@/lib/structured-data"; + +// One share card per guide, so a docs link pasted into Slack or a search +// result shows which page it is, not the site-wide card. Prerendered for +// every guide. The alt is static: Next 16 hands generateImageMetadata no +// params under a dynamic segment at build time, so it cannot name the page. +export const size = OG_SIZE; +export const contentType = "image/png"; +export const alt = "An Open Agent Primitives docs page title and summary"; + +// Like the page and its Markdown route: an unknown slug is a 404, not a +// failed import and a 500. +export const dynamicParams = false; + +export async function generateStaticParams() { + return (await guideSlugs()).map((slug) => ({ slug })); +} + +// Two lines of description at this size; longer ones end in an ellipsis. +const DESC_MAX = 150; + +export default async function GuideOgImage({ + params, +}: { + params: Promise<{ slug: string }>; +}) { + const { guide } = await loadGuide((await params).slug); + const kicker = guide.group + ? `${guide.section} · ${guide.group}` + : guide.section; + let desc = plainText(guide.description ?? ""); + if (desc.length > DESC_MAX) + desc = desc.slice(0, desc.lastIndexOf(" ", DESC_MAX)) + "…"; + return new ImageResponse( + <OgCard footer="Open Agent Primitives docs · openap.org"> + <div style={{ fontSize: 28, color: OG_INK.accent, marginBottom: 20 }}> + {nbWrap(kicker)} + </div> + <div + style={{ + fontSize: guide.title.length > 28 ? 60 : 72, + lineHeight: 1.08, + letterSpacing: "-0.03em", + }} + > + {nbWrap(guide.title)} + </div> + {desc && ( + <div + style={{ + fontSize: 30, + lineHeight: 1.35, + color: OG_INK.muted, + marginTop: 24, + }} + > + {nbWrap(desc)} + </div> + )} + </OgCard>, + size, + ); +} diff --git a/site/app/docs/[slug]/page.tsx b/site/app/docs/[slug]/page.tsx index ad7ecf89..923f25dd 100644 --- a/site/app/docs/[slug]/page.tsx +++ b/site/app/docs/[slug]/page.tsx @@ -1,6 +1,9 @@ import type { Metadata } from "next"; +import { CopyPage } from "@/components/CopyPage"; +import { JsonLd } from "@/components/JsonLd"; import { guideSlugs, loadGuide } from "@/lib/guides"; -import { OG_IMAGE, SITE_NAME } from "@/lib/site"; +import { SITE_DESCRIPTION, SITE_NAME } from "@/lib/site"; +import { guideGraph, plainText } from "@/lib/structured-data"; // Every guide is prerendered; any other slug is a real 404, not a silent // fallback to the first guide. @@ -12,30 +15,50 @@ export async function generateStaticParams() { type Props = { params: Promise<{ slug: string }> }; +// pnpm check requires a description on every guide; the fallback is only so a +// page under construction still renders. +const describe = (description?: string) => + description ? plainText(description) : SITE_DESCRIPTION; + +// The share image is the sibling opengraph-image.tsx, which Next attaches to +// openGraph and twitter on its own. export async function generateMetadata({ params }: Props): Promise<Metadata> { const { slug } = await params; const { guide } = await loadGuide(slug); const url = `/docs/${slug}`; + const description = describe(guide.description); return { title: guide.title, - description: guide.description, - alternates: { canonical: url }, + description, + alternates: { + canonical: url, + types: { "text/markdown": `${url}.md` }, + }, openGraph: { title: `${guide.title} · OAP docs`, - description: guide.description, + description, url, siteName: SITE_NAME, type: "article", - images: [OG_IMAGE], + section: guide.group || guide.section, }, - twitter: { card: "summary_large_image", images: [OG_IMAGE.url] }, + twitter: { card: "summary_large_image" }, }; } export default async function GuidePage({ params }: Props) { - const { Component, guide } = await loadGuide((await params).slug); + const { slug } = await params; + const { Component, guide } = await loadGuide(slug); return ( <div data-pagefind-meta={`title:${guide.title}`}> + <JsonLd + data={guideGraph( + guide, + describe(guide.description), + `/docs/${slug}/opengraph-image`, + )} + /> + <CopyPage slug={slug} /> <Component /> </div> ); diff --git a/site/app/layout.tsx b/site/app/layout.tsx index 2421d61b..2395e34c 100644 --- a/site/app/layout.tsx +++ b/site/app/layout.tsx @@ -2,7 +2,7 @@ import type { Metadata, Viewport } from "next"; import type { ReactNode } from "react"; import { ThemeProvider } from "next-themes"; import { Analytics } from "@/components/Analytics"; -import { SITE_DESCRIPTION, SITE_NAME, SITE_URL } from "@/lib/site"; +import { PUBLISHER, SITE_DESCRIPTION, SITE_NAME, SITE_URL } from "@/lib/site"; import iconDarkInk from "../../docs/assets/brand/oap-icon-dark.svg"; import iconLightInk from "../../docs/assets/brand/oap-icon-light.svg"; @@ -10,6 +10,23 @@ export const metadata: Metadata = { metadataBase: new URL(SITE_URL), title: { default: SITE_NAME, template: "%s · OAP docs" }, description: SITE_DESCRIPTION, + applicationName: SITE_NAME, + authors: [PUBLISHER], + creator: PUBLISHER.name, + publisher: PUBLISHER.name, + category: "technology", + // Let search show full snippets and large image previews. + robots: { + index: true, + follow: true, + googleBot: { + index: true, + follow: true, + "max-snippet": -1, + "max-image-preview": "large", + "max-video-preview": -1, + }, + }, openGraph: { siteName: SITE_NAME, type: "website", diff --git a/site/app/llms-full.txt/route.ts b/site/app/llms-full.txt/route.ts new file mode 100644 index 00000000..89645250 --- /dev/null +++ b/site/app/llms-full.txt/route.ts @@ -0,0 +1,17 @@ +import { allGuides } from "@/lib/guides"; +import { renderGuideMarkdown, textResponse } from "@/lib/markdown-routes"; +import { SITE_DESCRIPTION, SITE_NAME } from "@/lib/site"; + +// Every guide's Markdown in one file, in sidebar order, for an agent that +// wants the whole docs set in a single fetch. +export const dynamic = "force-static"; + +export async function GET() { + const pages = await Promise.all( + (await allGuides()).map((g) => renderGuideMarkdown(g.slug)), + ); + const body = [`# ${SITE_NAME}\n\n> ${SITE_DESCRIPTION}\n`, ...pages].join( + "\n---\n\n", + ); + return textResponse(body, "text/plain"); +} diff --git a/site/app/llms.txt/route.ts b/site/app/llms.txt/route.ts new file mode 100644 index 00000000..e5525b6f --- /dev/null +++ b/site/app/llms.txt/route.ts @@ -0,0 +1,17 @@ +import { allGuides } from "@/lib/guides"; +import { llmsTxt } from "@/lib/markdown"; +import { textResponse } from "@/lib/markdown-routes"; +import { buildNav } from "@/lib/nav"; +import { SITE_DESCRIPTION, SITE_NAME, SITE_URL } from "@/lib/site"; + +// The docs index for LLMs and agents (https://llmstxt.org). +export const dynamic = "force-static"; + +export async function GET() { + const body = llmsTxt(buildNav(await allGuides()), { + name: SITE_NAME, + description: SITE_DESCRIPTION, + siteUrl: SITE_URL, + }); + return textResponse(body, "text/plain"); +} diff --git a/site/app/md/[slug]/route.ts b/site/app/md/[slug]/route.ts new file mode 100644 index 00000000..23899a8a --- /dev/null +++ b/site/app/md/[slug]/route.ts @@ -0,0 +1,24 @@ +import { guideSlugs } from "@/lib/guides"; +import { renderGuideMarkdown, textResponse } from "@/lib/markdown-routes"; +import { SITE_URL } from "@/lib/site"; + +// The Markdown rendition of one guide. Reached as /docs/<slug>.md, or as +// /docs/<slug> with `Accept: text/markdown`; both are rewrites in +// next.config.mjs. Prerendered like the HTML pages. +export const dynamicParams = false; + +export async function generateStaticParams() { + return (await guideSlugs()).map((slug) => ({ slug })); +} + +export async function GET( + _req: Request, + { params }: { params: Promise<{ slug: string }> }, +) { + const { slug } = await params; + return textResponse( + await renderGuideMarkdown(slug), + "text/markdown", + `${SITE_URL}/docs/${slug}`, + ); +} diff --git a/site/app/not-found.tsx b/site/app/not-found.tsx index b9059170..a51a8f61 100644 --- a/site/app/not-found.tsx +++ b/site/app/not-found.tsx @@ -4,6 +4,9 @@ import "./not-found.css"; export const metadata: Metadata = { title: { absolute: "Page not found · Open Agent Primitives" }, + // Next adds its own noindex here, but the root layout's index/follow would + // otherwise render beside it as a second, contradicting robots tag. + robots: { index: false, follow: true }, }; // Every unknown path lands here, including stale /docs/<slug> bookmarks, so diff --git a/site/app/opengraph-image.tsx b/site/app/opengraph-image.tsx index b96132cb..51940a21 100644 --- a/site/app/opengraph-image.tsx +++ b/site/app/opengraph-image.tsx @@ -1,55 +1,31 @@ import { ImageResponse } from "next/og"; +import { nb, OG_INK, OG_SIZE, OgCard } from "@/components/OgCard"; import { OG_IMAGE } from "@/lib/site"; // The share card for every page that does not ship its own. Rendered once at -// build time. The logomark geometry is the same as components/OapMark.tsx. +// build time. export const alt = OG_IMAGE.alt; -export const size = { width: 1200, height: 630 }; +export const size = OG_SIZE; export const contentType = "image/png"; -// The renderer's default font sets an ordinary space far wider than the -// non-breaking one, so the headline's spaces are swapped before rendering. -const nb = (text: string) => text.replaceAll(" ", "\u00a0"); - export default function OpengraphImage() { return new ImageResponse( - <div - style={{ - width: "100%", - height: "100%", - display: "flex", - flexDirection: "column", - justifyContent: "space-between", - background: "#0c050f", - color: "#f8f7f8", - padding: "72px 80px", - }} - > - <svg width="180" height="71" viewBox="0 0 1000 393" fill="#f1f0f2"> - <path d="M160.219 0C248.705 2.34906e-05 320.437 71.7324 320.438 160.219C320.437 248.705 248.705 320.437 160.219 320.438C71.7324 320.438 2.35732e-05 248.705 0 160.219C-1.21838e-05 71.7324 71.7324 1.1051e-05 160.219 0ZM170.219 90.417C164.031 86.8444 156.407 86.8445 150.219 90.417L104.769 116.658C98.5808 120.231 94.7686 126.833 94.7686 133.979V186.459C94.7686 193.604 98.5808 200.207 104.769 203.779L150.219 230.021C156.407 233.593 164.031 233.593 170.219 230.021L215.669 203.779C221.857 200.207 225.669 193.604 225.669 186.459V133.979C225.669 126.833 221.857 120.231 215.669 116.658L170.219 90.417Z" /> - <path d="M478.478 19.8457C484.286 9.71696 498.895 9.71695 504.703 19.8457L661.864 293.951C667.642 304.028 660.367 316.585 648.752 316.585H334.43C322.814 316.585 315.54 304.028 321.317 293.951L478.478 19.8457ZM498.147 145.475C495.244 140.41 487.939 140.41 485.035 145.475L436.547 230.043C433.658 235.081 437.295 241.359 443.102 241.359H540.079C545.887 241.359 549.524 235.081 546.636 230.043L498.147 145.475Z" /> - <path d="M977.327 12.2494C989.849 12.2494 1000 22.3997 1000 34.9213V278.912C1000 291.434 989.849 301.585 977.327 301.585H826.933C814.412 301.586 804.262 311.736 804.262 324.257V369.663C804.262 382.185 794.11 392.336 781.589 392.336H721.129C708.607 392.336 698.457 382.185 698.457 369.663V34.9213C698.457 22.3998 708.607 12.2496 721.129 12.2494H977.327ZM762.693 143.302C762.693 151.65 769.46 158.417 777.808 158.417H860.646C868.993 158.417 875.761 165.185 875.761 173.532V208.245C875.761 216.593 882.528 223.36 890.876 223.36H920.646C928.993 223.36 935.761 216.593 935.761 208.245V113.532C935.761 105.185 928.993 98.4174 920.646 98.4174H777.808C769.46 98.4174 762.693 105.185 762.693 113.532V143.302Z" /> - </svg> - <div style={{ display: "flex", flexDirection: "column" }}> - <div - style={{ - fontSize: 76, - lineHeight: 1.05, - letterSpacing: "-0.03em", - display: "flex", - flexDirection: "column", - }} - > - <div style={{ display: "flex" }}>{nb("A secure way to run")}</div> - <div style={{ display: "flex", color: "#72b1ad" }}> - {nb("enterprise AI agents.")} - </div> - </div> - <div style={{ fontSize: 30, color: "#9a94a0", marginTop: 28 }}> - Open Agent Primitives · openap.org + <OgCard footer="Open Agent Primitives · openap.org"> + <div + style={{ + fontSize: 76, + lineHeight: 1.05, + letterSpacing: "-0.03em", + display: "flex", + flexDirection: "column", + }} + > + <div style={{ display: "flex" }}>{nb("A secure way to run")}</div> + <div style={{ display: "flex", color: OG_INK.accent }}> + {nb("enterprise AI agents.")} </div> </div> - </div>, + </OgCard>, size, ); } diff --git a/site/components/CopyPage.tsx b/site/components/CopyPage.tsx new file mode 100644 index 00000000..54ed850f --- /dev/null +++ b/site/components/CopyPage.tsx @@ -0,0 +1,265 @@ +"use client"; +import { + useEffect, + useRef, + useState, + type KeyboardEvent as ReactKeyboardEvent, + type ReactNode, +} from "react"; +import { chatLinks, markdownPath } from "@/lib/copy-page"; +import { SITE_URL } from "@/lib/site"; +import "./copy-page.css"; + +type CopyState = "idle" | "copied" | "failed"; + +// How long "Copied" / "Copy failed" shows before the button resets. +const FEEDBACK_MS = 2000; + +const ARROW_STEP: Record<string, number> = { ArrowDown: 1, ArrowUp: -1 }; + +// Copies the guide's Markdown rendition. The text is fetched after the click, +// and Safari drops the click's permission to write the clipboard across an +// await, so where ClipboardItem exists the fetch goes INTO the clipboard write +// as a promise rather than ahead of it. +async function copyMarkdown(path: string): Promise<void> { + const text = fetch(path).then((res) => { + if (!res.ok) throw new Error(`GET ${path}: ${res.status}`); + return res.text(); + }); + if (typeof ClipboardItem !== "undefined" && navigator.clipboard?.write) { + const blob = text.then((t) => new Blob([t], { type: "text/plain" })); + await navigator.clipboard.write([ + new ClipboardItem({ "text/plain": blob }), + ]); + return; + } + await navigator.clipboard.writeText(await text); +} + +/* The docs page's "Copy page" split button: the main half copies the page as + * Markdown; the chevron opens a menu that also views the Markdown or opens + * the page in an AI assistant. */ +export function CopyPage({ slug }: { slug: string }) { + const [state, setState] = useState<CopyState>("idle"); + const [open, setOpen] = useState(false); + const root = useRef<HTMLDivElement>(null); + const toggle = useRef<HTMLButtonElement>(null); + const items = useRef<(HTMLElement | null)[]>([]); + + const path = markdownPath(slug); + const links = chatLinks(SITE_URL + path); + + useEffect(() => { + if (state === "idle") return; + const t = setTimeout(() => setState("idle"), FEEDBACK_MS); + return () => clearTimeout(t); + }, [state]); + + useEffect(() => { + if (!open) return; + items.current[0]?.focus(); + const onPointerDown = (e: PointerEvent) => { + if (!root.current?.contains(e.target as Node)) setOpen(false); + }; + const onKeyDown = (e: KeyboardEvent) => { + if (e.key !== "Escape") return; + setOpen(false); + toggle.current?.focus(); + }; + document.addEventListener("pointerdown", onPointerDown); + document.addEventListener("keydown", onKeyDown); + return () => { + document.removeEventListener("pointerdown", onPointerDown); + document.removeEventListener("keydown", onKeyDown); + }; + }, [open]); + + const copy = async () => { + setOpen(false); + try { + await copyMarkdown(path); + setState("copied"); + } catch (err) { + console.error("docs copy page: copy failed", { path, err }); + setState("failed"); + } + }; + + const onMenuKeyDown = (e: ReactKeyboardEvent<HTMLDivElement>) => { + const step = ARROW_STEP[e.key]; + if (!step) return; + e.preventDefault(); + const n = items.current.length; + const at = items.current.findIndex((el) => el === document.activeElement); + items.current[(at + step + n) % n]?.focus(); + }; + + const label = + state === "copied" + ? "Copied" + : state === "failed" + ? "Copy failed" + : "Copy page"; + + const item = (i: number) => (el: HTMLElement | null) => { + items.current[i] = el; + }; + + return ( + <div className="copy-page" ref={root} data-pagefind-ignore> + <div className="copy-page-split"> + <button type="button" className="copy-page-main" onClick={copy}> + {state === "copied" ? <CheckIcon /> : <CopyIcon />} + <span aria-live="polite">{label}</span> + </button> + <button + ref={toggle} + type="button" + className="copy-page-toggle" + aria-haspopup="menu" + aria-expanded={open} + aria-label="More ways to use this page" + onClick={() => setOpen((o) => !o)} + > + <ChevronIcon /> + </button> + </div> + {open && ( + <div + className="copy-page-menu" + role="menu" + aria-label="Use this page" + onKeyDown={onMenuKeyDown} + > + <button + ref={item(0)} + type="button" + role="menuitem" + className="copy-page-item" + onClick={copy} + > + <MenuRow icon={<CopyIcon />} title="Copy page"> + Copy this page as Markdown for LLMs + </MenuRow> + </button> + <a + ref={item(1)} + role="menuitem" + className="copy-page-item" + href={path} + type="text/markdown" + onClick={() => setOpen(false)} + > + <MenuRow icon={<DocIcon />} title="View as Markdown"> + Open this page as plain text + </MenuRow> + </a> + {links.map((l, i) => ( + <a + key={l.id} + ref={item(i + 2)} + role="menuitem" + className="copy-page-item" + href={l.href} + target="_blank" + rel="noopener noreferrer" + onClick={() => setOpen(false)} + > + <MenuRow icon={<ChatIcon />} title={l.label} external> + Ask questions about this page + </MenuRow> + </a> + ))} + </div> + )} + </div> + ); +} + +function MenuRow({ + icon, + title, + external, + children, +}: { + icon: ReactNode; + title: string; + external?: boolean; + children: ReactNode; +}) { + return ( + <> + <span className="copy-page-item-icon">{icon}</span> + <span className="copy-page-item-text"> + <span className="copy-page-item-title"> + {title} + {external && <ExternalIcon />} + {external && ( + <span className="copy-page-sr"> (opens in a new tab)</span> + )} + </span> + <span className="copy-page-item-desc">{children}</span> + </span> + </> + ); +} + +const svg = { + viewBox: "0 0 16 16", + fill: "none", + stroke: "currentColor", + strokeWidth: 1.5, + strokeLinecap: "round", + strokeLinejoin: "round", + "aria-hidden": true, +} as const; + +function CopyIcon() { + return ( + <svg {...svg}> + <rect x="5.5" y="5.5" width="8" height="8" rx="1.5" /> + <path d="M3.5 10.5h-.5A1.5 1.5 0 0 1 1.5 9V3A1.5 1.5 0 0 1 3 1.5h6A1.5 1.5 0 0 1 10.5 3v.5" /> + </svg> + ); +} + +function CheckIcon() { + return ( + <svg {...svg}> + <path d="M3 8.5l3 3 7-7" /> + </svg> + ); +} + +function ChevronIcon() { + return ( + <svg {...svg}> + <path d="M4 6l4 4 4-4" /> + </svg> + ); +} + +function DocIcon() { + return ( + <svg {...svg}> + <path d="M9.5 1.5H4A1.5 1.5 0 0 0 2.5 3v10A1.5 1.5 0 0 0 4 14.5h8a1.5 1.5 0 0 0 1.5-1.5V5.5z" /> + <path d="M9.5 1.5v4h4M5.5 9h5M5.5 11.5h3" /> + </svg> + ); +} + +function ChatIcon() { + return ( + <svg {...svg}> + <path d="M2.5 4A1.5 1.5 0 0 1 4 2.5h8A1.5 1.5 0 0 1 13.5 4v6a1.5 1.5 0 0 1-1.5 1.5H7l-3 2.5v-2.5A1.5 1.5 0 0 1 2.5 10z" /> + </svg> + ); +} + +function ExternalIcon() { + return ( + <svg {...svg} className="copy-page-ext"> + <path d="M6 3.5H3.5v9h9V10M9 2.5h4.5V7M13.5 2.5L7.5 8.5" /> + </svg> + ); +} diff --git a/site/components/Coverage.tsx b/site/components/Coverage.tsx index 765aba97..f9b4c996 100644 --- a/site/components/Coverage.tsx +++ b/site/components/Coverage.tsx @@ -1,5 +1,5 @@ // A coverage badge for the OWASP page — the four levels the coverage doc uses. -const LABELS: Record<string, string> = { +export const COVERAGE_LABELS: Record<string, string> = { substantial: "Substantial", partial: "Partial", minimal: "Minimal", @@ -9,7 +9,7 @@ const LABELS: Record<string, string> = { export function Coverage({ level }: { level: string }) { return ( <span className={`doc-cov doc-cov--${level}`}> - {LABELS[level] ?? level} + {COVERAGE_LABELS[level] ?? level} </span> ); } diff --git a/site/components/JsonLd.tsx b/site/components/JsonLd.tsx new file mode 100644 index 00000000..9afdd2b5 --- /dev/null +++ b/site/components/JsonLd.tsx @@ -0,0 +1,13 @@ +// A schema.org JSON-LD block (lib/structured-data.ts). JSON.stringify leaves +// "<" alone, so a description containing "</script>" would end the element +// early; escaping it keeps the payload inert. +export function JsonLd({ data }: { data: object }) { + return ( + <script + type="application/ld+json" + dangerouslySetInnerHTML={{ + __html: JSON.stringify(data).replace(/</g, "\\u003c"), + }} + /> + ); +} diff --git a/site/components/OgCard.tsx b/site/components/OgCard.tsx new file mode 100644 index 00000000..badf2b67 --- /dev/null +++ b/site/components/OgCard.tsx @@ -0,0 +1,56 @@ +import type { ReactNode } from "react"; +import { OAP_MARK_PATHS } from "./OapMark"; + +// The share-card layout behind app/opengraph-image.tsx (the site card) and +// app/docs/[slug]/opengraph-image.tsx (one per guide). Rendered by next/og at +// build time, which supports only flexbox and inline styles. +export const OG_SIZE = { width: 1200, height: 630 }; + +export const OG_INK = { + bg: "#0c050f", + text: "#f8f7f8", + mark: "#f1f0f2", + accent: "#72b1ad", + muted: "#9a94a0", +}; + +// The renderer's default font sets an ordinary space far wider than the +// non-breaking one. Where a line must still wrap, a zero-width space after +// each non-breaking one keeps a break opportunity. +export const nb = (text: string) => text.replaceAll(" ", " "); +export const nbWrap = (text: string) => text.replaceAll(" ", " ​"); + +export function OgCard({ + children, + footer, +}: { + children: ReactNode; + footer: string; +}) { + return ( + <div + style={{ + width: "100%", + height: "100%", + display: "flex", + flexDirection: "column", + justifyContent: "space-between", + background: OG_INK.bg, + color: OG_INK.text, + padding: "72px 80px", + }} + > + <svg width="180" height="71" viewBox="0 0 1000 393" fill={OG_INK.mark}> + {OAP_MARK_PATHS.map((d) => ( + <path key={d} d={d} /> + ))} + </svg> + <div style={{ display: "flex", flexDirection: "column" }}> + {children} + <div style={{ fontSize: 30, color: OG_INK.muted, marginTop: 28 }}> + {nb(footer)} + </div> + </div> + </div> + ); +} diff --git a/site/components/SiteFooter.tsx b/site/components/SiteFooter.tsx index f74ba64d..a0958c64 100644 --- a/site/components/SiteFooter.tsx +++ b/site/components/SiteFooter.tsx @@ -25,6 +25,7 @@ export function SiteFooter() { <a href="/docs/cli-reference">CLI reference</a> <a href="/#owasp">OWASP coverage</a> <a href={DISCORD_URL}>Community</a> + <a href="/llms.txt">llms.txt</a> </div> <div className="site-footer-bottom"> <p className="site-footer-credit"> diff --git a/site/components/copy-page.css b/site/components/copy-page.css new file mode 100644 index 00000000..34894f1f --- /dev/null +++ b/site/components/copy-page.css @@ -0,0 +1,136 @@ +/* The docs "Copy page" split button (CopyPage.tsx). Floats beside the page's + h1, which is the first thing in every guide. Colours are the docs tokens, + so it follows the light/dark theme with the rest of the page. */ +.copy-page { + position: relative; + float: right; + margin: 4px 0 12px 16px; + font-size: 13px; +} + +.copy-page-split { + display: inline-flex; + align-items: stretch; + border: 1px solid var(--doc-border); + border-radius: 6px; + overflow: hidden; + background: var(--doc-panel); +} + +.copy-page-main, +.copy-page-toggle { + display: inline-flex; + align-items: center; + border: 0; + background: transparent; + color: var(--doc-text); + font: inherit; + cursor: pointer; +} + +.copy-page-main { + gap: 6px; + padding: 5px 10px; + font-weight: 500; +} + +.copy-page-toggle { + padding: 5px 7px; + border-left: 1px solid var(--doc-border); + color: var(--doc-muted); +} + +.copy-page-main:hover, +.copy-page-toggle:hover, +.copy-page-toggle[aria-expanded="true"] { + color: var(--doc-heading); + background: var(--doc-accent-soft); +} + +.copy-page svg { + width: 14px; + height: 14px; + flex: none; +} + +.copy-page-main:focus-visible, +.copy-page-toggle:focus-visible, +.copy-page-item:focus-visible { + outline: 2px solid var(--doc-accent); + outline-offset: -2px; +} + +.copy-page-menu { + position: absolute; + top: calc(100% + 6px); + right: 0; + z-index: 30; + display: grid; + width: max-content; + max-width: min(300px, calc(100vw - 32px)); + padding: 4px; + border: 1px solid var(--doc-border); + border-radius: 8px; + background: var(--doc-panel); + box-shadow: 0 8px 24px var(--doc-shadow); +} + +/* .doc-article a underlines links; menu rows are not prose links. */ +.doc-article .copy-page-item, +.copy-page-item { + display: flex; + gap: 10px; + padding: 8px 10px; + border: 0; + border-radius: 5px; + background: transparent; + color: var(--doc-text); + font: inherit; + text-align: left; + text-decoration: none; + cursor: pointer; +} + +.copy-page-item:hover, +.copy-page-item:focus-visible { + background: var(--doc-accent-soft); +} + +.copy-page-item-icon { + display: flex; + padding-top: 2px; + color: var(--doc-muted); +} + +.copy-page-item-text { + display: grid; + gap: 2px; +} + +.copy-page-item-title { + display: inline-flex; + align-items: center; + gap: 4px; + color: var(--doc-heading); + font-weight: 500; +} + +.copy-page-item-desc { + color: var(--doc-muted); + font-size: 12px; +} + +.copy-page .copy-page-ext { + width: 11px; + height: 11px; + color: var(--doc-muted); +} + +.copy-page-sr { + position: absolute; + width: 1px; + height: 1px; + overflow: hidden; + clip: rect(0 0 0 0); + white-space: nowrap; +} diff --git a/site/content/docs/crd-agentclass.mdx b/site/content/docs/crd-agentclass.mdx index 5d83da63..4a0f5e06 100644 --- a/site/content/docs/crd-agentclass.mdx +++ b/site/content/docs/crd-agentclass.mdx @@ -3,7 +3,7 @@ export const meta = { section: 'Reference', group: 'CRD reference', order: 3010, - description: 'AgentClass is the reviewable template an agent is defined by: model, system', + description: 'AgentClass is the reviewable template an agent is defined by: model, system prompt, tools, skills, identity, channels, budget and authz policy.', } # AgentClass diff --git a/site/content/docs/crd-agentidentity.mdx b/site/content/docs/crd-agentidentity.mdx index 5ed61ce9..876d8c50 100644 --- a/site/content/docs/crd-agentidentity.mdx +++ b/site/content/docs/crd-agentidentity.mdx @@ -3,7 +3,7 @@ export const meta = { section: 'Reference', group: 'CRD reference', order: 3020, - description: 'AgentIdentity is the named credential set an agent acts as: reusable static', + description: 'AgentIdentity is the named credential set an agent acts as: reusable static and OAuth credential descriptors that toolkits and MCP servers request by name.', } # AgentIdentity diff --git a/site/content/docs/crd-agentsession.mdx b/site/content/docs/crd-agentsession.mdx index 72860f42..74b685d1 100644 --- a/site/content/docs/crd-agentsession.mdx +++ b/site/content/docs/crd-agentsession.mdx @@ -3,7 +3,7 @@ export const meta = { section: 'Reference', group: 'CRD reference', order: 3030, - description: 'AgentSession is one running instance of an AgentClass: a conversation with', + description: 'AgentSession is one running instance of an AgentClass: a conversation with its own runner Pod, budget, channel bindings and lifecycle phase.', } # AgentSession @@ -271,21 +271,41 @@ Status is controller-owned (observed state). | `status.effectiveSettings.toolGuard.namespace.rules[].rateLimit.maxCallsPerTurn` | `integer (int32)` | MaxCallsPerTurn caps calls within a single agent turn; 0 is unlimited. _(min 1)_ | | `status.effectiveSettings.toolGuard.namespace.rules[].rateLimit.window` | `string` | Window is the sliding-window span for MaxCalls; both must be set together. | | `status.estimatedCost` | `object` | EstimatedCost is the best-effort end-of-session USD cost estimate. Runner-owned; written at SessionEnd when reportSessionCost is on. | -| `status.estimatedCost.amountMicroUSD` | `integer (int64)` | AmountMicroUSD is the session total in micro-USD (1e-6 USD): the sum of every priced component (ByModel + ByTool). When PricingKnown is false, at least one served MODEL had no rate, so this is a LOWER BOUND on spend (the components that could be priced), not the full session cost — it is no longer necessarily 0, because provider-reported tool cost is always priced. | +| `status.estimatedCost.amountMicroUSD` | `integer (int64)` | AmountMicroUSD is the SESSION total in micro-USD (1e-6 USD), accumulated across every pod that served the session: the sum of PerPod amounts. When PricingKnown is false, at least one served MODEL (in some pod) had no rate, so this is a LOWER BOUND on spend (the components that could be priced), not the full session cost — it is no longer necessarily 0, because provider-reported tool cost is always priced. | | `status.estimatedCost.asOf` | `string (date-time)` | AsOf is when the estimate was computed. | -| `status.estimatedCost.byModel` | `[]object` | ByModel breaks the total down per actually-served model, so a session that fell back or auto-routed across models shows where the spend went instead of one blended total under the configured model. Empty when the runner reported no per-model usage; AmountMicroUSD and PricingKnown are then the sole source of truth. | +| `status.estimatedCost.byModel` | `[]object` | ByModel breaks the total down per actually-served model, MERGED across every pod (summed per model display id), so a session that fell back or auto-routed across models — or ran across a reap/wake — shows where the spend went instead of one blended total under the configured model. Empty when no pod reported per-model usage; AmountMicroUSD and PricingKnown are then the sole source of truth. | | `status.estimatedCost.byModel[].amountMicroUSD` | `integer (int64)` | AmountMicroUSD is this bucket's share of the session cost, in micro-USD (1e-6 USD): the provider-reported cost when available, else tokens × the resolved per-model pricing table rate. | | `status.estimatedCost.byModel[].inputTokens` | `integer (int64)` | InputTokens is this model's share of prompt tokens. | | `status.estimatedCost.byModel[].model` \* | `string` | Model is the served-model display id verbatim (provider/model, or a provider-prefixed served model under routing) — never re-parsed here. | | `status.estimatedCost.byModel[].outputTokens` | `integer (int64)` | OutputTokens is this model's share of completion tokens. | | `status.estimatedCost.byModel[].pricingKnown` | `boolean` | PricingKnown is true when AmountMicroUSD is a real cost — either the provider reported it directly, or the pricing table had a rate for this model. False means AmountMicroUSD is 0 and must not be shown as real spend (same no-fabrication contract as EstimatedSessionCost). | -| `status.estimatedCost.byTool` | `[]object` | ByTool breaks out the cost of inner interactive toolkits (e.g. a `claude` Claude Code sub-run's own provider-reported spend) a session drove. These are ADDED to AmountMicroUSD alongside ByModel — a session's total is model spend plus tool spend. Empty when no interactive toolkit reported a cost. Tool cost is always provider-reported, never table-priced, so a bucket's PricingKnown is always true. | -| `status.estimatedCost.byTool[].amountMicroUSD` | `integer (int64)` | AmountMicroUSD is this tool's provider-reported cost, summed across its invocations, in micro-USD (1e-6 USD). | -| `status.estimatedCost.byTool[].pricingKnown` | `boolean` | PricingKnown is true when AmountMicroUSD is a real cost. Tool cost is always provider-reported, so this is true whenever a cost was reported. | +| `status.estimatedCost.byTool` | `[]object` | ByTool breaks out the cost of inner interactive toolkits (e.g. a `claude` Claude Code sub-run's own provider-reported spend) a session drove, MERGED across every pod (summed per tool). These are ADDED to AmountMicroUSD alongside ByModel — a session's total is model spend plus tool spend. Empty when no interactive toolkit reported a cost. | +| `status.estimatedCost.byTool[].amountMicroUSD` | `integer (int64)` | AmountMicroUSD is this tool's provider-reported cost, summed across its invocations, in micro-USD (1e-6 USD). Negative reports are excluded from the sum (see Anomalous). | +| `status.estimatedCost.byTool[].anomalous` | `boolean` | Anomalous is true when a negative (nonsensical) provider cost was reported for this tool. The tool is still recorded as seen, but the negative value is excluded from AmountMicroUSD and the session total — it signals "a cost report here does not make sense" rather than silently dropping it. | +| `status.estimatedCost.byTool[].pricingKnown` | `boolean` | PricingKnown is true when AmountMicroUSD is a real cost — i.e. at least one non-negative cost tally was reported. False when none was reported (the amount is 0 and must not be shown as real spend) or every report was anomalous. | +| `status.estimatedCost.byTool[].subscriptionEquivalent` | `boolean` | SubscriptionEquivalent is true when this tool ran under a flat-rate subscription (toolkit CostTreatment=subscription-equivalent), so its AmountMicroUSD is an API-equivalent estimate, not money charged. The bucket is recorded and shown, but its amount is EXCLUDED from the session total / admind spend so it cannot inflate the figure or trip cost alerts. | | `status.estimatedCost.byTool[].tool` \* | `string` | Tool is the outer tool name (e.g. "claude-oauth"). | | `status.estimatedCost.currency` | `string` | Currency is the ISO code the amount is denominated in ("USD"). | | `status.estimatedCost.model` | `string` | Model is the configured model the session ran under. | -| `status.estimatedCost.pricingKnown` | `boolean` | PricingKnown is false when a served model had no price. The amount is then a lower bound (priced components only), not the full cost. Tool buckets are always priced, so this tracks model-pricing coverage specifically. | +| `status.estimatedCost.perPod` | `[]object` | PerPod is the per-pod cost SOURCE OF TRUTH: one entry per pod that served the session (keyed by pod name), each carrying that pod's own total and breakdown. The aggregate fields above are DERIVED from this list — the total is the sum of PerPod amounts, ByModel/ByTool are merged by key, and PricingKnown is the AND. Each pod upserts only its own entry, so cost ACCUMULATES across a reap/wake instead of a later pod's single-pod total replacing an earlier one. Empty only for a session no pod has costed yet. | +| `status.estimatedCost.perPod[].amountMicroUSD` | `integer (int64)` | AmountMicroUSD is this pod's own total in micro-USD (model + tool). | +| `status.estimatedCost.perPod[].asOf` | `string (date-time)` | AsOf is when this pod last computed its estimate. | +| `status.estimatedCost.perPod[].byModel` | `[]object` | ByModel is this pod's per-served-model breakdown. | +| `status.estimatedCost.perPod[].byModel[].amountMicroUSD` | `integer (int64)` | AmountMicroUSD is this bucket's share of the session cost, in micro-USD (1e-6 USD): the provider-reported cost when available, else tokens × the resolved per-model pricing table rate. | +| `status.estimatedCost.perPod[].byModel[].inputTokens` | `integer (int64)` | InputTokens is this model's share of prompt tokens. | +| `status.estimatedCost.perPod[].byModel[].model` \* | `string` | Model is the served-model display id verbatim (provider/model, or a provider-prefixed served model under routing) — never re-parsed here. | +| `status.estimatedCost.perPod[].byModel[].outputTokens` | `integer (int64)` | OutputTokens is this model's share of completion tokens. | +| `status.estimatedCost.perPod[].byModel[].pricingKnown` | `boolean` | PricingKnown is true when AmountMicroUSD is a real cost — either the provider reported it directly, or the pricing table had a rate for this model. False means AmountMicroUSD is 0 and must not be shown as real spend (same no-fabrication contract as EstimatedSessionCost). | +| `status.estimatedCost.perPod[].byTool` | `[]object` | ByTool is this pod's per-interactive-toolkit breakdown. | +| `status.estimatedCost.perPod[].byTool[].amountMicroUSD` | `integer (int64)` | AmountMicroUSD is this tool's provider-reported cost, summed across its invocations, in micro-USD (1e-6 USD). Negative reports are excluded from the sum (see Anomalous). | +| `status.estimatedCost.perPod[].byTool[].anomalous` | `boolean` | Anomalous is true when a negative (nonsensical) provider cost was reported for this tool. The tool is still recorded as seen, but the negative value is excluded from AmountMicroUSD and the session total — it signals "a cost report here does not make sense" rather than silently dropping it. | +| `status.estimatedCost.perPod[].byTool[].pricingKnown` | `boolean` | PricingKnown is true when AmountMicroUSD is a real cost — i.e. at least one non-negative cost tally was reported. False when none was reported (the amount is 0 and must not be shown as real spend) or every report was anomalous. | +| `status.estimatedCost.perPod[].byTool[].subscriptionEquivalent` | `boolean` | SubscriptionEquivalent is true when this tool ran under a flat-rate subscription (toolkit CostTreatment=subscription-equivalent), so its AmountMicroUSD is an API-equivalent estimate, not money charged. The bucket is recorded and shown, but its amount is EXCLUDED from the session total / admind spend so it cannot inflate the figure or trip cost alerts. | +| `status.estimatedCost.perPod[].byTool[].tool` \* | `string` | Tool is the outer tool name (e.g. "claude-oauth"). | +| `status.estimatedCost.perPod[].currency` | `string` | Currency is the ISO code this pod's amount is denominated in ("USD"). | +| `status.estimatedCost.perPod[].pod` \* | `string` | Pod is the serving pod's name (its HOSTNAME); the upsert key. | +| `status.estimatedCost.perPod[].pricingKnown` | `boolean` | PricingKnown is false when a model this pod served had no rate (this pod's amount is then a lower bound). | +| `status.estimatedCost.pricingKnown` | `boolean` | PricingKnown is false when a served model had no price in ANY contributing pod (the AND across PerPod). The amount is then a lower bound (priced components only), not the full cost. Tool buckets are always priced, so this tracks model-pricing coverage specifically. | | `status.failureReason` | `string` | FailureReason is the human-readable reason phase became Failed. | | `status.finishedAt` | `string (date-time)` | FinishedAt is when the session reached a terminal phase. | | `status.identityChoiceParkedAt` | `string (date-time)` | IdentityChoiceParkedAt is when the session entered AwaitingIdentityChoice. The operator measures IdentityChoiceTimeout from here. | diff --git a/site/content/docs/crd-agentsessiongrants.mdx b/site/content/docs/crd-agentsessiongrants.mdx index f2749d1a..b47ccad1 100644 --- a/site/content/docs/crd-agentsessiongrants.mdx +++ b/site/content/docs/crd-agentsessiongrants.mdx @@ -3,7 +3,7 @@ export const meta = { section: 'Reference', group: 'CRD reference', order: 3040, - description: 'AgentSessionGrants is the per-AgentClass declaration of the (resourceType,', + description: 'AgentSessionGrants is the per-AgentClass declaration of the (resourceType, permission) pairs that must exist as grant_<perm>_<resType> relations on the SpiceDB…', } # AgentSessionGrants diff --git a/site/content/docs/crd-agentsettings.mdx b/site/content/docs/crd-agentsettings.mdx index 9d2157b4..1708b8ad 100644 --- a/site/content/docs/crd-agentsettings.mdx +++ b/site/content/docs/crd-agentsettings.mdx @@ -3,7 +3,7 @@ export const meta = { section: 'Reference', group: 'CRD reference', order: 3050, - description: 'AgentSettings is the namespace tier of agent governance settings: limits', + description: 'AgentSettings is the namespace tier of agent governance settings: limits that narrow what agents in this namespace may do, and defaults that fill in fields a…', } # AgentSettings diff --git a/site/content/docs/crd-agentui.mdx b/site/content/docs/crd-agentui.mdx index 996744d9..7e8e24a0 100644 --- a/site/content/docs/crd-agentui.mdx +++ b/site/content/docs/crd-agentui.mdx @@ -3,7 +3,7 @@ export const meta = { section: 'Reference', group: 'CRD reference', order: 3060, - description: 'AgentUI is a bundle-authored DECLARATION of an agent-defined view: a page', + description: 'AgentUI is a bundle-authored DECLARATION of an agent-defined view: a page tree in which generative hooks are the agent\'s regions, plus the tools and actions…', } # AgentUI diff --git a/site/content/docs/crd-artifactrender.mdx b/site/content/docs/crd-artifactrender.mdx index 250e09c8..6e393dbc 100644 --- a/site/content/docs/crd-artifactrender.mdx +++ b/site/content/docs/crd-artifactrender.mdx @@ -3,7 +3,7 @@ export const meta = { section: 'Reference', group: 'CRD reference', order: 3070, - description: 'ArtifactRender is one request to turn agent-supplied bytes into a', + description: 'ArtifactRender is one request to turn agent-supplied bytes into a deliverable artifact -- an HTML page, an SVG, an image -- through a named renderer plug-in.', } # ArtifactRender diff --git a/site/content/docs/crd-channel.mdx b/site/content/docs/crd-channel.mdx index 4f420401..bb3600ee 100644 --- a/site/content/docs/crd-channel.mdx +++ b/site/content/docs/crd-channel.mdx @@ -3,7 +3,7 @@ export const meta = { section: 'Reference', group: 'CRD reference', order: 3080, - description: 'Channel binds one transport conversation -- a Slack channel, a scheduled', + description: 'Channel binds one transport conversation -- a Slack channel, a scheduled Bento feed, a terminal, a browser view -- to one AgentClass, and declares how inbound…', } # Channel diff --git a/site/content/docs/crd-clusteragentsettings.mdx b/site/content/docs/crd-clusteragentsettings.mdx index 022a414d..e6f57ec2 100644 --- a/site/content/docs/crd-clusteragentsettings.mdx +++ b/site/content/docs/crd-clusteragentsettings.mdx @@ -3,7 +3,7 @@ export const meta = { section: 'Reference', group: 'CRD reference', order: 3090, - description: 'ClusterAgentSettings is the cluster-wide top tier of agent governance', + description: 'ClusterAgentSettings is the cluster-wide top tier of agent governance settings: the limits and defaults every namespace inherits, plus the model catalog.', } # ClusterAgentSettings diff --git a/site/content/docs/crd-clusteridentityprovider.mdx b/site/content/docs/crd-clusteridentityprovider.mdx index 4dc9e458..c32c3712 100644 --- a/site/content/docs/crd-clusteridentityprovider.mdx +++ b/site/content/docs/crd-clusteridentityprovider.mdx @@ -3,7 +3,7 @@ export const meta = { section: 'Reference', group: 'CRD reference', order: 3100, - description: 'ClusterIdentityProvider configures cluster-wide human login: which', + description: 'ClusterIdentityProvider configures cluster-wide human login: which registered idp.Kind authenticates people (pkg/platform/identity/idp), its OIDC client…', } # ClusterIdentityProvider diff --git a/site/content/docs/crd-clusterskill.mdx b/site/content/docs/crd-clusterskill.mdx index 6eb8b263..702592f9 100644 --- a/site/content/docs/crd-clusterskill.mdx +++ b/site/content/docs/crd-clusterskill.mdx @@ -3,7 +3,7 @@ export const meta = { section: 'Reference', group: 'CRD reference', order: 3110, - description: 'ClusterSkill is a cluster-scoped Skill, visible to every namespace. A', + description: 'ClusterSkill is a cluster-scoped Skill, visible to every namespace.', } # ClusterSkill diff --git a/site/content/docs/crd-clusterskillsource.mdx b/site/content/docs/crd-clusterskillsource.mdx index 296681df..422982a9 100644 --- a/site/content/docs/crd-clusterskillsource.mdx +++ b/site/content/docs/crd-clusterskillsource.mdx @@ -3,7 +3,7 @@ export const meta = { section: 'Reference', group: 'CRD reference', order: 3120, - description: 'ClusterSkillSource is a git repo that materializes cluster-scoped', + description: 'ClusterSkillSource is a git repo that materializes cluster-scoped ClusterSkills.', } # ClusterSkillSource diff --git a/site/content/docs/crd-credentialupdaterequest.mdx b/site/content/docs/crd-credentialupdaterequest.mdx index 874e6ff0..3a49acff 100644 --- a/site/content/docs/crd-credentialupdaterequest.mdx +++ b/site/content/docs/crd-credentialupdaterequest.mdx @@ -3,7 +3,7 @@ export const meta = { section: 'Reference', group: 'CRD reference', order: 3130, - description: 'CredentialUpdateRequest is an agent\'s REQUEST that a human replace a', + description: 'CredentialUpdateRequest is an agent\'s REQUEST that a human replace a credential it believes has stopped authenticating.', } # CredentialUpdateRequest diff --git a/site/content/docs/crd-mcpserver.mdx b/site/content/docs/crd-mcpserver.mdx index 5c547a18..1420ec51 100644 --- a/site/content/docs/crd-mcpserver.mdx +++ b/site/content/docs/crd-mcpserver.mdx @@ -3,7 +3,7 @@ export const meta = { section: 'Reference', group: 'CRD reference', order: 3140, - description: 'MCPServer declares one MCP endpoint an agent may call: where it lives', + description: 'MCPServer declares one MCP endpoint an agent may call: where it lives (spec.server), the allowlisted tools, per-tool CEL argument constraints, the credential…', } # MCPServer diff --git a/site/content/docs/crd-relationshipsource.mdx b/site/content/docs/crd-relationshipsource.mdx index e8bdb05e..86d8b2da 100644 --- a/site/content/docs/crd-relationshipsource.mdx +++ b/site/content/docs/crd-relationshipsource.mdx @@ -3,7 +3,7 @@ export const meta = { section: 'Reference', group: 'CRD reference', order: 3160, - description: 'RelationshipSource declares one upstream directory (Slack first) to poll', + description: 'RelationshipSource declares one upstream directory (Slack first) to poll and sync into SpiceDB.', } # RelationshipSource diff --git a/site/content/docs/crd-sessionhold.mdx b/site/content/docs/crd-sessionhold.mdx index f8c7b9c9..118c649b 100644 --- a/site/content/docs/crd-sessionhold.mdx +++ b/site/content/docs/crd-sessionhold.mdx @@ -3,7 +3,7 @@ export const meta = { section: 'Reference', group: 'CRD reference', order: 3170, - description: 'SessionHold parks an AgentSession for forensic review and gates its return to', + description: 'SessionHold parks an AgentSession for forensic review and gates its return to running on a human decision.', } # SessionHold diff --git a/site/content/docs/crd-sessionuseridentity.mdx b/site/content/docs/crd-sessionuseridentity.mdx index 40cc63b3..a9b7785a 100644 --- a/site/content/docs/crd-sessionuseridentity.mdx +++ b/site/content/docs/crd-sessionuseridentity.mdx @@ -3,7 +3,7 @@ export const meta = { section: 'Reference', group: 'CRD reference', order: 3180, - description: 'SessionUserIdentity is one session\'s narrowing of a user\'s UserIdentity', + description: 'SessionUserIdentity is one session\'s narrowing of a user\'s UserIdentity catalog down to just the credentials that session\'s agent asked for, for…', } # SessionUserIdentity diff --git a/site/content/docs/crd-sidecartoolbox.mdx b/site/content/docs/crd-sidecartoolbox.mdx index e0c6189c..80779fcf 100644 --- a/site/content/docs/crd-sidecartoolbox.mdx +++ b/site/content/docs/crd-sidecartoolbox.mdx @@ -3,7 +3,7 @@ export const meta = { section: 'Reference', group: 'CRD reference', order: 3190, - description: 'SidecarToolbox declares a user-supplied MCP server that runs as a sidecar', + description: 'SidecarToolbox declares a user-supplied MCP server that runs as a sidecar container alongside the agent runner pod: its image, sandbox shape, tool allowlist…', } # SidecarToolbox diff --git a/site/content/docs/crd-skill.mdx b/site/content/docs/crd-skill.mdx index 896e1e4a..aa92514f 100644 --- a/site/content/docs/crd-skill.mdx +++ b/site/content/docs/crd-skill.mdx @@ -3,7 +3,7 @@ export const meta = { section: 'Reference', group: 'CRD reference', order: 3200, - description: 'Skill is one agentskills.io skill: a SKILL.md (frontmatter + body) plus', + description: 'Skill is one agentskills.io skill: a SKILL.md (frontmatter + body) plus optional bundled files.', } # Skill diff --git a/site/content/docs/crd-spiceboxclass.mdx b/site/content/docs/crd-spiceboxclass.mdx index 299fdf7e..5ca7a27a 100644 --- a/site/content/docs/crd-spiceboxclass.mdx +++ b/site/content/docs/crd-spiceboxclass.mdx @@ -3,7 +3,7 @@ export const meta = { section: 'Reference', group: 'CRD reference', order: 3230, - description: 'SpiceboxClass is the template a tool sandbox is cut from: image, resources,', + description: 'SpiceboxClass is the template a tool sandbox is cut from: image, resources, network mode, mounts, and the toolspecs and toolchains available inside it.', } # SpiceboxClass diff --git a/site/content/docs/crd-spiceboxsession.mdx b/site/content/docs/crd-spiceboxsession.mdx index aa7da5bc..769f6d43 100644 --- a/site/content/docs/crd-spiceboxsession.mdx +++ b/site/content/docs/crd-spiceboxsession.mdx @@ -3,7 +3,7 @@ export const meta = { section: 'Reference', group: 'CRD reference', order: 3240, - description: 'SpiceboxSession is one live sandbox instantiated from a SpiceboxClass: the', + description: 'SpiceboxSession is one live sandbox instantiated from a SpiceboxClass: the long-lived pod that this session\'s ToolCalls execute inside, plus its workspace…', } # SpiceboxSession diff --git a/site/content/docs/crd-spiceboxtoolchain.mdx b/site/content/docs/crd-spiceboxtoolchain.mdx index a9a329c7..47bc0212 100644 --- a/site/content/docs/crd-spiceboxtoolchain.mdx +++ b/site/content/docs/crd-spiceboxtoolchain.mdx @@ -3,7 +3,7 @@ export const meta = { section: 'Reference', group: 'CRD reference', order: 3250, - description: 'SpiceboxToolchain is a language or tooling overlay a sandbox can mount -- a', + description: 'SpiceboxToolchain is a language or tooling overlay a sandbox can mount -- a compiler, an SDK, a linter -- delivered by a registered backend and mounted at…', } # SpiceboxToolchain diff --git a/site/content/docs/crd-spiceboxtoolkit.mdx b/site/content/docs/crd-spiceboxtoolkit.mdx index 74b7d15a..0fc06366 100644 --- a/site/content/docs/crd-spiceboxtoolkit.mdx +++ b/site/content/docs/crd-spiceboxtoolkit.mdx @@ -3,7 +3,7 @@ export const meta = { section: 'Reference', group: 'CRD reference', order: 3260, - description: 'SpiceboxToolkit is the machine-readable description of one CLI a sandbox', + description: 'SpiceboxToolkit is the machine-readable description of one CLI a sandbox tool can be built from: its subcommands, flags, env, parser config and default…', } # SpiceboxToolkit @@ -23,6 +23,7 @@ onto status. | Field | Type | Description | | --- | --- | --- | +| `spec.costTreatment` | `string` | CostTreatment declares how this toolkit's provider-reported cost counts toward session spend. "metered" (default) is real billed money and counts; "subscription-equivalent" is a flat-rate estimate (e.g. a Claude Max OAuth run) that is recorded and shown but EXCLUDED from the session total, so it cannot inflate spend or trip cost alerts. Mirrors pkg/tools/toolspec/toolkit.Toolkit.CostTreatment. _(enum: metered \| subscription-equivalent)_ | | `spec.docs` | `string` | Docs is prose about the CLI, surfaced to authors. | | `spec.env` \* | `object` | Env describes the environment variables the binary reads. It documents and wires them — notably which are sensitive — and is not a filter on the environment the binary actually receives. Mirrors pkg/tools/toolspec/toolkit.Toolkit.Env. | | `spec.env.allowed` \* | `[]object` | Allowed DESCRIBES the environment variables the binary reads. It is not an enforcement boundary: nothing strips a variable for being absent here. The environment a tool call runs with is composed by the ToolCall controller from the broker-resolved credentials, ToolCall.spec.env, SpiceboxSession.spec.defaultEnv, this toolkit's envDefaults and the sandbox container's own env. What an entry drives is keyed off Sensitive: redaction wherever the call is surfaced, which credential a setup flow must obtain, and which names a SpiceboxClass may not shadow through its own envDefaults. Adding a variable here documents and wires it; omitting one does not keep it out of the binary's environment. | diff --git a/site/content/docs/crd-spiceboxtoolspec.mdx b/site/content/docs/crd-spiceboxtoolspec.mdx index 81325042..dc3e178e 100644 --- a/site/content/docs/crd-spiceboxtoolspec.mdx +++ b/site/content/docs/crd-spiceboxtoolspec.mdx @@ -3,7 +3,7 @@ export const meta = { section: 'Reference', group: 'CRD reference', order: 3270, - description: 'SpiceboxToolspec is one sandbox tool as the agent sees it: a SpiceboxToolkit', + description: 'SpiceboxToolspec is one sandbox tool as the agent sees it: a SpiceboxToolkit reference narrowed to an allowed subcommand set, with CEL argument constraints…', } # SpiceboxToolspec diff --git a/site/content/docs/crd-spicedbbootstrap.mdx b/site/content/docs/crd-spicedbbootstrap.mdx index 19760ee4..3f225087 100644 --- a/site/content/docs/crd-spicedbbootstrap.mdx +++ b/site/content/docs/crd-spicedbbootstrap.mdx @@ -3,7 +3,7 @@ export const meta = { section: 'Reference', group: 'CRD reference', order: 3220, - description: 'SpiceDBBootstrap declaratively seeds SpiceDB state: an optional schema', + description: 'SpiceDBBootstrap declaratively seeds SpiceDB state: an optional schema fragment and/or a set of relationships TOUCHed into SpiceDB.', } # SpiceDBBootstrap diff --git a/site/content/docs/crd-toolcall.mdx b/site/content/docs/crd-toolcall.mdx index 8e410e82..ba15fc9f 100644 --- a/site/content/docs/crd-toolcall.mdx +++ b/site/content/docs/crd-toolcall.mdx @@ -3,7 +3,7 @@ export const meta = { section: 'Reference', group: 'CRD reference', order: 3290, - description: 'ToolCall is one execution of one sandbox tool inside a SpiceboxSession: the', + description: 'ToolCall is one execution of one sandbox tool inside a SpiceboxSession: the tool name, its args, non-secret env, stdin, and credential references -- never…', } # ToolCall diff --git a/site/content/docs/crd-useridentity.mdx b/site/content/docs/crd-useridentity.mdx index dac65b2d..6e07a06e 100644 --- a/site/content/docs/crd-useridentity.mdx +++ b/site/content/docs/crd-useridentity.mdx @@ -3,7 +3,7 @@ export const meta = { section: 'Reference', group: 'CRD reference', order: 3300, - description: 'UserIdentity is a human\'s credential catalog: the credentials one person has', + description: 'UserIdentity is a human\'s credential catalog: the credentials one person has linked, keyed by their canonical SpiceDB subject, used by…', } # UserIdentity diff --git a/site/content/docs/crd-workshop.mdx b/site/content/docs/crd-workshop.mdx index 1ac0806a..a59b42d8 100644 --- a/site/content/docs/crd-workshop.mdx +++ b/site/content/docs/crd-workshop.mdx @@ -3,7 +3,7 @@ export const meta = { section: 'Reference', group: 'CRD reference', order: 3310, - description: 'Workshop is one builder session\'s isolated build space: the durable record', + description: 'Workshop is one builder session\'s isolated build space: the durable record of its namespace, RBAC, SpiceDB tuple, tokens and sidecar identity, created by the…', } # Workshop diff --git a/site/content/docs/crd-workshopprobe.mdx b/site/content/docs/crd-workshopprobe.mdx index 16958ade..2d41314c 100644 --- a/site/content/docs/crd-workshopprobe.mdx +++ b/site/content/docs/crd-workshopprobe.mdx @@ -3,7 +3,7 @@ export const meta = { section: 'Reference', group: 'CRD reference', order: 3320, - description: 'WorkshopProbe is the tool-discovery probe object for an agent-builder', + description: 'WorkshopProbe is the tool-discovery probe object for an agent-builder workshop: the operator runs one pod against spec.image (or spec.script\'s baseImage, or…', } # WorkshopProbe diff --git a/site/content/docs/crd-workspacesource.mdx b/site/content/docs/crd-workspacesource.mdx index 98fd9261..721c8281 100644 --- a/site/content/docs/crd-workspacesource.mdx +++ b/site/content/docs/crd-workspacesource.mdx @@ -3,7 +3,7 @@ export const meta = { section: 'Reference', group: 'CRD reference', order: 3330, - description: 'WorkspaceSource declares a named, pluggable origin that per-session', + description: 'WorkspaceSource declares a named, pluggable origin that per-session workspaces are cut from.', } # WorkspaceSource diff --git a/site/content/docs/oap-agent.mdx b/site/content/docs/oap-agent.mdx index a65cff01..15aa7dec 100644 --- a/site/content/docs/oap-agent.mdx +++ b/site/content/docs/oap-agent.mdx @@ -3,7 +3,7 @@ export const meta = { section: 'Reference', group: 'CLI', order: 2710, - description: 'Manage and run AgentClass resources', + description: 'oap agent: Manage and run AgentClass resources. Every subcommand, flag, and example, generated from the CLI.', } # oap agent diff --git a/site/content/docs/oap-artifact.mdx b/site/content/docs/oap-artifact.mdx index 4e2d4213..a93957e8 100644 --- a/site/content/docs/oap-artifact.mdx +++ b/site/content/docs/oap-artifact.mdx @@ -3,7 +3,7 @@ export const meta = { section: 'Reference', group: 'CLI', order: 2720, - description: 'Inspect versioned artifacts and their revisions', + description: 'oap artifact: Inspect versioned artifacts and their revisions. Every subcommand, flag, and example, generated from the CLI.', } # oap artifact diff --git a/site/content/docs/oap-audit.mdx b/site/content/docs/oap-audit.mdx index 40ebb7e1..4ea18e69 100644 --- a/site/content/docs/oap-audit.mdx +++ b/site/content/docs/oap-audit.mdx @@ -3,7 +3,7 @@ export const meta = { section: 'Reference', group: 'CLI', order: 2730, - description: 'Verify the tamper-evident audit log of a session', + description: 'oap audit: Verify the tamper-evident audit log of a session. Every subcommand, flag, and example, generated from the CLI.', } # oap audit diff --git a/site/content/docs/oap-build.mdx b/site/content/docs/oap-build.mdx index e8becb52..5c81d174 100644 --- a/site/content/docs/oap-build.mdx +++ b/site/content/docs/oap-build.mdx @@ -3,7 +3,7 @@ export const meta = { section: 'Reference', group: 'CLI', order: 2740, - description: 'Build (and load) Docker images', + description: 'oap build: Build (and load) Docker images. Every subcommand, flag, and example, generated from the CLI.', } # oap build diff --git a/site/content/docs/oap-channel.mdx b/site/content/docs/oap-channel.mdx index 570f26f6..99d829d3 100644 --- a/site/content/docs/oap-channel.mdx +++ b/site/content/docs/oap-channel.mdx @@ -3,7 +3,7 @@ export const meta = { section: 'Reference', group: 'CLI', order: 2750, - description: 'Manage Channel CRs (Slack, browser, GitHub webhooks, schedules, ...).', + description: 'oap channel: Manage Channel CRs (Slack, browser, GitHub webhooks, schedules, ...). Every subcommand, flag, and example, generated from the CLI.', } # oap channel diff --git a/site/content/docs/oap-check.mdx b/site/content/docs/oap-check.mdx index 057b610a..71b191c5 100644 --- a/site/content/docs/oap-check.mdx +++ b/site/content/docs/oap-check.mdx @@ -3,7 +3,7 @@ export const meta = { section: 'Reference', group: 'CLI', order: 2760, - description: 'verify namespace, CRDs, services, and every registered component\'s health', + description: 'oap check: Verify namespace, CRDs, services, and every registered component\'s health. Every subcommand, flag, and example, generated from the CLI.', } # oap check diff --git a/site/content/docs/oap-class.mdx b/site/content/docs/oap-class.mdx index 52bd40ee..f4ae0c63 100644 --- a/site/content/docs/oap-class.mdx +++ b/site/content/docs/oap-class.mdx @@ -3,7 +3,7 @@ export const meta = { section: 'Reference', group: 'CLI', order: 2770, - description: 'Inspect SpiceboxClass resources (sandbox image + capability descriptors)', + description: 'oap class: Inspect SpiceboxClass resources (sandbox image + capability descriptors). Every subcommand, flag, and example, generated from the CLI.', } # oap class diff --git a/site/content/docs/oap-clean.mdx b/site/content/docs/oap-clean.mdx index 2374713c..32cb81f8 100644 --- a/site/content/docs/oap-clean.mdx +++ b/site/content/docs/oap-clean.mdx @@ -3,7 +3,7 @@ export const meta = { section: 'Reference', group: 'CLI', order: 2780, - description: 'Remove everything oap installed (CRs → operator → CRDs → namespace)', + description: 'oap clean: Remove everything oap installed (CRs → operator → CRDs → namespace). Every subcommand, flag, and example, generated from the CLI.', } # oap clean diff --git a/site/content/docs/oap-desktop.mdx b/site/content/docs/oap-desktop.mdx index 90c9d671..8aaec0cc 100644 --- a/site/content/docs/oap-desktop.mdx +++ b/site/content/docs/oap-desktop.mdx @@ -3,7 +3,7 @@ export const meta = { section: 'Reference', group: 'CLI', order: 2790, - description: 'Run the oap macOS menubar app (local VM + built-in chat; macOS/Apple Silicon only)', + description: 'oap desktop: Run the oap macOS menubar app (local VM + built-in chat; macOS/Apple Silicon only). Every subcommand, flag, and example, generated from the CLI.', } # oap desktop diff --git a/site/content/docs/oap-directory.mdx b/site/content/docs/oap-directory.mdx index 55422c2a..45111251 100644 --- a/site/content/docs/oap-directory.mdx +++ b/site/content/docs/oap-directory.mdx @@ -3,7 +3,7 @@ export const meta = { section: 'Reference', group: 'CLI', order: 2800, - description: 'Configure directory-sync sources (RelationshipSource): Slack, GitHub, 1Password, ...', + description: 'oap directory: Configure directory-sync sources (RelationshipSource): Slack, GitHub, 1Password, ... Every subcommand, flag, and example, generated from the CLI.', } # oap directory diff --git a/site/content/docs/oap-identity.mdx b/site/content/docs/oap-identity.mdx index cf1f626b..2f28de72 100644 --- a/site/content/docs/oap-identity.mdx +++ b/site/content/docs/oap-identity.mdx @@ -3,7 +3,7 @@ export const meta = { section: 'Reference', group: 'CLI', order: 2810, - description: 'Manage AgentIdentity resources', + description: 'oap identity: Manage AgentIdentity resources. Every subcommand, flag, and example, generated from the CLI.', } # oap identity diff --git a/site/content/docs/oap-idp.mdx b/site/content/docs/oap-idp.mdx index bf09dd6a..e05ca2a7 100644 --- a/site/content/docs/oap-idp.mdx +++ b/site/content/docs/oap-idp.mdx @@ -3,7 +3,7 @@ export const meta = { section: 'Reference', group: 'CLI', order: 2820, - description: 'Manage the cluster identity provider', + description: 'oap idp: Manage the cluster identity provider. Every subcommand, flag, and example, generated from the CLI.', } # oap idp diff --git a/site/content/docs/oap-image.mdx b/site/content/docs/oap-image.mdx index d7434cc3..0063252b 100644 --- a/site/content/docs/oap-image.mdx +++ b/site/content/docs/oap-image.mdx @@ -3,7 +3,7 @@ export const meta = { section: 'Reference', group: 'CLI', order: 2830, - description: 'Work with local Docker images and the current cluster', + description: 'oap image: Work with local Docker images and the current cluster. Every subcommand, flag, and example, generated from the CLI.', } # oap image diff --git a/site/content/docs/oap-init.mdx b/site/content/docs/oap-init.mdx index f57d853b..147cdd67 100644 --- a/site/content/docs/oap-init.mdx +++ b/site/content/docs/oap-init.mdx @@ -3,7 +3,7 @@ export const meta = { section: 'Reference', group: 'CLI', order: 2840, - description: 'Bring up agent-primitives end-to-end (build + install + check)', + description: 'oap init: Bring up agent-primitives end-to-end (build + install + check). Every subcommand, flag, and example, generated from the CLI.', } # oap init diff --git a/site/content/docs/oap-install.mdx b/site/content/docs/oap-install.mdx index fbe21abd..2ab8a461 100644 --- a/site/content/docs/oap-install.mdx +++ b/site/content/docs/oap-install.mdx @@ -3,7 +3,7 @@ export const meta = { section: 'Reference', group: 'CLI', order: 2850, - description: 'Install the operator + CRDs into the configured cluster', + description: 'oap install: Install the operator + CRDs into the configured cluster. Every subcommand, flag, and example, generated from the CLI.', } # oap install diff --git a/site/content/docs/oap-kg.mdx b/site/content/docs/oap-kg.mdx index 6c1d5f15..70d58776 100644 --- a/site/content/docs/oap-kg.mdx +++ b/site/content/docs/oap-kg.mdx @@ -3,7 +3,7 @@ export const meta = { section: 'Reference', group: 'CLI', order: 2860, - description: 'Query the knowledge graph', + description: 'oap kg: Query the knowledge graph. Every subcommand, flag, and example, generated from the CLI.', } # oap kg diff --git a/site/content/docs/oap-login.mdx b/site/content/docs/oap-login.mdx index 999979bc..d9c0941b 100644 --- a/site/content/docs/oap-login.mdx +++ b/site/content/docs/oap-login.mdx @@ -3,7 +3,7 @@ export const meta = { section: 'Reference', group: 'CLI', order: 2870, - description: 'Log in to this cluster\'s identity provider and cache the assertion', + description: 'oap login: Log in to this cluster\'s identity provider and cache the assertion. Every subcommand, flag, and example, generated from the CLI.', } # oap login diff --git a/site/content/docs/oap-logout.mdx b/site/content/docs/oap-logout.mdx index c2eb0501..ed2de59d 100644 --- a/site/content/docs/oap-logout.mdx +++ b/site/content/docs/oap-logout.mdx @@ -3,7 +3,7 @@ export const meta = { section: 'Reference', group: 'CLI', order: 2880, - description: 'Remove the cached identity assertion', + description: 'oap logout: Remove the cached identity assertion. Every subcommand, flag, and example, generated from the CLI.', } # oap logout diff --git a/site/content/docs/oap-memory.mdx b/site/content/docs/oap-memory.mdx index 070eec88..5d83fd44 100644 --- a/site/content/docs/oap-memory.mdx +++ b/site/content/docs/oap-memory.mdx @@ -3,7 +3,7 @@ export const meta = { section: 'Reference', group: 'CLI', order: 2890, - description: 'Inspect and manage session memory entries', + description: 'oap memory: Inspect and manage session memory entries. Every subcommand, flag, and example, generated from the CLI.', } # oap memory diff --git a/site/content/docs/oap-pin.mdx b/site/content/docs/oap-pin.mdx index 0b66c756..b12a0975 100644 --- a/site/content/docs/oap-pin.mdx +++ b/site/content/docs/oap-pin.mdx @@ -3,7 +3,7 @@ export const meta = { section: 'Reference', group: 'CLI', order: 2900, - description: 'Inspect and update dependency pin baselines', + description: 'oap pin: Inspect and update dependency pin baselines. Every subcommand, flag, and example, generated from the CLI.', } # oap pin diff --git a/site/content/docs/oap-plangate.mdx b/site/content/docs/oap-plangate.mdx index 2aecf654..ebc23eb4 100644 --- a/site/content/docs/oap-plangate.mdx +++ b/site/content/docs/oap-plangate.mdx @@ -3,7 +3,7 @@ export const meta = { section: 'Reference', group: 'CLI', order: 2910, - description: 'Inspect what the plan gate recorded for a session', + description: 'oap plangate: Inspect what the plan gate recorded for a session. Every subcommand, flag, and example, generated from the CLI.', } # oap plangate diff --git a/site/content/docs/oap-platform.mdx b/site/content/docs/oap-platform.mdx index 6492e1cb..5df0c6d5 100644 --- a/site/content/docs/oap-platform.mdx +++ b/site/content/docs/oap-platform.mdx @@ -3,7 +3,7 @@ export const meta = { section: 'Reference', group: 'CLI', order: 2920, - description: 'Manage platform-level admin access (the admin UI gate)', + description: 'oap platform: Manage platform-level admin access (the admin UI gate). Every subcommand, flag, and example, generated from the CLI.', } # oap platform diff --git a/site/content/docs/oap-preferences.mdx b/site/content/docs/oap-preferences.mdx index 08eebc36..471c3eba 100644 --- a/site/content/docs/oap-preferences.mdx +++ b/site/content/docs/oap-preferences.mdx @@ -3,7 +3,7 @@ export const meta = { section: 'Reference', group: 'CLI', order: 2930, - description: 'Inspect a session\'s resolved per-user preferences', + description: 'oap preferences: Inspect a session\'s resolved per-user preferences. Every subcommand, flag, and example, generated from the CLI.', } # oap preferences diff --git a/site/content/docs/oap-sandbox.mdx b/site/content/docs/oap-sandbox.mdx index ffc4534b..b21e1f50 100644 --- a/site/content/docs/oap-sandbox.mdx +++ b/site/content/docs/oap-sandbox.mdx @@ -3,7 +3,7 @@ export const meta = { section: 'Reference', group: 'CLI', order: 2940, - description: 'Inspect SpiceboxSession resources (per-bundle running sandbox pods)', + description: 'oap sandbox: Inspect SpiceboxSession resources (per-bundle running sandbox pods). Every subcommand, flag, and example, generated from the CLI.', } # oap sandbox diff --git a/site/content/docs/oap-session.mdx b/site/content/docs/oap-session.mdx index c4c8ca22..c9fcc591 100644 --- a/site/content/docs/oap-session.mdx +++ b/site/content/docs/oap-session.mdx @@ -3,7 +3,7 @@ export const meta = { section: 'Reference', group: 'CLI', order: 2950, - description: 'Inspect AgentSession resources', + description: 'oap session: Inspect AgentSession resources. Every subcommand, flag, and example, generated from the CLI.', } # oap session diff --git a/site/content/docs/oap-settings.mdx b/site/content/docs/oap-settings.mdx index 2ad1b7a4..2c62ec51 100644 --- a/site/content/docs/oap-settings.mdx +++ b/site/content/docs/oap-settings.mdx @@ -3,7 +3,7 @@ export const meta = { section: 'Reference', group: 'CLI', order: 2960, - description: 'Manage cluster-wide agent settings (security defaults wizard + apply).', + description: 'oap settings: Manage cluster-wide agent settings (security defaults wizard + apply). Every subcommand, flag, and example, generated from the CLI.', } # oap settings diff --git a/site/content/docs/oap-skill.mdx b/site/content/docs/oap-skill.mdx index 6030c452..f3ca27c8 100644 --- a/site/content/docs/oap-skill.mdx +++ b/site/content/docs/oap-skill.mdx @@ -3,7 +3,7 @@ export const meta = { section: 'Reference', group: 'CLI', order: 2970, - description: 'List, view, create, and delete agent Skills and SkillSources', + description: 'oap skill: List, view, create, and delete agent Skills and SkillSources. Every subcommand, flag, and example, generated from the CLI.', } # oap skill diff --git a/site/content/docs/oap-spicedb.mdx b/site/content/docs/oap-spicedb.mdx index 51c82228..f9595fd0 100644 --- a/site/content/docs/oap-spicedb.mdx +++ b/site/content/docs/oap-spicedb.mdx @@ -3,7 +3,7 @@ export const meta = { section: 'Reference', group: 'CLI', order: 2980, - description: 'Apply schema and check permissions against the system SpiceDB.', + description: 'oap spicedb: Apply schema and check permissions against the system SpiceDB. Every subcommand, flag, and example, generated from the CLI.', } # oap spicedb diff --git a/site/content/docs/oap-tools.mdx b/site/content/docs/oap-tools.mdx index a334d5ef..0d2e17d1 100644 --- a/site/content/docs/oap-tools.mdx +++ b/site/content/docs/oap-tools.mdx @@ -3,7 +3,7 @@ export const meta = { section: 'Reference', group: 'CLI', order: 2990, - description: 'Manage tools used by agents (kind-agnostic)', + description: 'oap tools: Manage tools used by agents (kind-agnostic). Every subcommand, flag, and example, generated from the CLI.', } # oap tools diff --git a/site/content/docs/oap-user-identity.mdx b/site/content/docs/oap-user-identity.mdx index 16c58a05..b65e5360 100644 --- a/site/content/docs/oap-user-identity.mdx +++ b/site/content/docs/oap-user-identity.mdx @@ -3,7 +3,7 @@ export const meta = { section: 'Reference', group: 'CLI', order: 3000, - description: 'Manage UserIdentity resources (per-user credential catalogs)', + description: 'oap user-identity: Manage UserIdentity resources (per-user credential catalogs). Every subcommand, flag, and example, generated from the CLI.', } # oap user-identity diff --git a/site/lib/copy-page.test.ts b/site/lib/copy-page.test.ts new file mode 100644 index 00000000..87e2788b --- /dev/null +++ b/site/lib/copy-page.test.ts @@ -0,0 +1,25 @@ +import { describe, expect, it } from "vitest"; +import { askPrompt, chatLinks, markdownPath } from "./copy-page"; + +const MD = "https://openap.org/docs/plan-gating.md"; + +describe("copy-page links", () => { + it("points at the guide's Markdown rendition", () => { + expect(markdownPath("plan-gating")).toBe("/docs/plan-gating.md"); + }); + + it.each([ + ["chatgpt", "https://chatgpt.com/?hints=search&prompt="], + ["claude", "https://claude.ai/new?q="], + ])("%s: prefilled with a prompt naming the Markdown URL", (id, prefix) => { + const link = chatLinks(MD).find((l) => l.id === id); + expect(link?.href.startsWith(prefix)).toBe(true); + const q = new URL(link!.href).searchParams; + expect(q.get(id === "claude" ? "q" : "prompt")).toBe(askPrompt(MD)); + }); + + it("encodes the prompt so the page URL survives as one parameter", () => { + const { href } = chatLinks(`${MD}?a=1&b=2`)[1]; + expect(new URL(href).searchParams.get("q")).toContain("?a=1&b=2"); + }); +}); diff --git a/site/lib/copy-page.ts b/site/lib/copy-page.ts new file mode 100644 index 00000000..9cf80497 --- /dev/null +++ b/site/lib/copy-page.ts @@ -0,0 +1,36 @@ +// The docs "Copy page" menu's destinations (components/CopyPage.tsx). Pure, so +// the URLs are tested without a browser. +// +// The chat links hand the assistant the page's Markdown rendition rather than +// its HTML: it is the same content without the sidebar, scripts and styles, +// so the model reads the guide and not the chrome around it. + +export function markdownPath(slug: string): string { + return `/docs/${slug}.md`; +} + +export function askPrompt(markdownUrl: string): string { + return `Read from ${markdownUrl} so I can ask questions about it.`; +} + +export interface ChatLink { + id: "chatgpt" | "claude"; + label: string; + href: string; +} + +export function chatLinks(markdownUrl: string): ChatLink[] { + const q = encodeURIComponent(askPrompt(markdownUrl)); + return [ + { + id: "chatgpt", + label: "Open in ChatGPT", + href: `https://chatgpt.com/?hints=search&prompt=${q}`, + }, + { + id: "claude", + label: "Open in Claude", + href: `https://claude.ai/new?q=${q}`, + }, + ]; +} diff --git a/site/lib/guides.test.ts b/site/lib/guides.test.ts new file mode 100644 index 00000000..1a2dc259 --- /dev/null +++ b/site/lib/guides.test.ts @@ -0,0 +1,21 @@ +import { describe, expect, it, vi } from "vitest"; + +// guides.ts is server-only; the guard throws outside a React server build. +vi.mock("server-only", () => ({})); + +const { guideSource } = await import("./guides"); + +describe("guideSource", () => { + it("reads a guide that exists", async () => { + await expect(guideSource("what-is-oap")).resolves.toContain( + "export const meta", + ); + }); + + it.each(["../../package", "../_manifest", "no-such-guide", ""])( + "%j: refused, since it names no guide", + async (slug) => { + await expect(guideSource(slug)).rejects.toThrow(/no guide named/); + }, + ); +}); diff --git a/site/lib/guides.ts b/site/lib/guides.ts index 302352e1..8f30d1d2 100644 --- a/site/lib/guides.ts +++ b/site/lib/guides.ts @@ -1,5 +1,5 @@ import "server-only"; -import { readdir } from "node:fs/promises"; +import { readdir, readFile } from "node:fs/promises"; import path from "node:path"; import { cache, type ComponentType } from "react"; import { normalizeGuide, sortGuides, type Guide, type GuideMeta } from "./nav"; @@ -34,3 +34,12 @@ export const allGuides = cache(async (): Promise<Guide[]> => { ); return sortGuides(guides); }); + +// The guide's raw MDX, for the Markdown renditions (lib/markdown.ts). The +// slug becomes a file path, so it must name an existing guide: this checks +// for itself rather than trusting every caller to have checked first. +export async function guideSource(slug: string): Promise<string> { + if (!(await guideSlugs()).includes(slug)) + throw new Error(`guideSource: no guide named ${JSON.stringify(slug)}`); + return readFile(path.join(GUIDES_DIR, `${slug}.mdx`), "utf8"); +} diff --git a/site/lib/markdown-routes.ts b/site/lib/markdown-routes.ts new file mode 100644 index 00000000..658d8e98 --- /dev/null +++ b/site/lib/markdown-routes.ts @@ -0,0 +1,30 @@ +import "server-only"; +import { allGuides, guideSource } from "./guides"; +import { getAsset } from "./manifest"; +import { guideToMarkdown, type MarkdownContext } from "./markdown"; +import { SITE_URL } from "./site"; + +// Shared by the route handlers that serve docs as text: app/md/[slug], +// app/llms.txt, and app/llms-full.txt. +const ctx: MarkdownContext = { siteUrl: SITE_URL, asset: getAsset }; + +export async function renderGuideMarkdown(slug: string): Promise<string> { + const guide = (await allGuides()).find((g) => g.slug === slug); + if (!guide) throw new Error(`no guide ${slug}`); + return guideToMarkdown(guide, await guideSource(slug), ctx); +} + +// Text responses are for agents, not search engines: they stay out of the +// index, and a page's Markdown names its HTML page as the canonical copy. +export function textResponse( + body: string, + contentType: string, + canonical?: string, +): Response { + const headers: Record<string, string> = { + "Content-Type": `${contentType}; charset=utf-8`, + "X-Robots-Tag": "noindex", + }; + if (canonical) headers.Link = `<${canonical}>; rel="canonical"`; + return new Response(body, { headers }); +} diff --git a/site/lib/markdown.test.ts b/site/lib/markdown.test.ts new file mode 100644 index 00000000..0ffcc93b --- /dev/null +++ b/site/lib/markdown.test.ts @@ -0,0 +1,165 @@ +import { readdirSync, readFileSync } from "node:fs"; +import path from "node:path"; +import { describe, expect, it } from "vitest"; +import { getAsset, type MediaAsset } from "./manifest"; +import { guideToMarkdown, llmsTxt, type MarkdownContext } from "./markdown"; +import { buildNav, normalizeGuide, sortGuides } from "./nav"; + +const SITE = "https://example.test"; +const ASSETS: Record<string, MediaAsset> = { + shot: { kind: "screenshot", src: "/media/shot.png", caption: "A shot" }, + clip: { + kind: "clip", + mp4: "/media/clip.mp4", + webm: "/media/clip.webm", + caption: "A clip", + }, + "set-1": { kind: "screenshot", src: "/media/set-1.png", caption: "One" }, + "set-2": { kind: "screenshot", src: "/media/set-2.png", caption: "Two" }, +}; +const ctx: MarkdownContext = { siteUrl: SITE, asset: (n) => ASSETS[n] }; +const guide = normalizeGuide("demo", { + title: "Demo", + description: "A demo page.", +}); + +const render = (body: string) => + guideToMarkdown( + guide, + `export const meta = {\n title: 'Demo',\n}\n\n# Demo\n\n${body}`, + ctx, + ); + +describe("guideToMarkdown", () => { + it("replaces the meta block with the description and canonical URL under the title", () => { + const md = render("Body."); + expect(md).not.toContain("export const meta"); + expect(md).toBe( + `# Demo\n\n> A demo page.\n\nSource: ${SITE}/docs/demo\n\nBody.\n`, + ); + }); + + it("a body that is only the title line: title once, header under it", () => { + const md = guideToMarkdown( + guide, + "export const meta = {\n title: 'Demo',\n}\n\n# Demo", + ctx, + ); + expect(md).toBe(`# Demo\n\n> A demo page.\n\nSource: ${SITE}/docs/demo\n`); + }); + + it.each([ + [ + "lede div: unwrapped and dedented", + '<div className="doc-lede">\n First line\n second line.\n</div>', + "First line\nsecond line.", + ], + [ + "callout: a titled blockquote", + '<Callout kind="security" title="Heads up">\n Body **text**.\n</Callout>', + "> **Security: Heads up**\n>\n> Body **text**.", + ], + [ + "screenshot: image with the manifest caption and an absolute URL", + '<Screenshot name="shot" />', + `![A shot](${SITE}/media/shot.png)`, + ], + [ + "clip: a link to the mp4", + '<Clip name="clip" caption="Override" />', + `[Video: Override](${SITE}/media/clip.mp4)`, + ], + [ + "screenshot series: every numbered still", + '<ScreenshotSeries name="set" />', + `![One](${SITE}/media/set-1.png)\n\n![Two](${SITE}/media/set-2.png)`, + ], + [ + "heading tag with coverage badge: a Markdown heading", + '<h3 id="asi01">ASI01 — Hijack <Coverage level="partial" /></h3>', + "### ASI01 — Hijack (coverage: Partial)", + ], + [ + "html list with links: Markdown list with absolute links", + '<ul>\n <li><a href="/docs/x#y">X</a>: one\n two.</li>\n <li><em>Three</em></li>\n</ul>', + `- [X](${SITE}/docs/x#y): one two.\n- *Three*`, + ], + [ + "markdown docs link: made absolute", + "See [this](/docs/other).", + `See [this](${SITE}/docs/other).`, + ], + [ + "inline tags with edge spaces: space moved outside the markers", + "a\n<strong> plan</strong> on <code> acme/w </code>.", + "a\n **plan** on `acme/w` .", + ], + [ + "inline code: tags inside it are left literal", + "Run `oap audit verify <session>` and <strong>check</strong>.", + "Run `oap audit verify <session>` and **check**.", + ], + ])("%s", (_, input, want) => { + expect(render(input)).toContain(want); + }); + + it("leaves fenced code untouched", () => { + const fence = '```yaml\nkind: <Thing>\nhref: "/docs/x"\n```'; + expect(render(fence)).toContain(fence); + }); + + it("drops a media reference missing from the manifest instead of emitting JSX", () => { + expect(render('<Screenshot name="nope" />')).not.toContain("<"); + }); +}); + +// Every real guide must come out as Markdown with no JSX left over: a new +// component or tag the transform does not know fails here, not in an agent's +// context window. +describe("every guide in content/docs", () => { + const dir = path.join(__dirname, "../content/docs"); + const files = readdirSync(dir).filter((f) => f.endsWith(".mdx")); + const realCtx: MarkdownContext = { siteUrl: SITE, asset: getAsset }; + + it.each(files)("%s renders without leftover JSX", (file) => { + const slug = file.slice(0, -4); + const md = guideToMarkdown( + normalizeGuide(slug, { title: slug }), + readFileSync(path.join(dir, file), "utf8"), + realCtx, + ); + const outsideCode = md + .replace(/^```[\s\S]*?^```/gm, "") + .replace(/`[^`]+`/g, ""); + expect(outsideCode).not.toMatch(/<\/?[A-Za-z][\w-]*[\s/>]/); + expect(outsideCode).not.toContain("className="); + expect(outsideCode).not.toMatch(/\]\(\/[^/]/); + }); +}); + +describe("llmsTxt", () => { + it("lists every guide under its section, linking the Markdown rendition", () => { + const nav = buildNav( + sortGuides([ + normalizeGuide("intro", { + title: "Intro", + section: "Guides", + group: "Get started", + order: 1, + description: "Start here.", + }), + normalizeGuide("cli", { title: "CLI", section: "Reference", order: 2 }), + ]), + ); + const txt = llmsTxt(nav, { + name: "Site", + description: "What it is.", + siteUrl: SITE, + }); + expect(txt).toMatch(/^# Site\n\n> What it is\.\n/); + expect(txt).toContain( + `## Guides\n\n- [Get started: Intro](${SITE}/docs/intro.md): Start here.`, + ); + expect(txt).toContain(`## Reference\n\n- [CLI](${SITE}/docs/cli.md)\n`); + }); +}); diff --git a/site/lib/markdown.ts b/site/lib/markdown.ts new file mode 100644 index 00000000..b170cf66 --- /dev/null +++ b/site/lib/markdown.ts @@ -0,0 +1,193 @@ +// Plain Markdown renditions of the docs, for agents and LLM tooling: the +// per-page /docs/<slug>.md, /llms.txt, and /llms-full.txt. Pure, so it is +// tested against every real guide without a bundler. +// +// The source is the guide's MDX, not its rendered HTML. MDX is already +// Markdown apart from a small, closed set of JSX (the components in +// mdx-components.tsx plus a few inline HTML tags), and each has a direct +// Markdown equivalent. lib/markdown.test.ts fails if a guide uses a tag this +// file does not translate, so a new component cannot silently leak JSX into +// the Markdown. +import { COVERAGE_LABELS } from "@/components/Coverage"; +import type { Guide, NavSection } from "./nav"; +import type { MediaAsset } from "./manifest"; + +export interface MarkdownContext { + siteUrl: string; + asset: (name: string) => MediaAsset | undefined; +} + +const CALLOUT_LABEL: Record<string, string> = { + note: "Note", + tip: "Tip", + warning: "Warning", + security: "Security", +}; + +const META_RE = /^export const meta = \{[\s\S]*?^\}\s*/m; + +function attr(attrs: string, name: string): string | undefined { + return new RegExp(`\\b${name}="([^"]*)"`).exec(attrs)?.[1]; +} + +function dedent(block: string): string { + return block + .split("\n") + .map((l) => l.trim()) + .join("\n") + .trim(); +} + +// Inline code spans are literal in MDX (`oap audit verify <session>`), so they +// are swapped out before any tag rewriting and restored after. +function withCodeSpansProtected( + text: string, + fn: (t: string) => string, +): string { + const spans: string[] = []; + const masked = text.replace(/`[^`]+`/g, (m) => { + spans.push(m); + return `\uE000${spans.length - 1}\uE001`; + }); + return fn(masked).replace(/\uE000(\d+)\uE001/g, (_, i) => spans[Number(i)]); +} + +function absolute(href: string, siteUrl: string): string { + return href.startsWith("/") ? siteUrl + href : href; +} + +function media(name: string, ctx: MarkdownContext, caption?: string): string { + const a = ctx.asset(name); + if (!a) return ""; + const cap = caption ?? a.caption ?? name; + if (a.kind === "screenshot") + return a.src ? `![${cap}](${absolute(a.src, ctx.siteUrl)})` : ""; + const file = a.mp4 ?? a.webm; + return file ? `[Video: ${cap}](${absolute(file, ctx.siteUrl)})` : ""; +} + +function series(name: string, ctx: MarkdownContext): string { + const out: string[] = []; + for (let i = 1; ctx.asset(`${name}-${i}`); i++) + out.push(media(`${name}-${i}`, ctx)); + return out.join("\n\n"); +} + +const INLINE_MARK: Record<string, string> = { + strong: "**", + em: "*", + code: "`", +}; + +// HTML collapses the space in "<strong> plan</strong>"; Markdown does not, and +// "** plan**" is not emphasis. Edge whitespace moves outside the markers. +function inlineMark(_: string, tag: string, body: string): string { + const [, lead, inner, trail] = /^(\s*)([\s\S]*?)(\s*)$/.exec(body)!; + const m = INLINE_MARK[tag]; + return `${lead}${m}${inner}${m}${trail}`; +} + +function prose(text: string, ctx: MarkdownContext): string { + return withCodeSpansProtected(text, (t) => + t + .replace(/<div className="doc-lede">([\s\S]*?)<\/div>/g, (_, body) => + dedent(body), + ) + .replace( + /<Callout\b([^>]*)>([\s\S]*?)<\/Callout>/g, + (_, attrs: string, body: string) => { + const kind = attr(attrs, "kind") ?? "note"; + const title = attr(attrs, "title"); + const head = `**${CALLOUT_LABEL[kind] ?? CALLOUT_LABEL.note}${title ? `: ${title}` : ""}**`; + const lines = [head, "", ...dedent(prose(body, ctx)).split("\n")]; + return lines.map((l) => (l ? `> ${l}` : ">")).join("\n"); + }, + ) + .replace(/<(Clip|Screenshot)\b([^>]*?)\/>/g, (_, _c, attrs: string) => + media(attr(attrs, "name") ?? "", ctx, attr(attrs, "caption")), + ) + .replace(/<ScreenshotSeries\b([^>]*?)\/>/g, (_, attrs: string) => + series(attr(attrs, "name") ?? "", ctx), + ) + .replace( + /<Coverage level="([^"]*)" \/>/g, + (_, level: string) => `(coverage: ${COVERAGE_LABELS[level] ?? level})`, + ) + .replace( + /<h([1-6])\b[^>]*>([\s\S]*?)<\/h\1>/g, + (_, n: string, body: string) => + `${"#".repeat(Number(n))} ${body.trim()}`, + ) + .replace(/<ul>\s*/g, "") + .replace(/\s*<\/ul>/g, "") + .replace( + /<li>([\s\S]*?)<\/li>\s*/g, + (_, body: string) => `- ${body.replace(/\s+/g, " ").trim()}\n`, + ) + .replace( + /<a href="([^"]*)">([\s\S]*?)<\/a>/g, + (_, href: string, body: string) => `[${body}](${href})`, + ) + .replace(/<(strong|em|code)>([\s\S]*?)<\/\1>/g, inlineMark) + .replace(/<\/?small>/g, "") + .replace( + /\]\((\/[^)\s]*)\)/g, + (_, href: string) => `](${absolute(href, ctx.siteUrl)})`, + ), + ); +} + +// Renders one guide's MDX as Markdown: the meta block becomes a short header +// (description and canonical URL), and everything outside code fences is +// rewritten through prose(). Fenced code is passed through untouched. +export function guideToMarkdown( + guide: Guide, + src: string, + ctx: MarkdownContext, +): string { + const body = src.replace(META_RE, ""); + const parts = body.split(/^(```[\s\S]*?^```)$/m); + const md = parts + .map((part, i) => + i % 2 === 1 ? part : prose(part, ctx).replace(/\n{3,}/g, "\n\n"), + ) + .join("") + .trim(); + const url = `${ctx.siteUrl}/docs/${guide.slug}`; + const head = [guide.description && `> ${guide.description}`, `Source: ${url}`] + .filter(Boolean) + .join("\n\n"); + // The body opens with the page's own `# Title`; the header goes under it. + // A body that is only the title line has nothing after it. + if (md.startsWith("# ")) { + const br = md.indexOf("\n"); + const [title, rest] = br < 0 ? [md, ""] : [md.slice(0, br), md.slice(br)]; + return `${title}\n\n${head}${rest}\n`; + } + return `# ${guide.title}\n\n${head}\n\n${md}\n`; +} + +// /llms.txt, per https://llmstxt.org: an H1, a blockquote summary, then one +// link list per docs section, each link to the page's Markdown rendition. +export function llmsTxt( + nav: NavSection[], + site: { name: string; description: string; siteUrl: string }, +): string { + const out = [ + `# ${site.name}`, + "", + `> ${site.description}`, + "", + `Every page below is the Markdown rendition of a docs page; drop the .md for the HTML. The whole docs set as one file: ${site.siteUrl}/llms-full.txt`, + ]; + for (const { section, blocks } of nav) { + out.push("", `## ${section}`, ""); + for (const block of blocks) + for (const g of block.guides) { + const label = block.group ? `${block.group}: ${g.title}` : g.title; + const desc = g.description ? `: ${g.description}` : ""; + out.push(`- [${label}](${site.siteUrl}/docs/${g.slug}.md)${desc}`); + } + } + return out.join("\n") + "\n"; +} diff --git a/site/lib/site.ts b/site/lib/site.ts index 5f39b67e..f5328344 100644 --- a/site/lib/site.ts +++ b/site/lib/site.ts @@ -5,8 +5,20 @@ export const SITE_URL = "https://openap.org"; export const SITE_NAME = "Open Agent Primitives"; +// Mirrors the landing hero (app/(landing)/Landing.tsx). Change them together. export const SITE_DESCRIPTION = - "Building blocks for running enterprise AI agents in your own cluster. The AI never decides what it's allowed to do: OAP checks every action before it runs."; + "Open-source building blocks for running enterprise AI agents in your own Kubernetes cluster, on the models you choose. OAP checks every action before it runs."; + +// The landing page's <title>, which is also its share-card headline. +export const SITE_TITLE = + "Open Agent Primitives: a secure way to run enterprise AI agents"; + +export const REPO_URL = "https://github.com/authzed/openagentprimitives"; + +export const PUBLISHER = { + name: "AuthZed", + url: "https://authzed.com", +} as const; // The build-time share card (app/opengraph-image.tsx). A page that sets its // own openGraph replaces the inherited one wholesale, image included, so diff --git a/site/lib/structured-data.test.ts b/site/lib/structured-data.test.ts new file mode 100644 index 00000000..ca0e7f33 --- /dev/null +++ b/site/lib/structured-data.test.ts @@ -0,0 +1,74 @@ +import { describe, expect, it } from "vitest"; +import { normalizeGuide } from "./nav"; +import { SITE_URL } from "./site"; +import { guideGraph, landingGraph, plainText } from "./structured-data"; + +type Node = Record<string, unknown>; +const nodes = (g: Node) => g["@graph"] as Node[]; +const byType = (g: Node, type: string) => + nodes(g).find((n) => n["@type"] === type); + +describe("landingGraph", () => { + it("declares the organization, website and project, cross-linked by @id", () => { + const g = landingGraph(); + expect(g["@context"]).toBe("https://schema.org"); + const org = byType(g, "Organization"); + const site = byType(g, "WebSite"); + const sw = byType(g, "SoftwareSourceCode"); + expect(org?.name).toBe("AuthZed"); + expect(site?.url).toBe(`${SITE_URL}/`); + expect(site?.publisher).toEqual({ "@id": org?.["@id"] }); + expect(sw?.codeRepository).toMatch(/^https:\/\/github\.com\//); + expect(sw?.author).toEqual({ "@id": org?.["@id"] }); + }); +}); + +describe("guideGraph", () => { + const guide = normalizeGuide("plan-gating", { + title: "Plan gating", + section: "Guides", + group: "Security", + }); + const g = guideGraph(guide, "Desc.", "/docs/plan-gating/opengraph-image"); + + it("describes the page as a TechArticle with an absolute image", () => { + const article = byType(g, "TechArticle"); + expect(article).toMatchObject({ + headline: "Plan gating", + description: "Desc.", + url: `${SITE_URL}/docs/plan-gating`, + image: `${SITE_URL}/docs/plan-gating/opengraph-image`, + articleSection: "Security", + }); + }); + + it("includes every node it references by @id", () => { + const ids = new Set(nodes(g).map((n) => n["@id"])); + const refs = JSON.stringify(g).matchAll(/\{"@id":"([^"]+)"\}/g); + for (const [, id] of refs) expect(ids).toContain(id); + }); + + it("gives every breadcrumb a URL, ending at the page", () => { + const crumbs = byType(g, "BreadcrumbList")?.itemListElement as Node[]; + expect(crumbs.map((c) => c.position)).toEqual([1, 2, 3]); + expect(crumbs.every((c) => typeof c.item === "string")).toBe(true); + expect(crumbs.at(-1)).toMatchObject({ + name: "Plan gating", + item: `${SITE_URL}/docs/plan-gating`, + }); + }); +}); + +describe("plainText", () => { + it.each([ + ["code span", "a moving `latest` tag", "a moving latest tag"], + [ + "emphasis", + "an *external* and **bold** effect", + "an external and bold effect", + ], + ["plain", "already plain", "already plain"], + ])("%s: stripped to plain text", (_, input, want) => { + expect(plainText(input)).toBe(want); + }); +}); diff --git a/site/lib/structured-data.ts b/site/lib/structured-data.ts new file mode 100644 index 00000000..8bb04104 --- /dev/null +++ b/site/lib/structured-data.ts @@ -0,0 +1,126 @@ +// schema.org JSON-LD for search engines and agents, rendered by +// components/JsonLd.tsx. Pure builders, so the shapes are tested directly. +// +// Every page emits one @graph. The shared nodes (organization, website, the +// project) carry a stable @id and are linked by it. Consumers resolve @id only +// within a page, so every page that links one also includes it. +import type { Guide } from "./nav"; +import { + PUBLISHER, + REPO_URL, + SITE_DESCRIPTION, + SITE_NAME, + SITE_URL, +} from "./site"; + +const ORG_ID = `${PUBLISHER.url}/#organization`; +const WEBSITE_ID = `${SITE_URL}/#website`; +const SOFTWARE_ID = `${SITE_URL}/#software`; + +type Node = Record<string, unknown>; + +export function graph(...nodes: Node[]): Node { + return { "@context": "https://schema.org", "@graph": nodes }; +} + +export function organization(): Node { + return { + "@type": "Organization", + "@id": ORG_ID, + name: PUBLISHER.name, + url: PUBLISHER.url, + sameAs: ["https://github.com/authzed"], + }; +} + +export function website(): Node { + return { + "@type": "WebSite", + "@id": WEBSITE_ID, + name: SITE_NAME, + alternateName: "OAP", + url: `${SITE_URL}/`, + description: SITE_DESCRIPTION, + inLanguage: "en", + publisher: { "@id": ORG_ID }, + }; +} + +// The project itself. SoftwareSourceCode rather than SoftwareApplication: the +// latter's rich result wants ratings and a price, and OAP has neither to +// state honestly. +export function software(): Node { + return { + "@type": "SoftwareSourceCode", + "@id": SOFTWARE_ID, + name: SITE_NAME, + alternateName: "OAP", + description: SITE_DESCRIPTION, + url: `${SITE_URL}/`, + codeRepository: REPO_URL, + license: "https://www.apache.org/licenses/LICENSE-2.0", + programmingLanguage: "Go", + runtimePlatform: "Kubernetes", + keywords: [ + "AI agents", + "agent security", + "authorization", + "SpiceDB", + "Kubernetes", + "MCP", + ], + author: { "@id": ORG_ID }, + publisher: { "@id": ORG_ID }, + }; +} + +export function landingGraph(): Node { + return graph(organization(), website(), software()); +} + +// A docs page: the article, and the breadcrumb a result shows in place of the +// URL. Sections and groups have no page of their own, so the trail is +// Home › Docs › page; a crumb without a URL is not allowed except last. +export function guideGraph( + guide: Guide, + description: string, + image: string, +): Node { + const url = `${SITE_URL}/docs/${guide.slug}`; + return graph( + organization(), + website(), + software(), + { + "@type": "TechArticle", + "@id": `${url}#article`, + headline: guide.title, + description, + url, + mainEntityOfPage: url, + image: `${SITE_URL}${image}`, + articleSection: guide.group || guide.section, + inLanguage: "en", + isPartOf: { "@id": WEBSITE_ID }, + about: { "@id": SOFTWARE_ID }, + publisher: { "@id": ORG_ID }, + }, + { + "@type": "BreadcrumbList", + itemListElement: [ + { name: SITE_NAME, item: `${SITE_URL}/` }, + { name: "Docs", item: `${SITE_URL}/docs` }, + { name: guide.title, item: url }, + ].map((crumb, i) => ({ "@type": "ListItem", position: i + 1, ...crumb })), + }, + ); +} + +// Meta descriptions are plain text; guide descriptions may carry Markdown +// code spans or emphasis (`latest`, *external*). +export function plainText(s: string): string { + return s + .replace(/`([^`]*)`/g, "$1") + .replace(/\*\*([^*]+)\*\*/g, "$1") + .replace(/\*([^*]+)\*/g, "$1"); +} diff --git a/site/next.config.mjs b/site/next.config.mjs index cbe17ff5..bf207018 100644 --- a/site/next.config.mjs +++ b/site/next.config.mjs @@ -52,7 +52,28 @@ const securityHeaders = [ /** @type {import('next').NextConfig} */ const nextConfig = { async headers() { - return [{ source: "/:path*", headers: securityHeaders }]; + return [ + { source: "/:path*", headers: securityHeaders }, + // A docs URL answers with HTML or Markdown depending on Accept (see + // rewrites), so a cache must key on it. + { source: "/docs/:slug", headers: [{ key: "Vary", value: "Accept" }] }, + ]; + }, + // Each guide's Markdown rendition (app/md/[slug]) lives beside its HTML + // page: /docs/<slug>.md, or /docs/<slug> asked for as text/markdown, the + // content negotiation agents use. beforeFiles, so it wins over the + // prerendered HTML. + async rewrites() { + return { + beforeFiles: [ + { source: "/docs/:slug.md", destination: "/md/:slug" }, + { + source: "/docs/:slug", + has: [{ type: "header", key: "accept", value: ".*text/markdown.*" }], + destination: "/md/:slug", + }, + ], + }; }, pageExtensions: ["ts", "tsx", "mdx"], turbopack: { root: repoRoot }, diff --git a/site/scripts/check.ts b/site/scripts/check.ts index 657548b6..c8c954eb 100644 --- a/site/scripts/check.ts +++ b/site/scripts/check.ts @@ -164,6 +164,15 @@ for (const p of checkLedeTags( )) err(`${p.file}: uses <p className="doc-lede">; use <div> instead`); +// 6: every guide has a meta description. It is the page's search snippet, +// its share-card text, and its line in /llms.txt; without one the page falls +// back to the site-wide description and reads as a duplicate of every other. +for (const [slug, src] of guideSources) { + const meta = /^export const meta = \{[\s\S]*?^\}/m.exec(src)?.[0] ?? ""; + if (!/\bdescription:\s*\S/.test(meta)) + err(`content/docs/${slug}.mdx: meta has no description`); +} + if (errors.length) { console.error(`check FAILED with ${errors.length} problem(s):`); for (const e of errors) console.error(` ✗ ${e}`);