From efa1dd16ccfdd4c774e648b530b5b6ad21810915 Mon Sep 17 00:00:00 2001 From: sepehr-safari Date: Sat, 19 Sep 2026 22:31:39 +0300 Subject: [PATCH 1/2] feat: serve a markdown twin of each page An assistant fetching a page gets markup it has to strip before it can read anything, and what it strips is the part carrying the meaning: which text was a heading, which was a table cell, which was a link. The twin hands it the structure instead of making it infer one from tags it just removed. Generated from the built HTML rather than written by hand. A twin that drifts from the page is worse than no twin, because it says something the site does not while carrying the site's authority. The pages stay the only source; this is a second rendering of them, produced by the same build that produces the first. The tables survive as markdown tables, which matters more here than anywhere else on the site: the results page is mostly numbers, and a table flattened into prose loses which figure belongs to which threshold. Linked from each page with rel="alternate", and from llms.txt, so a reader that prefers it can find it from either direction. Not added to the sitemap: these are a second representation of pages already listed, not pages of their own, and listing both would ask a crawler to treat one piece of content as two. Vercel is told to serve them as text/markdown, or a browser offers to download the file instead of showing it, which makes the link useless to a person checking what an assistant would see. CI fails if a twin is missing, too small to be the page it claims to be, or still carrying HTML tags. The conversion breaking quietly is the failure worth catching: an empty twin tells a reader the page says almost nothing. --- .github/workflows/ci.yml | 23 ++++++ package.json | 2 +- public/llms.txt | 5 ++ scripts/make-markdown.mjs | 169 ++++++++++++++++++++++++++++++++++++++ src/layouts/Base.astro | 11 +++ vercel.json | 46 +++++++++-- 6 files changed, 249 insertions(+), 7 deletions(-) create mode 100644 scripts/make-markdown.mjs diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 33c9c7f..eb06b85 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -49,6 +49,29 @@ jobs: - name: No word runs into a tag run: pnpm check:spacing + - name: The markdown twins are real + # Generated from the built HTML, so they cannot drift from the pages. + # What they can do is come out empty if the conversion breaks, and an + # empty twin tells a reader the page says almost nothing. + run: | + for page in index results; do + file="dist/$page.md" + if [ ! -f "$file" ]; then + echo "$file is missing, so the build did not produce a twin." >&2 + exit 1 + fi + if [ "$(wc -c < "$file")" -lt 1000 ]; then + echo "$file is too small to be the page it claims to be." >&2 + exit 1 + fi + if grep -qE '' "$file"; then + echo "$file still carries HTML tags, so the conversion is wrong:" >&2 + grep -nE '' "$file" | head -3 >&2 + exit 1 + fi + done + echo "Both twins are present and readable." + - name: No client JavaScript ships # The site's Content-Security-Policy says script-src 'none'. If a build # ever emits a script this fails here rather than in a browser console diff --git a/package.json b/package.json index 26faf5e..82a61c0 100644 --- a/package.json +++ b/package.json @@ -11,7 +11,7 @@ }, "scripts": { "dev": "astro dev", - "build": "astro build", + "build": "astro build && node scripts/make-markdown.mjs", "check:spacing": "node scripts/check-spacing.mjs", "preview": "astro preview", "typecheck": "astro check", diff --git a/public/llms.txt b/public/llms.txt index 85a73bb..583a981 100644 --- a/public/llms.txt +++ b/public/llms.txt @@ -11,6 +11,11 @@ Every judgment comes back as a probability rather than a verdict. Thresholds liv - What it is not: a sandbox. It sees what a call says it will do and cannot stop a server doing something the call did not describe. - What leaves the machine: the arguments and results being screened go to the configured model backend. Secret-shaped strings are replaced first, and screening can be turned off per server. +## This site, as markdown + +- [Overview](https://agentchaperone.dev/index.md): what it screens, what it is not, how it compares, and the shadow-to-enforce path +- [Measured results](https://agentchaperone.dev/results.md): the same tables as the HTML page + ## Docs - [README](https://raw.githubusercontent.com/agent-chaperone/agent-chaperone/main/README.md): install, the two screens, the commands, and the measured results diff --git a/scripts/make-markdown.mjs b/scripts/make-markdown.mjs new file mode 100644 index 0000000..1367ea0 --- /dev/null +++ b/scripts/make-markdown.mjs @@ -0,0 +1,169 @@ +/** + * A markdown twin of every built page. + * + * An assistant fetching a page gets markup it has to strip before it can read + * anything, and what it strips is the part carrying the meaning: which text was + * a heading, which was a table cell, which was a link. Serving the same content + * as markdown hands it the structure instead of making it infer one. + * + * Generated from the built HTML rather than written by hand, because a twin + * that drifts from the page is worse than no twin: it says something the site + * does not, with the site's authority. Nothing here is a second source of + * truth; the pages remain the only one. + * + * Run after `astro build`. Writes dist/index.md beside dist/index.html. + */ + +import { readFileSync, readdirSync, writeFileSync } from 'node:fs'; +import { join } from 'node:path'; + +const DIST = 'dist'; +const SITE = 'https://agentchaperone.dev'; + +/** Entities the pages actually produce, decoded so the markdown reads as text. */ +const ENTITIES = { + '&': '&', + '<': '<', + '>': '>', + '"': '"', + ''': "'", + ' ': ' ', + '—': '-', + '–': '-', +}; + +function decode(text) { + return text.replace(/&[a-z#0-9]+;/gi, (found) => ENTITIES[found] ?? found); +} + +/** Tags stripped to their text, with the markdown that carries the same meaning. */ +function inline(html) { + return decode( + html + .replace(/]*>(.*?)<\/code>/gis, (_, inner) => `\`${inner.replace(/<[^>]*>/g, '')}\``) + .replace(/<(?:b|strong)[^>]*>(.*?)<\/(?:b|strong)>/gis, '**$1**') + .replace(/<(?:i|em)[^>]*>(.*?)<\/(?:i|em)>/gis, '_$1_') + // A link keeps its destination. Relative ones are made absolute, since a + // reader of the markdown has no page to resolve them against. + .replace(/]*href="([^"]*)"[^>]*>(.*?)<\/a>/gis, (_, href, text) => { + const clean = text.replace(/<[^>]*>/g, '').trim(); + if (clean === '' || clean === '#') { + return ''; + } + const url = href.startsWith('/') ? `${SITE}${href}` : href; + return url.startsWith('#') ? clean : `[${clean}](${url})`; + }) + .replace(/<[^>]*>/g, ''), + ) + .replace(/[ \t]+/g, ' ') + .trim(); +} + +function cells(row, tag) { + return [...row.matchAll(new RegExp(`<${tag}[^>]*>(.*?)`, 'gis'))].map((m) => + inline(m[1]).replace(/\|/g, '\\|'), + ); +} + +/** One table, as a markdown table. The tables here carry the numbers. */ +function table(html) { + const rows = [...html.matchAll(/]*>(.*?)<\/tr>/gis)].map((m) => m[1]); + if (rows.length === 0) { + return ''; + } + const head = cells(rows[0], 'th'); + const body = rows.slice(head.length > 0 ? 1 : 0).map((row) => cells(row, 'td')); + const width = Math.max(head.length, ...body.map((r) => r.length), 1); + const pad = (r) => [...r, ...Array(width - r.length).fill('')]; + const out = []; + out.push(`| ${pad(head.length > 0 ? head : Array(width).fill('')).join(' | ')} |`); + out.push(`| ${Array(width).fill('---').join(' | ')} |`); + for (const row of body) { + if (row.length > 0) { + out.push(`| ${pad(row).join(' | ')} |`); + } + } + return out.join('\n'); +} + +function convert(html) { + const main = /]*>(.*?)<\/main>/is.exec(html)?.[1] ?? ''; + const out = []; + + // Block elements in the order they appear, so the twin reads in page order. + const blocks = main.matchAll( + /<(h1|h2|h3|h4|p|pre|ul|ol|table)\b[^>]*>(.*?)<\/\1>/gis, + ); + + for (const [, tag, inner] of blocks) { + if (tag === 'pre') { + const code = decode(inner.replace(/<[^>]*>/g, '')).trim(); + out.push(`\`\`\`\n${code}\n\`\`\``); + continue; + } + if (tag === 'table') { + const rendered = table(inner); + if (rendered !== '') { + out.push(rendered); + } + continue; + } + if (tag === 'ul' || tag === 'ol') { + const items = [...inner.matchAll(/]*>(.*?)<\/li>/gis)] + .map((m) => inline(m[1])) + .filter((one) => one !== ''); + if (items.length > 0) { + out.push(items.map((one, at) => (tag === 'ol' ? `${at + 1}. ${one}` : `- ${one}`)).join('\n')); + } + continue; + } + const text = inline(inner); + if (text === '') { + continue; + } + const level = { h1: '# ', h2: '## ', h3: '### ', h4: '#### ' }[tag] ?? ''; + out.push(`${level}${text}`); + } + + return out.join('\n\n'); +} + +function main() { + const pages = readdirSync(DIST).filter((name) => name.endsWith('.html') && name !== '404.html'); + let written = 0; + for (const page of pages) { + const html = readFileSync(join(DIST, page), 'utf8'); + const title = /(.*?)<\/title>/is.exec(html)?.[1] ?? ''; + const description = /<meta name="description" content="([^"]*)"/i.exec(html)?.[1] ?? ''; + const path = page === 'index.html' ? '/' : `/${page.replace(/\.html$/, '')}`; + const body = convert(html); + + if (body.length < 200) { + // A twin that is nearly empty means the conversion missed the content, and + // shipping it would tell a reader this page says almost nothing. + throw new Error(`${page}: the markdown twin came out empty, so the conversion is wrong`); + } + + const front = [ + `<!-- Generated from ${path} by scripts/make-markdown.mjs. The page is the source. -->`, + '', + body, + '', + '---', + '', + `Source: ${SITE}${path}`, + decode(description) === '' ? '' : decode(description), + ] + .filter((one) => one !== undefined) + .join('\n'); + + writeFileSync(join(DIST, page.replace(/\.html$/, '.md')), `${front}\n`); + written += 1; + console.log(`${path} -> ${page.replace(/\.html$/, '.md')} (${body.length} chars, "${title}")`); + } + if (written === 0) { + throw new Error('no pages were converted, which means dist/ was not built'); + } +} + +main(); diff --git a/src/layouts/Base.astro b/src/layouts/Base.astro index d9b292f..8af6f34 100644 --- a/src/layouts/Base.astro +++ b/src/layouts/Base.astro @@ -28,6 +28,17 @@ const updated = RUN.date; <title>{title} + {/* The same content as markdown, for a reader that would rather not strip + markup to find the structure. */} + diff --git a/vercel.json b/vercel.json index d5a5616..b74564c 100644 --- a/vercel.json +++ b/vercel.json @@ -14,19 +14,49 @@ "key": "Strict-Transport-Security", "value": "max-age=63072000; includeSubDomains; preload" }, - { "key": "X-Content-Type-Options", "value": "nosniff" }, - { "key": "Referrer-Policy", "value": "no-referrer" }, - { "key": "X-Frame-Options", "value": "DENY" }, + { + "key": "X-Content-Type-Options", + "value": "nosniff" + }, + { + "key": "Referrer-Policy", + "value": "no-referrer" + }, + { + "key": "X-Frame-Options", + "value": "DENY" + }, { "key": "Permissions-Policy", "value": "accelerometer=(), camera=(), geolocation=(), gyroscope=(), magnetometer=(), microphone=(), payment=(), usb=(), interest-cohort=()" }, - { "key": "Cross-Origin-Opener-Policy", "value": "same-origin" } + { + "key": "Cross-Origin-Opener-Policy", + "value": "same-origin" + } + ] + }, + { + "source": "/(.*).md", + "headers": [ + { + "key": "Content-Type", + "value": "text/markdown; charset=utf-8" + }, + { + "key": "Cache-Control", + "value": "public, max-age=0, must-revalidate" + } ] }, { "source": "/_astro/(.*)", - "headers": [{ "key": "Cache-Control", "value": "public, max-age=31536000, immutable" }] + "headers": [ + { + "key": "Cache-Control", + "value": "public, max-age=31536000, immutable" + } + ] } ], "redirects": [ @@ -60,6 +90,10 @@ "destination": "https://github.com/agent-chaperone/agent-chaperone/blob/main/ROADMAP.md", "permanent": true }, - { "source": "/benchmark", "destination": "/results", "permanent": true } + { + "source": "/benchmark", + "destination": "/results", + "permanent": true + } ] } From 92907e90c452185339a22aed5ae877d3becaf83b Mon Sep 17 00:00:00 2001 From: sepehr-safari Date: Sat, 19 Sep 2026 22:32:49 +0300 Subject: [PATCH 2/2] style: format the markdown converter Formatted the files I had named instead of the repository, so the new script was the one thing prettier never saw. --- scripts/make-markdown.mjs | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/scripts/make-markdown.mjs b/scripts/make-markdown.mjs index 1367ea0..5d4ff51 100644 --- a/scripts/make-markdown.mjs +++ b/scripts/make-markdown.mjs @@ -91,9 +91,7 @@ function convert(html) { const out = []; // Block elements in the order they appear, so the twin reads in page order. - const blocks = main.matchAll( - /<(h1|h2|h3|h4|p|pre|ul|ol|table)\b[^>]*>(.*?)<\/\1>/gis, - ); + const blocks = main.matchAll(/<(h1|h2|h3|h4|p|pre|ul|ol|table)\b[^>]*>(.*?)<\/\1>/gis); for (const [, tag, inner] of blocks) { if (tag === 'pre') { @@ -113,7 +111,9 @@ function convert(html) { .map((m) => inline(m[1])) .filter((one) => one !== ''); if (items.length > 0) { - out.push(items.map((one, at) => (tag === 'ol' ? `${at + 1}. ${one}` : `- ${one}`)).join('\n')); + out.push( + items.map((one, at) => (tag === 'ol' ? `${at + 1}. ${one}` : `- ${one}`)).join('\n'), + ); } continue; }