mirror of
https://github.com/Abdess/retroarch_system.git
synced 2026-11-13 13:33:52 -06:00
feat: guide AI agents to the fewest files
llms.txt and an ARD manifest name the pages and the JSON API, and lead an agent setting up a device to --for-games --required-only. Every page registers four read-only WebMCP tools: the setup command, a hash lookup, an emulator's files and the site search.
This commit is contained in:
1 parent
072380c268
commit
24d7db0ab3
8 files changed
+432
-3
No files matched your search
@@ -45,6 +45,7 @@ on:
|
||||
- "scripts/restore_large_files.py"
|
||||
- "scripts/safeparse.py"
|
||||
- "scripts/sect233r1.py"
|
||||
- "scripts/siteagents.py"
|
||||
- "scripts/sitebootstrap.py"
|
||||
- "scripts/sitediagrams.py"
|
||||
- "scripts/siteentries.py"
|
||||
|
||||
@@ -32,7 +32,7 @@ import urllib.parse
|
||||
import urllib.request
|
||||
import zipfile
|
||||
from collections import Counter
|
||||
from collections.abc import Callable, Iterator
|
||||
from collections.abc import Callable, Iterable, Iterator
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
from typing import TypeVar
|
||||
@@ -92,6 +92,7 @@ import sitemarkup as markup
|
||||
import siteentries
|
||||
import siteinputs
|
||||
import retired_files
|
||||
import siteagents
|
||||
import sitestarlight
|
||||
from siteexports import generate_exports_page, load_export_reports
|
||||
from siteguides import (
|
||||
@@ -8065,7 +8066,7 @@ def generate_emulators_index(profiles: dict) -> str:
|
||||
return "\n".join(lines) + "\n"
|
||||
|
||||
|
||||
def _snippet_noun(names, firmware: int, fetched: int) -> str:
|
||||
def _snippet_noun(names: Iterable[str], firmware: int, fetched: int) -> str:
|
||||
"""What an emulator's files are called in its snippet. A Switch
|
||||
emulator reads keys, and "eden keys" is what readers search for; Ship
|
||||
of Harkinian reads game ROMs, which are no BIOS."""
|
||||
@@ -12528,6 +12529,9 @@ def _generate(args: argparse.Namespace) -> None:
|
||||
stats = compute_stats(db, coverages, profiles)
|
||||
stats["composition"] = compute_composition(db)
|
||||
write_if_changed(str(public / "stats.json"), generate_stats(stats))
|
||||
# llms.txt and the ARD manifest an agent reads first, from the registry.
|
||||
siteagents.write(public, sorted(((name, cov["platform"]) for name, cov in coverages.items()),
|
||||
key=lambda row: row[1].casefold()), write_if_changed)
|
||||
|
||||
# Computed once: the gap analysis page and the published gaps export must
|
||||
# report the same files.
|
||||
|
||||
@@ -0,0 +1,157 @@
|
||||
"""What the site tells an AI agent about itself.
|
||||
|
||||
- ``llms.txt`` (https://llmstxt.org): the pages and data an agent should
|
||||
read first, in Markdown, at the site's root.
|
||||
- ``ard.json``: an Agentic Resource Discovery manifest
|
||||
(https://github.com/ards-project/ard-spec) listing that file and the JSON
|
||||
API, linked from every page's head with ``rel="ard"``.
|
||||
|
||||
Both are written from the registry at each build, so a new platform or a
|
||||
moved page cannot leave them stale. The WebMCP tools a page registers live
|
||||
in website/src/scripts/agent-tools.ts.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from collections.abc import Callable
|
||||
from pathlib import Path
|
||||
|
||||
SITE = "https://abdess.github.io/retrobios/"
|
||||
PUBLISHER = "abdess.github.io"
|
||||
SH = "curl -fsSL https://abdess.github.io/retrobios/i | sh -s --"
|
||||
PS = "& ([scriptblock]::Create((irm https://abdess.github.io/retrobios/i.ps1)))"
|
||||
|
||||
START_PAGES = (
|
||||
("Which pack do I need", "which-pack/", "pick a device or setup, get the folder its BIOS files go in, "
|
||||
"one command that installs and checks them, or a ZIP pack"),
|
||||
("First install", "wiki/first-install/", "the steps from nothing to a working BIOS folder"),
|
||||
("What are BIOS files", "wiki/what-are-bios-files/", "why emulators need them and where they come from"),
|
||||
("Troubleshooting", "wiki/troubleshooting/", "a file the emulator does not find or refuses"),
|
||||
("Common questions", "wiki/faq/", "short answers"),
|
||||
)
|
||||
|
||||
DATA = (
|
||||
("API index", "api/v1/index.json", "every dataset of the JSON API, versioned"),
|
||||
("Catalog", "api/v1/catalog.json", "each dataset, manifest, export and download with its size, SHA-256 and schema"),
|
||||
("Emulators", "api/v1/emulators.json", "every emulator profile: the files it reads, required or not, "
|
||||
"their hashes and the source line that reads each. One document "
|
||||
"per emulator at api/v1/emulators/<id>.json"),
|
||||
("Core names", "api/v1/cores.json", "each core name and alias, with the profile it designates"),
|
||||
("Systems", "api/v1/systems.json", "each system id, its name and the section holding its files"),
|
||||
("Platforms", "api/v1/platforms.json", "each setup (RetroArch, Batocera...), its BIOS folder and files"),
|
||||
("Hash lookup", "api/v1/lookup/00.json", "what a hash (MD5, SHA-1, SHA-256, CRC32) designates, sharded "
|
||||
"by its first two hex characters: api/v1/lookup/<xx>.json"),
|
||||
)
|
||||
|
||||
MORE = (
|
||||
("Emulators", "emulators/", "one page per emulator"),
|
||||
("Systems", "systems/", "one page per console maker, every file with its hashes"),
|
||||
("Installer", "wiki/installer/", "every option of install.py"),
|
||||
("What we count", "wiki/privacy/", "the site's cookie-free counting"),
|
||||
("Is it legal?", "wiki/legal/", "the project's position"),
|
||||
("Contributing", "contributing/", "add a missing file or an emulator profile"),
|
||||
)
|
||||
|
||||
|
||||
def llms_txt(platforms: list[tuple[str, str]]) -> str:
|
||||
"""The site's llms.txt. ``platforms`` lists (id, display name) of every
|
||||
registered setup, each with its page."""
|
||||
lines = [
|
||||
"# RetroBIOS",
|
||||
"",
|
||||
"> BIOS and firmware files for emulators, each checked against the emulator's source "
|
||||
"code: which files an emulator reads, whether it starts without them, where each one "
|
||||
"goes on every setup, their hashes, and an installer that puts them in place.",
|
||||
"",
|
||||
"Every claim about a file cites the line of emulator source code that reads it. The "
|
||||
"files themselves are served by the GitHub repository "
|
||||
"(https://github.com/Abdess/retrobios) and its releases.",
|
||||
"",
|
||||
"## Setting up a device for its user",
|
||||
"",
|
||||
"Most people want to play now, with the fewest files. The installer does that in one "
|
||||
"command: it finds the setup and its BIOS folder, reads the games on the card, and with "
|
||||
"`--for-games --required-only` installs only the files those games cannot start "
|
||||
"without. Without these two options it installs every file of the setup's list "
|
||||
"(gigabytes), even with `--yes`.",
|
||||
"",
|
||||
f"1. Preview, nothing written: `{SH} --for-games --required-only --dry-run`",
|
||||
f"2. Install, no questions: `{SH} --for-games --required-only --yes`",
|
||||
f"3. On Windows (PowerShell): `{PS} --for-games --required-only --yes`",
|
||||
"",
|
||||
"- Run it on the device itself (its terminal or SSH), or on a computer with the device's "
|
||||
"card plugged in. The installer recognises the handheld systems on the card, among them "
|
||||
"ArkOS, dArkOS, ArchR and UnofficialOS (R36S and its clones), muOS, KNULLI, ROCKNIX, "
|
||||
"AmberELEC, EmuELEC, Onion, spruceOS and MinUI. "
|
||||
"The [Linux handhelds page](" + SITE + "platforms/linux-handhelds/) maps each device "
|
||||
"to its system and folder.",
|
||||
"- From a computer, if the card is not found, name its BIOS folder: `--dest <card>/bios` "
|
||||
"(ArkOS and dArkOS: the `bios` folder of the EASYROMS part).",
|
||||
"- A games folder somewhere else: `--for-games <folder>`, one subfolder per system.",
|
||||
"- Later, to see what is missing or wrong: `--check`. Machine-readable progress: "
|
||||
"`--events` (JSON lines, one per event, documented on the installer page).",
|
||||
"- A game the installer cannot identify gets every BIOS of its system, and the run names it.",
|
||||
"",
|
||||
"## Start",
|
||||
"",
|
||||
*(f"- [{title}]({SITE}{path}): {what}" for title, path, what in START_PAGES),
|
||||
"",
|
||||
"## Setups",
|
||||
"",
|
||||
*(f"- [{name}]({SITE}platforms/{pid}/): its BIOS folder and files" for pid, name in platforms),
|
||||
"",
|
||||
"## Data",
|
||||
"",
|
||||
*(f"- [{title}]({SITE}{path}): {what}" for title, path, what in DATA),
|
||||
"",
|
||||
"## Optional",
|
||||
"",
|
||||
*(f"- [{title}]({SITE}{path}): {what}" for title, path, what in MORE),
|
||||
]
|
||||
return "\n".join(lines) + "\n"
|
||||
|
||||
|
||||
def ard_manifest() -> dict:
|
||||
"""The ARD manifest: the llms.txt and the JSON API, each with the
|
||||
questions it answers."""
|
||||
return {
|
||||
"entries": [
|
||||
{
|
||||
"identifier": f"urn:air:{PUBLISHER}:retrobios:guide",
|
||||
"displayName": "RetroBIOS guide for agents",
|
||||
"type": "text/markdown",
|
||||
"url": f"{SITE}llms.txt",
|
||||
"description": "The pages and datasets of RetroBIOS: BIOS and firmware files for "
|
||||
"emulators, checked against each emulator's source code.",
|
||||
"representativeQueries": [
|
||||
"install the minimum BIOS files for the games on my R36S running dArkOS",
|
||||
"which BIOS files does mGBA need",
|
||||
"where do PlayStation BIOS files go on Batocera",
|
||||
"how do I install BIOS files for RetroArch",
|
||||
],
|
||||
"tags": ["bios", "firmware", "emulation", "retrogaming"],
|
||||
},
|
||||
{
|
||||
"identifier": f"urn:air:{PUBLISHER}:retrobios:api",
|
||||
"displayName": "RetroBIOS JSON API",
|
||||
"type": "application/json",
|
||||
"url": f"{SITE}api/v1/index.json",
|
||||
"description": "Versioned JSON datasets: emulator profiles, platforms, systems, core "
|
||||
"names and a hash lookup.",
|
||||
"representativeQueries": [
|
||||
"what file has the MD5 490f666e1afb15b7362b406ed1cea246",
|
||||
"which files does the beetle_psx core read",
|
||||
"list the systems Batocera supports",
|
||||
],
|
||||
"capabilities": ["HashLookup", "EmulatorFiles", "PlatformFiles"],
|
||||
"tags": ["api", "json", "bios"],
|
||||
},
|
||||
]
|
||||
}
|
||||
|
||||
|
||||
def write(public: Path, platforms: list[tuple[str, str]],
|
||||
write_if_changed: Callable[[str, str], object]) -> None:
|
||||
write_if_changed(str(public / "llms.txt"), llms_txt(platforms))
|
||||
write_if_changed(str(public / "ard.json"), json.dumps(ard_manifest(), indent=1) + "\n")
|
||||
@@ -0,0 +1,93 @@
|
||||
"""What the site tells an AI agent: llms.txt, the ARD manifest, the WebMCP tools.
|
||||
|
||||
An agent asked to set up a handheld reads these first. They must lead it to
|
||||
the fewest files the games need, and every address they give must exist.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import tests # noqa: F401 offline, scratch under tmp/, the suite's own cache
|
||||
|
||||
import re
|
||||
import sys
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
|
||||
REPO_ROOT = Path(__file__).resolve().parent.parent
|
||||
sys.path.insert(0, str(REPO_ROOT / "scripts"))
|
||||
|
||||
import siteagents # noqa: E402
|
||||
|
||||
DIST = REPO_ROOT / "website" / "dist"
|
||||
TOOLS = REPO_ROOT / "website" / "src" / "scripts" / "agent-tools.ts"
|
||||
FOOTER = REPO_ROOT / "website" / "src" / "components" / "Footer.astro"
|
||||
CONFIG = REPO_ROOT / "website" / "astro.config.mjs"
|
||||
|
||||
|
||||
class TheGuideLeadsToTheFewestFiles(unittest.TestCase):
|
||||
def setUp(self) -> None:
|
||||
self.text = siteagents.llms_txt([("retroarch", "RetroArch"), ("batocera", "Batocera")])
|
||||
|
||||
def test_it_is_llms_txt(self) -> None:
|
||||
self.assertTrue(self.text.startswith("# RetroBIOS\n\n> "))
|
||||
self.assertIn("\n## Setting up a device for its user\n", self.text)
|
||||
|
||||
def test_every_command_installs_only_what_the_games_need(self) -> None:
|
||||
commands = re.findall(r"`((?:curl|&) [^`]+)`", self.text)
|
||||
self.assertGreaterEqual(len(commands), 3)
|
||||
for command in commands:
|
||||
with self.subTest(command=command):
|
||||
self.assertIn("--for-games --required-only", command)
|
||||
|
||||
def test_each_setup_has_its_page(self) -> None:
|
||||
self.assertIn("(https://abdess.github.io/retrobios/platforms/batocera/)", self.text)
|
||||
|
||||
@unittest.skipUnless((DIST / "llms.txt").is_file(), "website/dist is not built")
|
||||
def test_every_address_of_the_built_guide_exists(self) -> None:
|
||||
built = (DIST / "llms.txt").read_text(encoding="utf-8")
|
||||
for url in re.findall(r"\((https://abdess\.github\.io/retrobios/[^)]*)\)", built):
|
||||
with self.subTest(url=url):
|
||||
target = DIST / url.removeprefix(siteagents.SITE)
|
||||
self.assertTrue(target.is_file() or (target / "index.html").is_file(), url)
|
||||
|
||||
|
||||
class TheManifestIsAnArdManifest(unittest.TestCase):
|
||||
"""The terms ard-entry.schema.json requires (ards-project/ard-spec)."""
|
||||
|
||||
def test_entries(self) -> None:
|
||||
entries = siteagents.ard_manifest()["entries"]
|
||||
self.assertTrue(entries)
|
||||
for entry in entries:
|
||||
with self.subTest(entry=entry.get("identifier")):
|
||||
self.assertRegex(entry["identifier"], r"^urn:air:[a-zA-Z0-9.-]+(:[a-zA-Z0-9._-]+)+$")
|
||||
self.assertTrue(entry["displayName"])
|
||||
self.assertRegex(entry["type"], r"^[a-z]+/[\w.+-]+$")
|
||||
self.assertTrue(("url" in entry) != ("data" in entry))
|
||||
self.assertTrue(2 <= len(entry["representativeQueries"]) <= 5)
|
||||
self.assertTrue(entry["url"].startswith(siteagents.SITE))
|
||||
|
||||
def test_every_page_links_it(self) -> None:
|
||||
self.assertIn("rel: 'ard'", CONFIG.read_text(encoding="utf-8"))
|
||||
|
||||
|
||||
class TheToolsReadOnly(unittest.TestCase):
|
||||
def setUp(self) -> None:
|
||||
self.source = TOOLS.read_text(encoding="utf-8")
|
||||
|
||||
def test_every_page_registers_them(self) -> None:
|
||||
self.assertIn("import '../scripts/agent-tools.ts';", FOOTER.read_text(encoding="utf-8"))
|
||||
names = re.findall(r"^\s*name: '([a-z_]+)'", self.source, re.M)
|
||||
self.assertEqual(names, ["lookup_bios_hash", "emulator_bios_files", "search_retrobios",
|
||||
"device_setup_command"])
|
||||
self.assertEqual(self.source.count("readOnlyHint: true"), len(names))
|
||||
|
||||
def test_the_setup_command_defaults_to_the_games(self) -> None:
|
||||
self.assertIn("const games = everything ? [] :", self.source)
|
||||
self.assertIn("'--required-only'", self.source)
|
||||
|
||||
def test_they_reach_no_other_site(self) -> None:
|
||||
code = re.sub(r"^\s*//.*$", "", self.source, flags=re.M)
|
||||
self.assertEqual(set(re.findall(r"https://[\w.-]+", code)), {"https://abdess.github.io"})
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -77,6 +77,9 @@ export default defineConfig({
|
||||
// Ownership of the URL-prefix property https://abdess.github.io/retrobios/
|
||||
// in Google Search Console. Removing it unverifies the property.
|
||||
{ tag: 'meta', attrs: { name: 'google-site-verification', content: '8SueST3hNHl8_vM-OJJP0rz-ZMlI2DALLpChrwZoGMs' } },
|
||||
// What an AI agent reads first (scripts/siteagents.py): the ARD manifest lists
|
||||
// llms.txt and the JSON API.
|
||||
{ tag: 'link', attrs: { rel: 'ard', type: 'application/json', href: `${BASE}ard.json` } },
|
||||
],
|
||||
social: [{ icon: 'github', label: 'RetroBIOS on GitHub', href: 'https://github.com/Abdess/retrobios' }],
|
||||
customCss: ['./src/styles/retrobios.css'],
|
||||
|
||||
@@ -48,6 +48,7 @@ const date = releaseDate(tag);
|
||||
|
||||
<script>
|
||||
import '../scripts/count.ts';
|
||||
import '../scripts/agent-tools.ts';
|
||||
</script>
|
||||
|
||||
<style>
|
||||
|
||||
@@ -0,0 +1,170 @@
|
||||
// WebMCP tools (https://developer.chrome.com/docs/ai/webmcp): what an AI
|
||||
// agent browsing the site can ask it directly, instead of reading pages.
|
||||
// Each tool reads the JSON the site already publishes (api/v1/) or its
|
||||
// search index, changes nothing and sends nothing anywhere else. A browser
|
||||
// without WebMCP skips the whole file.
|
||||
|
||||
type ToolResult = string;
|
||||
type Tool = {
|
||||
name: string;
|
||||
description: string;
|
||||
inputSchema: Record<string, unknown>;
|
||||
annotations?: { readOnlyHint?: boolean };
|
||||
execute: (input: Record<string, unknown>) => Promise<ToolResult>;
|
||||
};
|
||||
type ModelContext = { registerTool: (tool: Tool) => Promise<void> | void };
|
||||
|
||||
const BASE = import.meta.env.BASE_URL.replace(/\/?$/, '/');
|
||||
const SITE = new URL(BASE, location.origin);
|
||||
const json = async <T>(path: string): Promise<T> => {
|
||||
const response = await fetch(new URL(path, SITE));
|
||||
if (!response.ok) throw new Error(`${path}: HTTP ${response.status}`);
|
||||
return response.json() as Promise<T>;
|
||||
};
|
||||
const absolute = (url: string) => new URL(url, location.origin).href;
|
||||
|
||||
type LookupItem = { algorithm: string; layer: string; name?: string; value: string; url: string; owner?: string; sha1?: string };
|
||||
type ProfileFile = Record<string, unknown> & { name?: string; required?: boolean };
|
||||
type EmulatorDoc = { items: { id: string; profile: { emulator?: string; systems?: string[]; files?: ProfileFile[] } }[] };
|
||||
type CoreIndex = { items: { name: string; profile: string; preferred?: boolean }[] };
|
||||
|
||||
const HASH = /^(?:[0-9a-f]{8}|[0-9a-f]{32}|[0-9a-f]{40}|[0-9a-f]{64})$/;
|
||||
|
||||
const lookupHash: Tool = {
|
||||
name: 'lookup_bios_hash',
|
||||
description:
|
||||
'Find which BIOS or firmware file a hash designates in the RetroBIOS collection, and the emulators or setups that '
|
||||
+ 'declare it. Accepts a CRC32 (8 hex characters), MD5 (32), SHA-1 (40) or SHA-256 (64).',
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
properties: { hash: { type: 'string', description: 'The hash, in hexadecimal.' } },
|
||||
required: ['hash'],
|
||||
},
|
||||
annotations: { readOnlyHint: true },
|
||||
async execute({ hash }) {
|
||||
const value = String(hash ?? '').trim().toLowerCase().replace(/^0x/, '');
|
||||
if (!HASH.test(value)) return 'Not a CRC32, MD5, SHA-1 or SHA-256 value.';
|
||||
const shard = await json<{ items: LookupItem[] }>(`api/v1/lookup/${value.slice(0, 2)}.json`);
|
||||
const found = shard.items.filter((item) => item.value === value);
|
||||
if (!found.length) return `No file in the collection or its profiles has the ${value.length === 8 ? 'CRC32' : 'hash'} ${value}.`;
|
||||
return JSON.stringify(found.map((item) => ({ ...item, url: absolute(item.url) })), null, 1);
|
||||
},
|
||||
};
|
||||
|
||||
const FILE_FIELDS = ['name', 'path', 'required', 'system', 'size', 'md5', 'sha1', 'crc32', 'sha256', 'region', 'note'];
|
||||
|
||||
const emulatorFiles: Tool = {
|
||||
name: 'emulator_bios_files',
|
||||
description:
|
||||
'List the BIOS and firmware files an emulator or libretro core reads, from its profile checked against its source code: '
|
||||
+ 'each file name, where it goes, whether the emulator starts without it, and its hashes.',
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
emulator: { type: 'string', description: 'The emulator or core name, for example "mGBA", "beetle_psx" or "DuckStation".' },
|
||||
},
|
||||
required: ['emulator'],
|
||||
},
|
||||
annotations: { readOnlyHint: true },
|
||||
async execute({ emulator }) {
|
||||
const asked = String(emulator ?? '').trim();
|
||||
const key = asked.toLowerCase().replace(/[\s_]+/g, '_');
|
||||
const cores = await json<CoreIndex>('api/v1/cores.json');
|
||||
const named = cores.items.filter((core) => [core.name.toLowerCase(), core.profile].includes(key)
|
||||
|| core.name.toLowerCase().replace(/[\s-]+/g, '_') === key);
|
||||
const id = (named.find((core) => core.preferred) ?? named[0])?.profile ?? key.replace(/_/g, '-');
|
||||
let doc: EmulatorDoc;
|
||||
try {
|
||||
doc = await json<EmulatorDoc>(`api/v1/emulators/${encodeURIComponent(id)}.json`);
|
||||
} catch {
|
||||
return `No emulator profile named "${asked}". The list is at ${absolute(`${BASE}emulators/`)}.`;
|
||||
}
|
||||
const { profile } = doc.items[0];
|
||||
const files = (profile.files ?? []).map((file) =>
|
||||
Object.fromEntries(FILE_FIELDS.filter((field) => file[field] !== undefined).map((field) => [field, file[field]])));
|
||||
return JSON.stringify({
|
||||
emulator: profile.emulator ?? id,
|
||||
page: absolute(`${BASE}emulators/${id}/`),
|
||||
systems: profile.systems ?? [],
|
||||
files,
|
||||
}, null, 1);
|
||||
},
|
||||
};
|
||||
|
||||
type Pagefind = { search: (query: string) => Promise<{ results: { data: () => Promise<{ url: string; meta: { title?: string }; excerpt: string }> }[] }> };
|
||||
|
||||
const searchSite: Tool = {
|
||||
name: 'search_retrobios',
|
||||
description: 'Search the RetroBIOS site: emulators, consoles, setups (RetroArch, Batocera, EmuDeck...) and guides. Returns the best pages.',
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
properties: { query: { type: 'string', description: 'Words to search for, for example "dreamcast bios batocera".' } },
|
||||
required: ['query'],
|
||||
},
|
||||
annotations: { readOnlyHint: true },
|
||||
async execute({ query }) {
|
||||
const words = String(query ?? '').trim();
|
||||
if (!words) return 'Nothing to search for.';
|
||||
const pagefind = (await import(/* @vite-ignore */ `${BASE}pagefind/pagefind.js`)) as Pagefind;
|
||||
const { results } = await pagefind.search(words);
|
||||
const top = await Promise.all(results.slice(0, 8).map((result) => result.data()));
|
||||
if (!top.length) return `No page matches "${words}".`;
|
||||
return JSON.stringify(top.map((page) => ({
|
||||
title: page.meta.title ?? '',
|
||||
url: absolute(page.url),
|
||||
excerpt: page.excerpt.replace(/<[^>]+>/g, ''),
|
||||
})), null, 1);
|
||||
},
|
||||
};
|
||||
|
||||
// The command an agent runs to set up its user's device, from the same
|
||||
// options wiki/installer.md documents: the fewest files the games on the
|
||||
// card need, unless the user asks for everything.
|
||||
const SH = 'curl -fsSL https://abdess.github.io/retrobios/i | sh -s --';
|
||||
const PS = '& ([scriptblock]::Create((irm https://abdess.github.io/retrobios/i.ps1)))';
|
||||
|
||||
const setupCommand: Tool = {
|
||||
name: 'device_setup_command',
|
||||
description:
|
||||
'The one command that installs the BIOS files a device or computer needs, with RetroBIOS\'s installer. By default it '
|
||||
+ 'installs only what the games on the card need to start, which is what most players want. It finds the setup '
|
||||
+ '(RetroArch, Batocera, ArkOS, dArkOS, muOS, KNULLI, ROCKNIX...) and its BIOS folder itself.',
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
shell: { type: 'string', enum: ['sh', 'powershell'], description: '"sh" on Linux, macOS and handhelds (the default), "powershell" on Windows.' },
|
||||
everything: { type: 'boolean', description: 'Install every file of the setup\'s list instead of only what the games need. Gigabytes.' },
|
||||
bios_folder: { type: 'string', description: 'The BIOS folder, when run on a computer and the card is not found, for example "/media/user/EASYROMS/bios".' },
|
||||
games_folder: { type: 'string', description: 'A games folder that is not on the card, one subfolder per system.' },
|
||||
platform: { type: 'string', description: 'The setup, only when it is not detected (names from the installer\'s --list-platforms).' },
|
||||
},
|
||||
},
|
||||
annotations: { readOnlyHint: true },
|
||||
async execute({ shell, everything, bios_folder, games_folder, platform }) {
|
||||
const quote = (value: unknown) => `'${String(value).replace(/'/g, shell === 'powershell' ? "''" : "'\\''")}'`;
|
||||
const run = shell === 'powershell' ? PS : SH;
|
||||
const place: string[] = [];
|
||||
if (platform) place.push('--platform', quote(platform));
|
||||
if (bios_folder) place.push('--dest', quote(bios_folder));
|
||||
const games = everything ? [] : [...(games_folder ? ['--for-games', quote(games_folder)] : ['--for-games']), '--required-only'];
|
||||
const command = (...more: string[]) => [run, ...place, ...more].join(' ');
|
||||
return JSON.stringify({
|
||||
preview: command(...games, '--dry-run'),
|
||||
install: command(...games, '--yes'),
|
||||
check_later: command('--check'),
|
||||
notes: [
|
||||
'Run the preview first: it writes nothing and lists what would be downloaded.',
|
||||
'Run on the device itself, or on a computer with the device\'s card plugged in.',
|
||||
'A game the installer cannot identify gets every BIOS of its system, and the run names it.',
|
||||
`Guide: ${absolute(`${BASE}llms.txt`)}`,
|
||||
],
|
||||
}, null, 1);
|
||||
},
|
||||
};
|
||||
|
||||
const context = (document as Document & { modelContext?: ModelContext }).modelContext;
|
||||
if (context?.registerTool) {
|
||||
for (const tool of [setupCommand, lookupHash, emulatorFiles, searchSite]) {
|
||||
Promise.resolve(context.registerTool(tool)).catch((error) => console.warn(`WebMCP: ${tool.name}:`, error));
|
||||
}
|
||||
}
|
||||
@@ -67,7 +67,7 @@ Every module in `tests/`, by the area it guards.
|
||||
| Pack probe | the cores a platform runs for a system, staging grown to a fixed point, runs mapped to pack and manifest entries, the line and its schema, the probe lock per run, that no page shows its lines. One class builds a real pack narrowed to PlayStation and lays it out both ways, from the archive and with `install.py --dest`, and skips without the collection | `test_pack_probe` |
|
||||
| Automation runner, disk cleanup, asset sync and bit-flip repair | fake jobs, git repositories with a bare origin and session worktrees, a committed file with one bit flipped, canned CI runs in place of `gh` | `test_autorun` |
|
||||
| Truth and native export | synthetic + cached originals | `test_export_counts`, `test_export_native_layer`, `test_export_report`, `test_exporters`, `test_truth_data_dir_readers`, `test_truth_diff`, `test_truth_layout` |
|
||||
| Scrapers, upstream and provenance | inline sources, synthetic | `test_batocera_md5_list`, `test_check_freshness`, `test_cited_lines`, `test_contributing_text`, `test_coverage_cache`, `test_data_dir_refs`, `test_dataclass_defaults`, `test_dedup`, `test_doc_pages`, `test_doc_surfaces`, `test_elec_scrapers`, `test_fbneo_parser`, `test_find_pinned_revision`, `test_hash_merge`, `test_launchers`, `test_launchers_emudeck`, `test_launchers_recalbox`, `test_launchers_retrobat`, `test_launchers_retrodeck`, `test_launchers_retropie`, `test_launchers_rocknix`, `test_mame_devices`, `test_mame_parser`, `test_mame_ref_audit`, `test_optional_dependencies`, `test_pin_build`, `test_profile_sync`, `test_prose_style`, `test_provenance`, `test_refresh_data_dirs`, `test_refresh_failures`, `test_refresh_stale`, `test_retrodeck_scraper_errors`, `test_retired_files`, `test_romset_recipes`, `test_schema_semantic_gate`, `test_scraper_contract`, `test_site_arcade_boards`, `test_site_availability`, `test_site_bootstrap`, `test_site_built_gate`, `test_site_components`, `test_site_console_folders`, `test_site_content_data`, `test_site_copy`, `test_site_cores`, `test_site_css`, `test_site_density_cards`, `test_site_emulator_page`, `test_site_emulators`, `test_site_entry`, `test_site_entry_tables`, `test_site_exports`, `test_site_funnel`, `test_site_images`, `test_site_inputs`, `test_site_insights`, `test_site_integration`, `test_site_nav`, `test_site_picker`, `test_site_platform_density`, `test_site_platform_hashes`, `test_site_privacy`, `test_site_reference`, `test_site_render`, `test_site_search`, `test_site_system_links`, `test_site_system_names`, `test_site_system_sections`, `test_site_systems`, `test_site_validation`, `test_site_weight`, `test_site_wiki`, `test_site_write`, `test_source_ref_targets`, `test_upstream`, `test_upstream_index`, `test_validate_pr`, `test_validate_pr_flow`, `test_validate_pr_hashes`, `test_version_basis`, `test_workflow_paths` |
|
||||
| Scrapers, upstream and provenance | inline sources, synthetic | `test_batocera_md5_list`, `test_check_freshness`, `test_cited_lines`, `test_contributing_text`, `test_coverage_cache`, `test_data_dir_refs`, `test_dataclass_defaults`, `test_dedup`, `test_doc_pages`, `test_doc_surfaces`, `test_elec_scrapers`, `test_fbneo_parser`, `test_find_pinned_revision`, `test_hash_merge`, `test_launchers`, `test_launchers_emudeck`, `test_launchers_recalbox`, `test_launchers_retrobat`, `test_launchers_retrodeck`, `test_launchers_retropie`, `test_launchers_rocknix`, `test_mame_devices`, `test_mame_parser`, `test_mame_ref_audit`, `test_optional_dependencies`, `test_pin_build`, `test_profile_sync`, `test_prose_style`, `test_provenance`, `test_refresh_data_dirs`, `test_refresh_failures`, `test_refresh_stale`, `test_retrodeck_scraper_errors`, `test_retired_files`, `test_romset_recipes`, `test_schema_semantic_gate`, `test_scraper_contract`, `test_site_agents`, `test_site_arcade_boards`, `test_site_availability`, `test_site_bootstrap`, `test_site_built_gate`, `test_site_components`, `test_site_console_folders`, `test_site_content_data`, `test_site_copy`, `test_site_cores`, `test_site_css`, `test_site_density_cards`, `test_site_emulator_page`, `test_site_emulators`, `test_site_entry`, `test_site_entry_tables`, `test_site_exports`, `test_site_funnel`, `test_site_images`, `test_site_inputs`, `test_site_insights`, `test_site_integration`, `test_site_nav`, `test_site_picker`, `test_site_platform_density`, `test_site_platform_hashes`, `test_site_privacy`, `test_site_reference`, `test_site_render`, `test_site_search`, `test_site_system_links`, `test_site_system_names`, `test_site_system_sections`, `test_site_systems`, `test_site_validation`, `test_site_weight`, `test_site_wiki`, `test_site_write`, `test_source_ref_targets`, `test_upstream`, `test_upstream_index`, `test_validate_pr`, `test_validate_pr_flow`, `test_validate_pr_hashes`, `test_version_basis`, `test_workflow_paths` |
|
||||
| Games on the card | small DATs, listxml and setup sources, a fake fetcher, fixture install lists, and the committed data against its schemas | `test_arcade_parents`, `test_game_catalogs`, `test_game_headers`, `test_game_regions`, `test_games_index`, `test_romfolders_card`, `test_romfolders_es` |
|
||||
|
||||
## Test architecture
|
||||
|
||||
Reference in new issue
Block a user