Files
zeekay 1ef51e6401 hanzo.bot: its own index.md, robots.txt, sitemap.xml and llms.txt
scripts/host.ts, run by deploy.yml's bot step on out-bot: the twin of the
page the host is rooted at, hanzo.ai's robots groups naming hanzo.bot and its
sitemap, a sitemap of its one page, and an llms.txt saying what Hanzo Bot is.
2026-10-27 01:28:45 -07:00

98 lines
4.9 KiB
TypeScript

/**
* What a machine reads on a host deploy.yml roots at one page of this export:
* `node scripts/host.ts <dir> <origin> <page>`, after `<dir>` is a hardlinked
* copy of out/ with `<page>.html` at its index.html.
*
* index.md the twin of the page it draws (its canonical's), what the edge
* answers `Accept: text/markdown` on `/` with; index.html names it
* robots.txt out/robots.txt, naming this host and its sitemap
* sitemap.xml this host's one page, dated as out/sitemap.xml dates the page it draws
* llms.txt ABOUT[origin], then the page
*
* Every other path on the host is hanzo.ai's page and answers as hanzo.ai's does.
* `<dir>` shares inodes with out/, so each file is unlinked before it is written.
*/
import { copyFileSync, existsSync, readFileSync, rmSync, writeFileSync } from 'node:fs'
import { join } from 'node:path'
const OUT = 'out'
/** Each rooted host's own words for llms.txt: what it is and where an agent goes. */
const ABOUT: Record<string, { name: string; text: string }> = {
'https://hanzo.bot': {
name: 'Hanzo Bot',
text: `Hanzo Bot is AI teammates with computers of their own: each bot runs in its own sandbox with a browser, a terminal and your apps, signs in to your tools, and comes back when the work is done. It is open source (MIT) and runs on your own machine, in Hanzo's cloud, or both. hanzo.bot is its front door; the product page is https://hanzo.ai/bot.
## For agents
- This page as markdown: https://hanzo.bot/index.md
- Install on your machine: \`npm install -g @hanzo/bot\`, then \`hanzo-bot onboard --install-daemon\`
- Source: https://github.com/hanzoai/bot. Docs: https://docs.hanzo.bot
- The Bot API is the \`bot\` capability of https://api.hanzo.ai/v1: https://api.hanzo.ai/v1/bot, with schemas at https://api.hanzo.ai/v1/openapi.json?capability=bot. Every operation: https://api.hanzo.ai/v1/openapi.json
- How an agent gets a credential (wallet sign-in, device flow, API keys): https://hanzoskills.com/auth.md
- The skill an agent reads to use Hanzo: https://hanzo.bot/skill.md
- API docs: https://docs.hanzo.ai and https://docs.hanzo.ai/llms.txt
- Every other page on this host is hanzo.ai's, listed at https://hanzo.ai/llms.txt`,
},
}
const [dir, origin, page] = process.argv.slice(2)
const about = ABOUT[origin]
if (!dir || !page || !about) throw new Error(`usage: node scripts/host.ts <dir> <${Object.keys(ABOUT).join('|')}> <page>`)
/** Replace a file of the hardlinked copy without writing through to out/. */
const put = (name: string, body: string) => {
rmSync(join(dir, name), { force: true })
writeFileSync(join(dir, name), body)
}
const read = (path: string): string => {
if (!existsSync(path)) throw new Error(`[host] ${path} is missing from the export`)
return readFileSync(path, 'utf8')
}
const html = read(join(OUT, `${page}.html`))
if (read(join(dir, 'index.html')) !== html) throw new Error(`[host] ${dir}/index.html is not ${page}.html`)
const esc = (s: string) => s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
// A rooted page is an alternate of its canonical, so the canonical's twin is its own.
const canonical = html.match(/<link rel="canonical" href="(https:\/\/hanzo\.ai[^"]*)"/)?.[1]
if (!canonical) throw new Error(`[host] ${page}.html names no hanzo.ai canonical`)
const drawn = new URL(canonical).pathname.replace(/^\/$/, '/index')
rmSync(join(dir, 'index.md'), { force: true })
read(join(OUT, `${drawn}.md`))
copyFileSync(join(OUT, `${drawn}.md`), join(dir, 'index.md'))
const link = '<link rel="alternate" type="text/markdown" href="/index.md"/>'
const own = new RegExp(`<link rel="alternate" type="text/markdown" href="/${esc(page)}\\.md"/>`)
put('index.html', own.test(html) ? html.replace(own, link) : html.replace('</head>', `${link}</head>`))
// hanzo.ai's groups, this host's name.
const robots = read(join(OUT, 'robots.txt'))
if (!/^User-Agent:/im.test(robots)) throw new Error('[host] out/robots.txt has no User-Agent group')
put(
'robots.txt',
`${robots
.split('\n')
.filter((line) => !/^(Host|Sitemap):/i.test(line))
.join('\n')
.trimEnd()}\n\nHost: ${origin}\nSitemap: ${origin}/sitemap.xml\n`,
)
// Dated as the sitemap dates the page it draws.
const lastmod = read(join(OUT, 'sitemap.xml')).match(new RegExp(`<loc>${esc(canonical)}</loc>\\s*<lastmod>([^<]+)</lastmod>`))?.[1]
put(
'sitemap.xml',
`<?xml version="1.0" encoding="UTF-8"?>\n<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">\n<url><loc>${origin}/</loc>${lastmod ? `<lastmod>${lastmod}</lastmod>` : ''}</url>\n</urlset>\n`,
)
const description = html
.match(/<meta name="description" content="([^"]*)"/)?.[1]
?.replace(/&#x27;|&#39;/g, "'")
.replace(/&quot;/g, '"')
.replace(/&amp;/g, '&')
if (!description) throw new Error(`[host] ${page}.html has no description`)
put('llms.txt', `# ${about.name}\n\n> ${description}\n\n${about.text}\n`)
console.log(`[host] ${origin}: index.md from ${drawn.slice(1)}.md, robots.txt, sitemap.xml${lastmod ? ` (${lastmod})` : ''}, llms.txt`)