commit 305fbf0b013ba46db38d03163a39e00de35f0239
parent 7767905706d8891812758e43c46441072d95b8be
Author: I Mean I'm Just Saying <imeanimjustsaying@kiwifarms.st>
Date: Mon, 6 Jul 2026 21:45:01 -0400
Merge branch 'main' into worktree-feat+archive-downloads
# Conflicts:
# pnpm-lock.yaml
Diffstat:
26 files changed, 3100 insertions(+), 1 deletion(-)
diff --git a/common/bin/compose-hub.ts b/common/bin/compose-hub.ts
@@ -15,7 +15,13 @@ import path from "node:path";
import { cp, rm, writeFile, access } from "node:fs/promises";
import { getPaths } from "../lib/paths";
import { listSites, resolveHubUrl } from "../lib/site";
+import { getHomepageConfig } from "../lib/homepage";
import { SITE_DESCRIPTOR_VERSION } from "../lib/siteDescriptor";
+import {
+ buildHubCorpus,
+ renderHubLlmsTxt,
+ renderRobotsTxt,
+} from "../lib/corpus";
// CORS for the hub's own JSON (hub-sites.json). The hub is primarily a reader,
// but keeping its endpoints CORS-open lets a hub-of-hubs federate it too. Same
@@ -33,6 +39,12 @@ const CORS_HEADERS = `# Generated by compose-hub.ts — do not edit by hand.
Access-Control-Allow-Origin: *
/stats/*
Access-Control-Allow-Origin: *
+/corpus.json
+ Access-Control-Allow-Origin: *
+/llms.txt
+ Access-Control-Allow-Origin: *
+/robots.txt
+ Access-Control-Allow-Origin: *
`;
// One entry the hub registry loads at boot to seed its trusted built-in pool.
@@ -80,6 +92,28 @@ async function main(): Promise<void> {
JSON.stringify(builtins),
);
+ // Aggregate AI-discovery surface for the federation (fixed file count): a hub
+ // corpus.json directory of member origins + llms.txt + robots.txt. The hub
+ // holds no shard data — these point at each member's own corpus.json.
+ const hub = getHomepageConfig(paths);
+ const hubCorpus = buildHubCorpus(builtins, {
+ hubTitle: hub.siteTitle,
+ hubUrl: hub.siteUrl,
+ generatedAt: new Date().toISOString(),
+ });
+ await writeFile(
+ path.join(publicDir, "corpus.json"),
+ JSON.stringify(hubCorpus),
+ );
+ await writeFile(
+ path.join(publicDir, "llms.txt"),
+ renderHubLlmsTxt(hubCorpus),
+ );
+ await writeFile(
+ path.join(publicDir, "robots.txt"),
+ renderRobotsTxt({ siteUrl: hub.siteUrl }),
+ );
+
await writeFile(path.join(publicDir, "_headers"), CORS_HEADERS);
// The hub always ships a PWA. Copy the hub service worker into place. Until
diff --git a/common/bin/compose-site.ts b/common/bin/compose-site.ts
@@ -24,7 +24,13 @@ import {
type DuplicateReport,
} from "../lib/duplicates";
import type { Manifest, SubsManifest } from "../lib/manifest";
-import { buildSiteDescriptor } from "../lib/siteDescriptor";
+import { buildSiteDescriptor, type PublicSiteDescriptor } from "../lib/siteDescriptor";
+import {
+ buildSiteCorpus,
+ renderSiteLlmsTxt,
+ renderRobotsTxt,
+ renderSitemapXml,
+} from "../lib/corpus";
import { archiveTranscripts } from "../controller/archiveTranscripts";
import { archiveLiveChat } from "../controller/archiveLiveChat";
import {
@@ -53,6 +59,14 @@ const CORS_HEADERS = `# Generated by compose-site.ts — do not edit by hand.
Access-Control-Allow-Origin: *
/archives/*
Access-Control-Allow-Origin: *
+/corpus.json
+ Access-Control-Allow-Origin: *
+/llms.txt
+ Access-Control-Allow-Origin: *
+/robots.txt
+ Access-Control-Allow-Origin: *
+/sitemap.xml
+ Access-Control-Allow-Origin: *
`;
// Emit the public federation contract: /site.json (branding + channels +
@@ -77,6 +91,68 @@ async function emitFederationFiles(
);
}
+// Emit the AI-discovery surface — a small FIXED set of site-root files
+// (llms.txt, corpus.json, robots.txt, sitemap.xml). These document how to
+// navigate the already-served paginated shards; they never enumerate per-video
+// files, so the count is constant regardless of corpus size. Runs after
+// site.json and the archives are composed (both feed into these files).
+async function emitAiFiles(paths: ReturnType<typeof getPaths>): Promise<void> {
+ const sitePath = path.join(paths.exportPublicDir, "site.json");
+ if (!(await exists(sitePath))) return; // no composed data → nothing to describe
+ const descriptor = JSON.parse(
+ await readFile(sitePath, "utf8"),
+ ) as PublicSiteDescriptor;
+
+ // Bulk archives present? (mirror of export/app/lib/archives.ts hasArchives)
+ let hasArchives = false;
+ const amPath = path.join(
+ paths.exportPublicDir,
+ "archives",
+ ARCHIVE_MANIFEST_FILENAME,
+ );
+ if (await exists(amPath)) {
+ try {
+ const am = JSON.parse(await readFile(amPath, "utf8")) as ArchiveManifest;
+ hasArchives =
+ Array.isArray(am.entries) &&
+ am.entries.some((e) => !e.oversize || e.url);
+ } catch {
+ // A malformed archive manifest just means we omit the archives link.
+ }
+ }
+
+ const corpus = buildSiteCorpus(descriptor, { hasArchives });
+ await writeFile(
+ path.join(paths.exportPublicDir, "corpus.json"),
+ JSON.stringify(corpus),
+ );
+ await writeFile(
+ path.join(paths.exportPublicDir, "llms.txt"),
+ renderSiteLlmsTxt(corpus),
+ );
+ await writeFile(
+ path.join(paths.exportPublicDir, "robots.txt"),
+ renderRobotsTxt({ siteUrl: descriptor.siteUrl }),
+ );
+
+ // A sitemap of relative paths is useless, so only emit one with an absolute
+ // siteUrl; otherwise clear any stale copy from a previous build.
+ const sitemapPath = path.join(paths.exportPublicDir, "sitemap.xml");
+ if (descriptor.siteUrl) {
+ const routes = ["/", "/use-with-ai", "/changelog"];
+ if (hasArchives) routes.push("/downloads");
+ if (await exists(path.join(paths.exportPublicDir, DUPLICATES_FILENAME))) {
+ routes.push("/duplicates");
+ }
+ await writeFile(
+ sitemapPath,
+ renderSitemapXml({ siteUrl: descriptor.siteUrl, routes }),
+ );
+ } else {
+ await rm(sitemapPath, { force: true });
+ }
+}
+
// Whether this build ships an installable PWA. Resolved from the site's `pwa`
// config flag (site builds are dumb instances by default) or forced on in hub
// mode. Keep in sync with export/app/lib/mode.ts shipsPwa().
@@ -435,6 +511,10 @@ async function main(): Promise<void> {
// --- bulk-download archive zips (on by default; see archivesEnabled) ---
await composeArchives(site, memberSlugs, paths);
+ // --- AI discovery: llms.txt / corpus.json / robots.txt / sitemap.xml ---
+ // (after site.json + archives — both feed into these fixed-count files)
+ await emitAiFiles(paths);
+
const channelDirs = (await readdir(paths.exportTranscriptsDir).catch(
() => [] as string[],
)).length;
diff --git a/common/components/PlayerProvider.tsx b/common/components/PlayerProvider.tsx
@@ -18,6 +18,7 @@ import { useUrlParams, writeUrlParams } from "./urlState";
import type { ModalMode } from "./urlState";
import { formatDate, formatDuration } from "../lib/format";
import { cuesToSrt, cuesToText } from "../lib/vtt";
+import { transcriptToMarkdown } from "../lib/transcriptToMarkdown";
import type { Platform } from "../lib/transcripts";
import { vodExpiry } from "../lib/vodExpiry";
import { VodExpiredBadge } from "./badges";
@@ -129,6 +130,9 @@ type PlayerState = {
// Download the currently-shown transcript or live-chat log (per modalMode) as
// a single file, generated client-side from the loaded cues.
downloadTranscriptFile: (fmt: "json" | "txt" | "srt") => void;
+ // Copy the currently-shown transcript (or live chat) to the clipboard as clean
+ // markdown for pasting into an AI chat. Resolves true on success.
+ copyTranscriptMarkdown: () => Promise<boolean>;
openInPreservetube: () => void;
seekTo: (seconds: number) => void;
};
@@ -469,6 +473,37 @@ export function PlayerProvider({
[data, chat, activeSlug, modalMode],
);
+ // Copy the currently-shown cues as markdown (metadata header + timestamped
+ // body) for pasting into any AI chat. Uses the shared transcriptToMarkdown so
+ // the format matches the MCP server and the /ask chat.
+ const copyTranscriptMarkdown = useCallback(async (): Promise<boolean> => {
+ const isChat = modalMode === "chat";
+ const cues = isChat
+ ? chat.slug === activeSlug
+ ? chat.cues
+ : null
+ : (data?.cues ?? null);
+ if (!data || !cues || cues.length === 0) return false;
+ const md = transcriptToMarkdown(
+ {
+ id: data.id,
+ title: isChat ? `${data.title} — live chat` : data.title,
+ channel: data.channel,
+ uploadDate: data.uploadDate,
+ webpageUrl: data.webpageUrl,
+ description: isChat ? undefined : data.description,
+ cues,
+ },
+ { timestamps: true, includeDescription: !isChat },
+ );
+ try {
+ await navigator.clipboard.writeText(md);
+ return true;
+ } catch {
+ return false;
+ }
+ }, [data, chat, activeSlug, modalMode]);
+
const seekTo = useCallback((seconds: number) => {
if (playerRef.current) {
playerRef.current.seekTo(seconds, "seconds");
@@ -666,6 +701,7 @@ export function PlayerProvider({
copyDownloadCommand,
copyShareUrl,
downloadTranscriptFile,
+ copyTranscriptMarkdown,
openInPreservetube,
seekTo,
}),
@@ -689,6 +725,7 @@ export function PlayerProvider({
copyDownloadCommand,
copyShareUrl,
downloadTranscriptFile,
+ copyTranscriptMarkdown,
openInPreservetube,
seekTo,
],
diff --git a/common/components/TranscriptModal.tsx b/common/components/TranscriptModal.tsx
@@ -42,6 +42,7 @@ export default function TranscriptModal() {
copyDownloadCommand,
copyShareUrl,
downloadTranscriptFile,
+ copyTranscriptMarkdown,
openInPreservetube,
seekTo,
} = usePlayer();
@@ -51,6 +52,8 @@ export default function TranscriptModal() {
const copyResetRef = useRef<number | null>(null);
const [shareCopied, setShareCopied] = useState(false);
const shareResetRef = useRef<number | null>(null);
+ const [mdCopied, setMdCopied] = useState(false);
+ const mdResetRef = useRef<number | null>(null);
const [downloadOpen, setDownloadOpen] = useState(false);
const downloadRef = useRef<HTMLDivElement | null>(null);
// Tracks whether the next scrollToIndex should animate. Smooth on
@@ -283,6 +286,24 @@ export default function TranscriptModal() {
</div>
)}
</div>
+ <ControlButton
+ title={
+ !canDownloadFile
+ ? "Nothing to copy yet"
+ : mdCopied
+ ? "Copied!"
+ : `Copy this ${isChat ? "live chat" : "transcript"} as Markdown (for AI)`
+ }
+ onClick={async () => {
+ const ok = await copyTranscriptMarkdown();
+ if (!ok) return;
+ setMdCopied(true);
+ if (mdResetRef.current) window.clearTimeout(mdResetRef.current);
+ mdResetRef.current = window.setTimeout(() => setMdCopied(false), 1500);
+ }}
+ char={mdCopied ? "✓" : "📋"}
+ disabled={!canDownloadFile}
+ />
{data?.platform === "youtube" && (
<ControlButton
title="Open in Preservetube"
diff --git a/common/components/TranscriptSearch.tsx b/common/components/TranscriptSearch.tsx
@@ -89,6 +89,51 @@ type ResultGroup = {
accent?: string;
};
+// Serialize the current result set as markdown to paste into an AI chat as
+// context: the search terms, then each matching video with its timestamped hit
+// snippets. Bounded so the clipboard payload stays reasonable.
+function buildResultsContextMarkdown(
+ groups: ResultGroup[],
+ queries: string[],
+ opts: { maxVideos?: number; maxHitsPerVideo?: number } = {},
+): string {
+ const maxVideos = opts.maxVideos ?? 30;
+ const maxHits = opts.maxHitsPerVideo ?? 8;
+ const hms = (s: number): string => {
+ const n = Math.max(0, Math.floor(s));
+ const h = Math.floor(n / 3600);
+ const m = Math.floor((n % 3600) / 60);
+ const ss = n % 60;
+ const mm = String(m).padStart(2, "0");
+ const sss = String(ss).padStart(2, "0");
+ return h > 0 ? `${h}:${mm}:${sss}` : `${m}:${sss}`;
+ };
+ const lines: string[] = [];
+ lines.push(
+ queries.length
+ ? `# Transcript search results for: ${queries.join(" AND ")}`
+ : "# Transcript search results",
+ );
+ lines.push("");
+ const shown = groups.slice(0, maxVideos);
+ for (const g of shown) {
+ lines.push(`## ${g.title} — ${g.channel}${g.date ? ` (${g.date})` : ""}`);
+ const hits = g.hits.slice(0, maxHits);
+ for (const h of hits) {
+ const stamp = h.start > 0 ? `[${hms(h.start)}] ` : "";
+ lines.push(`- ${stamp}${h.text.trim().replace(/\s+/g, " ")}`);
+ }
+ if (g.hits.length > hits.length) {
+ lines.push(`- …and ${g.hits.length - hits.length} more hit(s)`);
+ }
+ lines.push("");
+ }
+ if (groups.length > shown.length) {
+ lines.push(`_(showing ${shown.length} of ${groups.length} matching videos)_`);
+ }
+ return lines.join("\n") + "\n";
+}
+
const DEFAULT_MAX_HITS = 500;
const DEFAULT_FETCH_CONCURRENCY = 6;
const DEFAULT_FLUSH_INTERVAL_MS = 120;
@@ -1303,6 +1348,28 @@ export default function TranscriptSearch() {
return m;
}, [committedRoot]);
+ const [resultsCopied, setResultsCopied] = useState(false);
+ const resultsCopiedResetRef = useRef<number | null>(null);
+ const copyResultsContext = useCallback(async () => {
+ const queries = Array.from(leavesById.values())
+ .map((l) => l.query.trim())
+ .filter(Boolean);
+ const md = buildResultsContextMarkdown(resultGroups, queries);
+ try {
+ await navigator.clipboard.writeText(md);
+ setResultsCopied(true);
+ if (resultsCopiedResetRef.current) {
+ window.clearTimeout(resultsCopiedResetRef.current);
+ }
+ resultsCopiedResetRef.current = window.setTimeout(
+ () => setResultsCopied(false),
+ 1500,
+ );
+ } catch {
+ /* clipboard blocked — no-op */
+ }
+ }, [leavesById, resultGroups]);
+
// Hits split by leaf, in the order leaves appear in the tree, so the UI
// sections render in a stable order regardless of the order findHitsInCues
// discovered them.
@@ -1780,6 +1847,16 @@ export default function TranscriptSearch() {
Chart
</button>
</span>
+ {hasActiveQuery && resultGroups.length > 0 && (
+ <button
+ type="button"
+ onClick={copyResultsContext}
+ title="Copy these results as context to paste into an AI chat"
+ className="inline-flex items-center rounded-md border border-border bg-muted px-2.5 py-1 text-xs font-normal text-muted-foreground hover:bg-accent hover:text-accent-foreground"
+ >
+ {resultsCopied ? "✓ Copied" : "Copy for AI"}
+ </button>
+ )}
</h2>
{view === "chart" ? (
diff --git a/common/lib/corpus.test.ts b/common/lib/corpus.test.ts
@@ -0,0 +1,115 @@
+import { test } from "node:test";
+import assert from "node:assert/strict";
+import {
+ buildSiteCorpus,
+ buildHubCorpus,
+ renderSiteLlmsTxt,
+ renderHubLlmsTxt,
+ renderRobotsTxt,
+ renderSitemapXml,
+ CORPUS_SPEC_VERSION,
+} from "./corpus";
+import type { PublicSiteDescriptor } from "./siteDescriptor";
+
+// Run with:
+// pnpm --filter yt-dlp-transcript-common exec tsx --test common/lib/corpus.test.ts
+
+function descriptor(
+ over: Partial<PublicSiteDescriptor> = {},
+): PublicSiteDescriptor {
+ return {
+ contract: 1,
+ siteId: "demo",
+ siteTitle: "Demo Archive",
+ siteDescription: "Talks and streams.",
+ headerTitle: "Demo",
+ homeTagline: "",
+ pwa: false,
+ socialLinks: [],
+ groups: [],
+ defaultGroupId: "default",
+ channels: [
+ { slug: "alice", name: "Alice", count: 12 },
+ { slug: "bob", name: "Bob", count: 3, groupId: "g1" },
+ ],
+ generatedAt: "2026-07-06T00:00:00.000Z",
+ summariesVersion: 3,
+ ...over,
+ };
+}
+
+test("buildSiteCorpus: absolute shard URLs + totals when siteUrl is set", () => {
+ const corpus = buildSiteCorpus(
+ descriptor({ siteUrl: "https://demo.example/", hubUrl: "https://hub.example" }),
+ { hasArchives: true },
+ );
+ assert.equal(corpus.spec, CORPUS_SPEC_VERSION);
+ assert.equal(corpus.kind, "site");
+ assert.equal(corpus.totals.channels, 2);
+ assert.equal(corpus.totals.videos, 15);
+ assert.equal(corpus.site.url, "https://demo.example/");
+ assert.equal(corpus.site.hubUrl, "https://hub.example");
+ assert.equal(
+ corpus.channels[0].manifests.transcripts,
+ "https://demo.example/transcripts/alice/manifest.json",
+ );
+ assert.equal(corpus.channels[1].groupId, "g1");
+ assert.ok(corpus.bulkArchives, "archives present → bulkArchives block");
+ assert.ok(corpus.shardScheme.description.includes("paginated"));
+});
+
+test("buildSiteCorpus: root-relative URLs + no archives when unset", () => {
+ const corpus = buildSiteCorpus(descriptor(), { hasArchives: false });
+ assert.equal(corpus.site.url, undefined);
+ assert.equal(
+ corpus.channels[0].manifests.subs,
+ "/subs/alice/manifest.json",
+ );
+ assert.equal(corpus.bulkArchives, undefined);
+});
+
+test("renderSiteLlmsTxt: title, corpus link, channels", () => {
+ const corpus = buildSiteCorpus(
+ descriptor({ siteUrl: "https://demo.example" }),
+ { hasArchives: true },
+ );
+ const txt = renderSiteLlmsTxt(corpus);
+ assert.match(txt, /^# Demo Archive/);
+ assert.match(txt, /\[corpus\.json\]\(https:\/\/demo\.example\/corpus\.json\)/);
+ assert.match(txt, /Alice — 12 transcripts/);
+ assert.match(txt, /Bulk archives/);
+});
+
+test("buildHubCorpus: drops members with no siteUrl, links each corpus", () => {
+ const hub = buildHubCorpus(
+ [
+ { siteId: "a", siteTitle: "A", siteUrl: "https://a.example/", pwa: true },
+ { siteId: "b", siteTitle: "B", siteUrl: "", pwa: false },
+ ],
+ { hubTitle: "The Hub", hubUrl: "https://hub.example", generatedAt: "t" },
+ );
+ assert.equal(hub.kind, "hub");
+ assert.equal(hub.sites.length, 1);
+ assert.equal(hub.sites[0].corpus, "https://a.example/corpus.json");
+ assert.equal(hub.sites[0].siteJson, "https://a.example/site.json");
+ const txt = renderHubLlmsTxt(hub);
+ assert.match(txt, /^# The Hub/);
+ assert.match(txt, /A: https:\/\/a\.example/);
+});
+
+test("renderRobotsTxt: sitemap line only with an absolute siteUrl", () => {
+ assert.match(
+ renderRobotsTxt({ siteUrl: "https://demo.example/" }),
+ /Sitemap: https:\/\/demo\.example\/sitemap\.xml/,
+ );
+ assert.doesNotMatch(renderRobotsTxt({}), /Sitemap:/);
+});
+
+test("renderSitemapXml: one loc per route", () => {
+ const xml = renderSitemapXml({
+ siteUrl: "https://demo.example",
+ routes: ["/", "/use-with-ai"],
+ });
+ assert.match(xml, /<loc>https:\/\/demo\.example\/<\/loc>/);
+ assert.match(xml, /<loc>https:\/\/demo\.example\/use-with-ai<\/loc>/);
+});
diff --git a/common/lib/corpus.ts b/common/lib/corpus.ts
@@ -0,0 +1,291 @@
+import type { PublicSiteDescriptor } from "./siteDescriptor";
+
+// The machine-readable corpus index emitted at `/corpus.json` on every export
+// bundle (and an aggregate variant on a hub). It does NOT contain transcripts —
+// the corpus is far too large to enumerate per-video without blowing the
+// Cloudflare Pages file-count limit. Instead it *documents how to navigate the
+// existing paginated JSON shards*, turning the already-served
+// `subs/`, `transcripts/`, and `summaries/` trees into a self-describing API for
+// LLM tools (Claude Code `WebFetch`, the repo MCP server, the in-browser chat).
+//
+// Pure module: builders take already-loaded data and return plain objects /
+// strings. All file I/O lives in compose-site.ts / compose-hub.ts.
+
+export const CORPUS_SPEC_VERSION = 1;
+
+// How to resolve a single transcript from the paginated shards, described once
+// and embedded in every corpus.json so any HTTP client can navigate without
+// reading our source.
+const SHARD_SCHEME = {
+ description:
+ "Transcripts are served as paginated JSON shards — there is no per-video " +
+ "file. To read one video's transcript: (1) GET the channel's transcripts " +
+ "manifest; (2) look up the video id in its `slugToPage` map to get a page " +
+ "number N; (3) GET page-<NNNN>.json (N zero-padded to 4 digits) and take " +
+ "the record whose `id` matches.",
+ transcriptsManifest:
+ "<channel.manifests.transcripts> -> { pageCount, slugToPage: { <videoId>: <pageNumber> } }",
+ transcriptPage:
+ "/transcripts/<slug>/page-<NNNN>.json -> array of { id, title, uploadDate, " +
+ "duration, channel, description, tags, webpageUrl, platform, cues: [{ start, end, text }] }",
+ subsManifest:
+ "<channel.manifests.subs> -> lighter list-view records under the same slugToPage scheme",
+ summariesIndex:
+ "/summaries/manifest.json + /summaries/page-<NNNN>.json -> cross-channel browse index",
+ pageNumberFormat: "zero-padded to 4 digits, e.g. page 0 -> page-0000.json",
+} as const;
+
+export type CorpusChannel = {
+ slug: string;
+ name: string;
+ videoCount: number;
+ groupId?: string;
+ manifests: {
+ transcripts: string;
+ subs: string;
+ };
+};
+
+export type SiteCorpus = {
+ spec: number;
+ kind: "site";
+ generatedAt: string;
+ site: {
+ id: string;
+ title: string;
+ description: string;
+ url?: string;
+ hubUrl?: string;
+ };
+ totals: { channels: number; videos: number };
+ channels: CorpusChannel[];
+ shardScheme: typeof SHARD_SCHEME;
+ // Present when this build ships bulk-download archives (whole-channel zips).
+ bulkArchives?: { manifest: string; note: string };
+ // Pointer to the human page and BYO-key chat.
+ useWithAi: string;
+};
+
+export type HubCorpusSite = {
+ siteId: string;
+ title: string;
+ url: string;
+ corpus: string;
+ siteJson: string;
+ pwa: boolean;
+};
+
+export type HubCorpus = {
+ spec: number;
+ kind: "hub";
+ generatedAt: string;
+ hub: { title: string; url?: string };
+ sites: HubCorpusSite[];
+ federation: { description: string };
+ useWithAi: string;
+};
+
+// Minimal member shape a hub knows about (mirror of compose-hub.ts HubSiteEntry).
+export type HubMemberInput = {
+ siteId: string;
+ siteTitle: string;
+ siteUrl: string;
+ pwa?: boolean;
+};
+
+// Join an origin base with a root-relative path. When no base is known (a site
+// built without a configured siteUrl) the path is left root-relative — still
+// correct for a same-origin fetch, just not portable cross-origin.
+function join(base: string | undefined, p: string): string {
+ if (!base) return p;
+ return `${base.replace(/\/+$/, "")}${p}`;
+}
+
+// Build the per-site corpus index from the public site descriptor (`/site.json`)
+// plus whether this build emitted bulk archives.
+export function buildSiteCorpus(
+ descriptor: PublicSiteDescriptor,
+ opts: { hasArchives: boolean },
+): SiteCorpus {
+ const base = descriptor.siteUrl;
+ const channels: CorpusChannel[] = descriptor.channels.map((c) => ({
+ slug: c.slug,
+ name: c.name,
+ videoCount: c.count,
+ ...(c.groupId ? { groupId: c.groupId } : {}),
+ manifests: {
+ transcripts: join(base, `/transcripts/${c.slug}/manifest.json`),
+ subs: join(base, `/subs/${c.slug}/manifest.json`),
+ },
+ }));
+ const videos = channels.reduce((n, c) => n + c.videoCount, 0);
+
+ const corpus: SiteCorpus = {
+ spec: CORPUS_SPEC_VERSION,
+ kind: "site",
+ generatedAt: descriptor.generatedAt,
+ site: {
+ id: descriptor.siteId,
+ title: descriptor.siteTitle,
+ description: descriptor.siteDescription,
+ ...(descriptor.siteUrl ? { url: descriptor.siteUrl } : {}),
+ ...(descriptor.hubUrl ? { hubUrl: descriptor.hubUrl } : {}),
+ },
+ totals: { channels: channels.length, videos },
+ channels,
+ shardScheme: SHARD_SCHEME,
+ useWithAi: join(base, "/use-with-ai"),
+ };
+ if (opts.hasArchives) {
+ corpus.bulkArchives = {
+ manifest: join(base, "/archives/manifest.json"),
+ note: "Whole-channel transcript and live-chat archives for offline bulk ingestion.",
+ };
+ }
+ return corpus;
+}
+
+// Build the aggregate hub corpus: a directory of member origins, each pointing
+// at its own corpus.json. No shard data — the hub reads members cross-origin.
+export function buildHubCorpus(
+ members: HubMemberInput[],
+ opts: { hubTitle: string; hubUrl?: string; generatedAt: string },
+): HubCorpus {
+ const sites: HubCorpusSite[] = members
+ .filter((m) => m.siteUrl)
+ .map((m) => {
+ const url = m.siteUrl.replace(/\/+$/, "");
+ return {
+ siteId: m.siteId,
+ title: m.siteTitle,
+ url,
+ corpus: `${url}/corpus.json`,
+ siteJson: `${url}/site.json`,
+ pwa: m.pwa === true,
+ };
+ });
+ return {
+ spec: CORPUS_SPEC_VERSION,
+ kind: "hub",
+ generatedAt: opts.generatedAt,
+ hub: { title: opts.hubTitle, ...(opts.hubUrl ? { url: opts.hubUrl } : {}) },
+ sites,
+ federation: {
+ description:
+ "This is a federation hub. Each site below is an independent origin " +
+ "serving its own corpus.json and CORS-enabled JSON shards. To search " +
+ "the whole federation, fetch each site's corpus.json and follow its " +
+ "shardScheme; results can be merged client-side.",
+ },
+ useWithAi: join(opts.hubUrl, "/use-with-ai"),
+ };
+}
+
+// Cap on how many channels/sites we inline into llms.txt. The full set is always
+// in corpus.json; llms.txt is a human/LLM-readable overview, not an exhaustive
+// index. (Bounded either way — this is per-channel, never per-video.)
+const LLMS_INLINE_LIMIT = 100;
+
+// Render the per-site llms.txt (llmstxt.org convention: H1 + blockquote summary
+// + linked sections).
+export function renderSiteLlmsTxt(corpus: SiteCorpus): string {
+ const base = corpus.site.url;
+ const out: string[] = [];
+ out.push(`# ${corpus.site.title}`);
+ out.push("");
+ const summary =
+ (corpus.site.description ? corpus.site.description.trim() + " " : "") +
+ `A machine-navigable archive of ${corpus.totals.videos.toLocaleString()} ` +
+ `transcripts across ${corpus.totals.channels} channel(s). ` +
+ `See corpus.json for the shard API.`;
+ out.push(`> ${summary}`);
+ out.push("");
+ out.push("## Ask AI");
+ out.push(
+ `- [Use with AI](${join(base, "/use-with-ai")}): in-browser chat (bring ` +
+ `your own API key) and MCP-server setup for Claude Code, Cursor, and other tools.`,
+ );
+ out.push("");
+ out.push("## Corpus");
+ out.push(
+ `- [corpus.json](${join(base, "/corpus.json")}): machine-readable index — ` +
+ `channels and how to fetch any transcript from the paginated JSON shards.`,
+ );
+ if (corpus.bulkArchives) {
+ out.push(
+ `- [Bulk archives](${join(base, "/downloads")}): whole-channel transcript ` +
+ `and live-chat zips for offline ingestion.`,
+ );
+ }
+ out.push("");
+ out.push("## Channels");
+ const shown = corpus.channels.slice(0, LLMS_INLINE_LIMIT);
+ for (const c of shown) {
+ out.push(`- ${c.name} — ${c.videoCount} transcripts: ${c.manifests.transcripts}`);
+ }
+ if (corpus.channels.length > shown.length) {
+ out.push(
+ `- …and ${corpus.channels.length - shown.length} more — see corpus.json for the full list.`,
+ );
+ }
+ return out.join("\n") + "\n";
+}
+
+// Render the aggregate hub llms.txt.
+export function renderHubLlmsTxt(corpus: HubCorpus): string {
+ const base = corpus.hub.url;
+ const out: string[] = [];
+ out.push(`# ${corpus.hub.title}`);
+ out.push("");
+ out.push(
+ `> A federated hub across ${corpus.sites.length} transcript archive(s). ` +
+ `Each site is an independent origin with its own machine-readable corpus. ` +
+ `See corpus.json for the federation directory.`,
+ );
+ out.push("");
+ out.push("## Ask AI");
+ out.push(
+ `- [Use with AI](${join(base, "/use-with-ai")}): ask across the whole ` +
+ `federation (bring your own API key) or wire up the MCP server.`,
+ );
+ out.push("");
+ out.push("## Federation");
+ out.push(
+ `- [corpus.json](${join(base, "/corpus.json")}): machine-readable directory ` +
+ `of every member site and its corpus endpoint.`,
+ );
+ out.push("");
+ out.push("## Sites");
+ for (const s of corpus.sites.slice(0, LLMS_INLINE_LIMIT)) {
+ out.push(`- ${s.title}: ${s.url} (corpus: ${s.corpus})`);
+ }
+ return out.join("\n") + "\n";
+}
+
+// robots.txt — allow crawling and advertise the LLM index + sitemap.
+export function renderRobotsTxt(opts: { siteUrl?: string }): string {
+ const out: string[] = ["User-agent: *", "Allow: /", ""];
+ out.push("# AI / LLM corpus index: /llms.txt and /corpus.json");
+ if (opts.siteUrl) {
+ out.push(`Sitemap: ${opts.siteUrl.replace(/\/+$/, "")}/sitemap.xml`);
+ }
+ return out.join("\n") + "\n";
+}
+
+// sitemap.xml of the app's static routes. Only meaningful with an absolute
+// siteUrl; callers skip emission otherwise. One file regardless of corpus size.
+export function renderSitemapXml(opts: {
+ siteUrl: string;
+ routes: string[];
+}): string {
+ const base = opts.siteUrl.replace(/\/+$/, "");
+ const urls = opts.routes
+ .map((r) => ` <url><loc>${base}${r === "/" ? "/" : r}</loc></url>`)
+ .join("\n");
+ return (
+ `<?xml version="1.0" encoding="UTF-8"?>\n` +
+ `<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">\n` +
+ `${urls}\n` +
+ `</urlset>\n`
+ );
+}
diff --git a/common/lib/transcriptToMarkdown.test.ts b/common/lib/transcriptToMarkdown.test.ts
@@ -0,0 +1,50 @@
+import { test } from "node:test";
+import assert from "node:assert/strict";
+import { transcriptToMarkdown } from "./transcriptToMarkdown";
+
+// Run with:
+// pnpm --filter yt-dlp-transcript-common exec tsx --test common/lib/transcriptToMarkdown.test.ts
+
+const base = {
+ id: "abc123",
+ title: "Episode One",
+ channel: "Alice",
+ uploadDate: "20191218",
+ duration: 3661,
+ webpageUrl: "https://youtube.com/watch?v=abc123",
+ description: "A first episode.",
+ cues: [
+ { start: 0, end: 2, text: "Hello there" },
+ { start: 3661.25, end: 3663, text: "General Kenobi" },
+ ],
+};
+
+test("renders header, description, and timestamped cues by default", () => {
+ const md = transcriptToMarkdown(base);
+ assert.match(md, /^# Episode One/);
+ assert.match(md, /- Channel: Alice/);
+ assert.match(md, /- Uploaded: 2019-12-18/);
+ assert.match(md, /- Duration: 1:01:01/);
+ assert.match(md, /- Source: https:\/\/youtube\.com/);
+ assert.match(md, /## Description\n\nA first episode\./);
+ assert.match(md, /\[0:00\] Hello there/);
+ assert.match(md, /\[1:01:01\] General Kenobi/);
+});
+
+test("timestamps:false emits plain prose lines", () => {
+ const md = transcriptToMarkdown(base, { timestamps: false });
+ assert.match(md, /\nHello there\n/);
+ assert.doesNotMatch(md, /\[0:00\]/);
+});
+
+test("missing cues → explicit no-transcript marker", () => {
+ const md = transcriptToMarkdown({ id: "x", title: "Empty", cues: undefined });
+ assert.match(md, /_\(no transcript available\)_/);
+});
+
+test("maxCues truncates and notes it", () => {
+ const md = transcriptToMarkdown(base, { maxCues: 1 });
+ assert.match(md, /Hello there/);
+ assert.doesNotMatch(md, /General Kenobi/);
+ assert.match(md, /truncated: showing 1 of 2 cues/);
+});
diff --git a/common/lib/transcriptToMarkdown.ts b/common/lib/transcriptToMarkdown.ts
@@ -0,0 +1,108 @@
+import type { Cue } from "./vtt";
+import { formatDate, formatDuration } from "./format";
+
+// The subset of a transcripts-page record (TranscriptDetail, see transcripts.ts)
+// needed to render a self-contained markdown document. Kept as its own loose
+// type so callers on the client (which reconstruct records from shard JSON) and
+// on the server (MCP, build tools) can all feed it without importing the full
+// TranscriptDetail chain.
+export type TranscriptMarkdownInput = {
+ id: string;
+ title: string;
+ channel?: string;
+ channelSlug?: string;
+ uploadDate?: string; // "YYYYMMDD"
+ duration?: number; // seconds
+ webpageUrl?: string;
+ description?: string;
+ tags?: string[];
+ cues?: Cue[] | undefined;
+};
+
+export type TranscriptMarkdownOptions = {
+ // Prefix each cue line with a [h:mm:ss] timestamp. Default true — timestamps
+ // let an LLM cite a moment and let a reader jump to it. Turn off for the
+ // cleanest possible prose block.
+ timestamps?: boolean;
+ // Include the video description section. Default true.
+ includeDescription?: boolean;
+ // Include a "Tags" line. Default false — tags are noisy for most Q&A.
+ includeTags?: boolean;
+ // Cap the number of cue lines emitted (for fitting a context window). When
+ // truncated, a marker line is appended. Default: no cap.
+ maxCues?: number;
+};
+
+// [h:mm:ss] / [m:ss] label for a cue start. formatDuration returns "" for 0, so
+// handle the zero case explicitly here (a transcript's first cue is often 0s).
+function stamp(totalSeconds: number): string {
+ const s = Math.max(0, Math.floor(totalSeconds));
+ return s === 0 ? "0:00" : formatDuration(s);
+}
+
+// Render a transcript record as a clean, self-contained markdown document:
+// a metadata header, an optional description, and the transcript body. This is
+// the single source of truth for "transcript → text for an AI" across the MCP
+// server, the in-browser copy buttons, and the chat retrieval context.
+export function transcriptToMarkdown(
+ input: TranscriptMarkdownInput,
+ options: TranscriptMarkdownOptions = {},
+): string {
+ const {
+ timestamps = true,
+ includeDescription = true,
+ includeTags = false,
+ maxCues,
+ } = options;
+
+ const lines: string[] = [];
+ lines.push(`# ${input.title || input.id}`);
+ lines.push("");
+
+ const meta: string[] = [];
+ if (input.channel) meta.push(`- Channel: ${input.channel}`);
+ if (input.uploadDate) meta.push(`- Uploaded: ${formatDate(input.uploadDate)}`);
+ if (typeof input.duration === "number" && input.duration > 0) {
+ meta.push(`- Duration: ${formatDuration(input.duration)}`);
+ }
+ meta.push(`- Video ID: ${input.id}`);
+ if (input.webpageUrl) meta.push(`- Source: ${input.webpageUrl}`);
+ if (includeTags && input.tags && input.tags.length > 0) {
+ meta.push(`- Tags: ${input.tags.join(", ")}`);
+ }
+ lines.push(...meta);
+
+ if (includeDescription && input.description && input.description.trim()) {
+ lines.push("");
+ lines.push("## Description");
+ lines.push("");
+ lines.push(input.description.trim());
+ }
+
+ lines.push("");
+ lines.push("## Transcript");
+ lines.push("");
+
+ const cues = input.cues;
+ if (!cues || cues.length === 0) {
+ lines.push("_(no transcript available)_");
+ return lines.join("\n") + "\n";
+ }
+
+ const limit =
+ typeof maxCues === "number" && maxCues >= 0
+ ? Math.min(maxCues, cues.length)
+ : cues.length;
+ for (let i = 0; i < limit; i++) {
+ const cue = cues[i];
+ const text = cue.text.trim();
+ if (!text) continue;
+ lines.push(timestamps ? `[${stamp(cue.start)}] ${text}` : text);
+ }
+ if (limit < cues.length) {
+ lines.push("");
+ lines.push(`_(transcript truncated: showing ${limit} of ${cues.length} cues)_`);
+ }
+
+ return lines.join("\n") + "\n";
+}
diff --git a/export/CHANGELOG.md b/export/CHANGELOG.md
@@ -1,6 +1,12 @@
# Changelog
## [Unreleased]
+- **Bring-your-own-AI: the archive is now machine-navigable for AI tools.** Every site publishes a small fixed set of discovery files — `llms.txt` (an LLM-readable overview) and `corpus.json` (a documented index of the channels and *how to fetch any transcript* from the existing paginated JSON shards), plus `robots.txt` and a `sitemap.xml`. Nothing is generated per video (the shard scheme is documented instead), so the file count stays constant no matter how large the corpus grows. This lets Claude Code and other tools browse and answer questions about the archive by fetching a couple of URLs. The federated hub publishes an aggregate `corpus.json`/`llms.txt` spanning every member site.
+- **New MCP server (`mcp/`) for Claude Code, Cursor, and other MCP clients.** A local tool that exposes the archive as MCP tools — `list_channels`, `search_transcripts` (timestamped snippets), `get_transcript`, `get_video_metadata` — reading the same static shards over disk or HTTP. It can point at one site or federate a whole hub. It never changes or hosts the site; see `mcp/README.md` for setup.
+- **New "Use with AI" page + copy-for-AI buttons.** A `/use-with-ai` page (linked from the header and footer) explains the in-browser chat, the `llms.txt`/`corpus.json` discovery files, and the MCP server. The player toolbar gains a "Copy as Markdown" control that copies the current transcript or live chat as clean, timestamped markdown for pasting into any AI chat, and the search results header gains a "Copy for AI" button that copies the matched videos and their hit snippets as context.
+- **New in-browser "Ask AI" chat (`/ask`), bring-your-own-key.** Ask a question and get an answer grounded in the transcripts, with citations back to the source videos and timestamps. It runs entirely in your browser: it searches the published transcript shards, sends the relevant excerpts to the AI provider you choose (Anthropic, OpenAI, or Google Gemini) using your own API key, and streams the reply. The key is stored only on your device (or just for the session) and requests go straight to the provider — this site hosts no AI and sees no key. On the federated hub the chat searches across every member site.
+
+## [0.6.4] - 2026-07-06
- **Fixed: sites always opened in light mode until you re-picked a theme.** If you'd chosen dark (or left it on "system" with a dark device), the page still loaded light on every visit and only switched after you opened the theme menu again. The saved theme is now re-applied before the page paints, so your choice sticks across reloads — no flash, no re-toggling.
## [0.6.3] - 2026-07-04
diff --git a/export/app/ask/AskChat.tsx b/export/app/ask/AskChat.tsx
@@ -0,0 +1,384 @@
+"use client";
+
+import { useCallback, useEffect, useRef, useState } from "react";
+import {
+ PROVIDERS,
+ askStream,
+ type Provider,
+ type ChatMessage,
+} from "../lib/askProvider";
+import {
+ loadCorpus,
+ retrieve,
+ buildContext,
+ type CorpusInfo,
+ type RetrievedVideo,
+} from "../lib/askRetrieval";
+
+const SYSTEM_PROMPT =
+ "You are a helpful assistant answering questions about a video-transcript " +
+ "archive. Base your answer ONLY on the transcript excerpts provided with the " +
+ "user's question. Cite the excerpts you use by their bracketed number, e.g. " +
+ "[1]. Each excerpt shows a video title, channel, and timestamped lines. If the " +
+ "excerpts do not contain enough to answer, say so plainly rather than guessing. " +
+ "Be concise.";
+
+const K_PROVIDER = "ytdlp-tb:ai:provider";
+const K_REMEMBER = "ytdlp-tb:ai:remember";
+const keyFor = (p: Provider) => `ytdlp-tb:ai:key:${p}`;
+const modelFor = (p: Provider) => `ytdlp-tb:ai:model:${p}`;
+
+type UiMessage = {
+ role: "user" | "assistant";
+ content: string;
+ sources?: RetrievedVideo[];
+ truncated?: boolean;
+ error?: boolean;
+};
+
+export default function AskChat() {
+ const [provider, setProvider] = useState<Provider>("anthropic");
+ const [apiKey, setApiKey] = useState("");
+ const [model, setModel] = useState(PROVIDERS.anthropic.defaultModel);
+ const [remember, setRemember] = useState(true);
+ const [showKey, setShowKey] = useState(false);
+
+ const [corpus, setCorpus] = useState<CorpusInfo | null>(null);
+ const [corpusError, setCorpusError] = useState<string | null>(null);
+ const [messages, setMessages] = useState<UiMessage[]>([]);
+ const [input, setInput] = useState("");
+ const [busy, setBusy] = useState(false);
+ const abortRef = useRef<AbortController | null>(null);
+ const scrollRef = useRef<HTMLDivElement | null>(null);
+
+ // Restore saved provider + (if remembered) that provider's key/model.
+ useEffect(() => {
+ try {
+ const savedProvider = localStorage.getItem(K_PROVIDER) as Provider | null;
+ const rememberSaved = localStorage.getItem(K_REMEMBER) !== "0";
+ const p =
+ savedProvider && PROVIDERS[savedProvider] ? savedProvider : "anthropic";
+ setProvider(p);
+ setRemember(rememberSaved);
+ loadProviderCreds(p, rememberSaved);
+ } catch {
+ /* storage unavailable */
+ }
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ }, []);
+
+ // Load the channel list once.
+ useEffect(() => {
+ const ac = new AbortController();
+ loadCorpus(ac.signal)
+ .then(setCorpus)
+ .catch((e: Error) => setCorpusError(e.message));
+ return () => ac.abort();
+ }, []);
+
+ useEffect(() => {
+ scrollRef.current?.scrollTo({ top: scrollRef.current.scrollHeight });
+ }, [messages]);
+
+ function loadProviderCreds(p: Provider, rememberOn: boolean) {
+ if (rememberOn) {
+ setApiKey(localStorage.getItem(keyFor(p)) ?? "");
+ setModel(localStorage.getItem(modelFor(p)) ?? PROVIDERS[p].defaultModel);
+ } else {
+ setApiKey("");
+ setModel(PROVIDERS[p].defaultModel);
+ }
+ }
+
+ function onProviderChange(p: Provider) {
+ setProvider(p);
+ try {
+ localStorage.setItem(K_PROVIDER, p);
+ } catch {
+ /* ignore */
+ }
+ loadProviderCreds(p, remember);
+ }
+
+ function persistKey(p: Provider, key: string, mdl: string, rememberOn: boolean) {
+ try {
+ if (rememberOn) {
+ localStorage.setItem(keyFor(p), key);
+ localStorage.setItem(modelFor(p), mdl);
+ } else {
+ localStorage.removeItem(keyFor(p));
+ localStorage.removeItem(modelFor(p));
+ }
+ localStorage.setItem(K_REMEMBER, rememberOn ? "1" : "0");
+ } catch {
+ /* ignore */
+ }
+ }
+
+ const send = useCallback(async () => {
+ const question = input.trim();
+ if (!question || busy) return;
+ if (!apiKey.trim()) return;
+ if (!corpus) return;
+
+ persistKey(provider, apiKey, model, remember);
+
+ const priorTurns: ChatMessage[] = messages
+ .filter((m) => !m.error)
+ .map((m) => ({ role: m.role, content: m.content }));
+
+ setInput("");
+ setMessages((prev) => [
+ ...prev,
+ { role: "user", content: question },
+ { role: "assistant", content: "" },
+ ]);
+ setBusy(true);
+ const ac = new AbortController();
+ abortRef.current = ac;
+
+ // Update the trailing assistant message immutably.
+ const patchLast = (fn: (m: UiMessage) => UiMessage) =>
+ setMessages((prev) => {
+ const next = prev.slice();
+ next[next.length - 1] = fn(next[next.length - 1]);
+ return next;
+ });
+
+ try {
+ const { videos, truncated } = await retrieve(corpus.channels, question, {
+ signal: ac.signal,
+ });
+ patchLast((m) => ({ ...m, sources: videos, truncated }));
+
+ const context = buildContext(videos);
+ const apiMessages: ChatMessage[] = [
+ ...priorTurns,
+ {
+ role: "user",
+ content: `${question}\n\n---\nTranscript excerpts you may cite (by number):\n${context}`,
+ },
+ ];
+
+ await askStream({
+ provider,
+ apiKey: apiKey.trim(),
+ model: model.trim() || PROVIDERS[provider].defaultModel,
+ system: SYSTEM_PROMPT,
+ messages: apiMessages,
+ signal: ac.signal,
+ onDelta: (chunk) =>
+ patchLast((m) => ({ ...m, content: m.content + chunk })),
+ });
+ } catch (e) {
+ const err = e as Error;
+ if (err.name === "AbortError") {
+ patchLast((m) => ({ ...m, content: m.content + "\n\n_(stopped)_" }));
+ } else {
+ patchLast((m) => ({
+ ...m,
+ content: m.content || err.message,
+ error: true,
+ }));
+ }
+ } finally {
+ setBusy(false);
+ abortRef.current = null;
+ }
+ }, [input, busy, apiKey, corpus, provider, model, remember, messages]);
+
+ const stop = () => abortRef.current?.abort();
+
+ const info = PROVIDERS[provider];
+ const channelCount = corpus?.channels.length ?? 0;
+
+ return (
+ <div className="flex flex-col gap-5">
+ {/* Provider / key settings */}
+ <details className="rounded-lg border border-border bg-card/40" open={!apiKey}>
+ <summary className="cursor-pointer px-4 py-2.5 text-sm font-medium text-foreground">
+ {apiKey ? `${info.label} · key set` : "Set up your AI provider"}
+ </summary>
+ <div className="flex flex-col gap-3 border-t border-border px-4 py-3">
+ <div className="flex flex-wrap gap-3">
+ <label className="flex flex-col gap-1 text-xs text-muted-foreground">
+ Provider
+ <select
+ value={provider}
+ onChange={(e) => onProviderChange(e.target.value as Provider)}
+ className="rounded-md border border-border bg-background px-2 py-1.5 text-sm text-foreground"
+ >
+ {(Object.keys(PROVIDERS) as Provider[]).map((p) => (
+ <option key={p} value={p}>
+ {PROVIDERS[p].label}
+ </option>
+ ))}
+ </select>
+ </label>
+ <label className="flex min-w-[10rem] flex-1 flex-col gap-1 text-xs text-muted-foreground">
+ Model
+ <input
+ list="ask-models"
+ value={model}
+ onChange={(e) => setModel(e.target.value)}
+ className="rounded-md border border-border bg-background px-2 py-1.5 text-sm text-foreground"
+ />
+ <datalist id="ask-models">
+ {info.models.map((m) => (
+ <option key={m} value={m} />
+ ))}
+ </datalist>
+ </label>
+ </div>
+ <label className="flex flex-col gap-1 text-xs text-muted-foreground">
+ API key
+ <div className="flex gap-2">
+ <input
+ type={showKey ? "text" : "password"}
+ value={apiKey}
+ onChange={(e) => setApiKey(e.target.value)}
+ placeholder={info.keyHint}
+ autoComplete="off"
+ className="flex-1 rounded-md border border-border bg-background px-2 py-1.5 font-mono text-sm text-foreground"
+ />
+ <button
+ type="button"
+ onClick={() => setShowKey((v) => !v)}
+ className="rounded-md border border-border px-2 text-xs text-muted-foreground hover:text-foreground"
+ >
+ {showKey ? "Hide" : "Show"}
+ </button>
+ </div>
+ </label>
+ <label className="flex items-center gap-2 text-xs text-muted-foreground">
+ <input
+ type="checkbox"
+ checked={remember}
+ onChange={(e) => {
+ setRemember(e.target.checked);
+ persistKey(provider, apiKey, model, e.target.checked);
+ }}
+ />
+ Remember my key in this browser
+ </label>
+ <p className="text-xs text-muted-foreground/80">
+ Your key is stored only {remember ? "in this browser" : "for this page session"} and is sent
+ directly to {info.label} — never to this site. Requests are billed to
+ your own account.{" "}
+ <a href={info.keyUrl} target="_blank" rel="noopener noreferrer" className="text-brand hover:underline">
+ Get a key →
+ </a>
+ </p>
+ </div>
+ </details>
+
+ {corpusError && (
+ <p className="text-sm text-warning">
+ Couldn't load the corpus index ({corpusError}). This chat needs the
+ site's <code className="font-mono">corpus.json</code>.
+ </p>
+ )}
+
+ {/* Conversation */}
+ <div
+ ref={scrollRef}
+ className="flex max-h-[60vh] min-h-[8rem] flex-col gap-4 overflow-y-auto"
+ >
+ {messages.length === 0 && (
+ <p className="text-sm text-muted-foreground">
+ Ask a question about the transcripts
+ {channelCount > 0 ? ` (${channelCount} channel${channelCount === 1 ? "" : "s"} indexed)` : ""}.
+ Answers cite the videos they draw from.
+ </p>
+ )}
+ {messages.map((m, i) => (
+ <div key={i} className="flex flex-col gap-2">
+ <div
+ className={
+ m.role === "user"
+ ? "self-end rounded-lg bg-primary/10 px-3 py-2 text-sm text-foreground"
+ : `rounded-lg border border-border bg-card/40 px-3 py-2 text-sm ${m.error ? "text-warning" : "text-foreground"}`
+ }
+ >
+ <span className="whitespace-pre-wrap">{m.content || (busy && i === messages.length - 1 ? "…" : "")}</span>
+ </div>
+ {m.role === "assistant" && m.sources && m.sources.length > 0 && (
+ <ol className="ml-1 flex flex-col gap-1 text-xs text-muted-foreground">
+ {m.sources.map((s, si) => (
+ <li key={s.key}>
+ <span className="font-mono text-brand">[{si + 1}]</span>{" "}
+ {s.url ? (
+ <a href={s.url} target="_blank" rel="noopener noreferrer" className="hover:underline">
+ {s.title}
+ </a>
+ ) : (
+ s.title
+ )}{" "}
+ <span className="text-muted-foreground/70">
+ — {s.channel}
+ {s.siteTitle ? ` · ${s.siteTitle}` : ""} · {s.snippets.map((sn) => sn.clock).join(", ")}
+ </span>
+ </li>
+ ))}
+ {m.truncated && (
+ <li className="text-muted-foreground/60">
+ (search was truncated; ask more specifically for better coverage)
+ </li>
+ )}
+ </ol>
+ )}
+ </div>
+ ))}
+ </div>
+
+ {/* Composer */}
+ <form
+ onSubmit={(e) => {
+ e.preventDefault();
+ void send();
+ }}
+ className="flex flex-col gap-2"
+ >
+ <textarea
+ value={input}
+ onChange={(e) => setInput(e.target.value)}
+ onKeyDown={(e) => {
+ if (e.key === "Enter" && (e.metaKey || e.ctrlKey)) {
+ e.preventDefault();
+ void send();
+ }
+ }}
+ rows={2}
+ placeholder={
+ corpus
+ ? "Ask about the transcripts… (⌘/Ctrl+Enter to send)"
+ : "Loading corpus…"
+ }
+ disabled={!corpus}
+ className="w-full resize-y rounded-md border border-border bg-background px-3 py-2 text-sm text-foreground"
+ />
+ <div className="flex items-center gap-2">
+ <button
+ type="submit"
+ disabled={busy || !corpus || !input.trim() || !apiKey.trim()}
+ className="rounded-md bg-primary px-4 py-2 text-sm font-medium text-primary-foreground transition-colors hover:bg-brand-strong disabled:opacity-50"
+ >
+ {busy ? "Thinking…" : "Ask"}
+ </button>
+ {busy && (
+ <button
+ type="button"
+ onClick={stop}
+ className="rounded-md border border-border px-3 py-2 text-sm text-muted-foreground hover:text-foreground"
+ >
+ Stop
+ </button>
+ )}
+ {!apiKey.trim() && (
+ <span className="text-xs text-muted-foreground">Set an API key above to start.</span>
+ )}
+ </div>
+ </form>
+ </div>
+ );
+}
diff --git a/export/app/ask/page.tsx b/export/app/ask/page.tsx
@@ -0,0 +1,40 @@
+import type { Metadata } from "next";
+import Link from "next/link";
+import { currentSite } from "../lib/site";
+import { instanceMode } from "../lib/mode";
+import AskChat from "./AskChat";
+
+export const metadata: Metadata = { title: "Ask AI" };
+
+// Static shell for the bring-your-own-key chat. All the work happens client-side
+// in <AskChat/> — retrieval over the published shards + a direct call to the
+// visitor's chosen provider. The export stays fully static.
+export default function AskPage() {
+ const site = currentSite();
+ const scope = instanceMode() === "hub" ? "the federation" : "the transcripts";
+
+ return (
+ <div className="mx-auto flex max-w-3xl flex-col gap-6">
+ <header className="flex flex-col gap-2 border-b border-border pb-5">
+ <p className="font-mono text-xs uppercase tracking-[0.18em] text-brand">
+ Ask AI · {site.headerTitle}
+ </p>
+ <h1 className="font-display text-3xl font-semibold leading-tight text-foreground">
+ Ask a question about {scope}
+ </h1>
+ <p className="max-w-prose text-sm text-muted-foreground">
+ Bring your own API key. The chat searches the transcripts in your
+ browser, sends the relevant excerpts to your chosen AI, and answers with
+ citations. Nothing is hosted here — your key and the requests stay
+ between your browser and the provider. See{" "}
+ <Link href="/use-with-ai" className="text-brand hover:underline">
+ Use with AI
+ </Link>{" "}
+ for other ways to use this archive.
+ </p>
+ </header>
+
+ <AskChat />
+ </div>
+ );
+}
diff --git a/export/app/components/Footer.tsx b/export/app/components/Footer.tsx
@@ -54,6 +54,15 @@ export default function Footer() {
Offline
</a>
)}
+ <span aria-hidden="true" className="text-muted-foreground/50">
+ ·
+ </span>
+ <a
+ href="/use-with-ai"
+ className="underline underline-offset-2 hover:text-foreground transition-colors"
+ >
+ Use with AI
+ </a>
</div>
{socialLinks.length > 0 && (
<ul className="flex items-center gap-3 list-none">
diff --git a/export/app/components/Header.tsx b/export/app/components/Header.tsx
@@ -68,6 +68,12 @@ export default function Header() {
Downloads
</Link>
)}
+ <Link
+ href="/use-with-ai"
+ className="text-foreground hover:text-brand transition-colors"
+ >
+ Use with AI
+ </Link>
</nav>
<div className="ml-auto flex items-center gap-2 sm:gap-3 shrink-0">
diff --git a/export/app/lib/askProvider.ts b/export/app/lib/askProvider.ts
@@ -0,0 +1,243 @@
+// Bring-your-own-key chat providers, called DIRECTLY from the browser. The
+// export site is fully static — there is no backend and we host no inference.
+// The visitor supplies their own API key; requests go browser → provider only.
+//
+// Each provider is CORS-enabled for browser use:
+// - Anthropic: requires the `anthropic-dangerous-direct-browser-access` header
+// - OpenAI: chat-completions is CORS-open with a Bearer key
+// - Gemini: Generative Language API takes the key as a query param
+//
+// Everything streams so the answer renders as it arrives.
+
+export type Provider = "anthropic" | "openai" | "gemini";
+
+export type ChatRole = "user" | "assistant";
+export type ChatMessage = { role: ChatRole; content: string };
+
+export type ProviderInfo = {
+ id: Provider;
+ label: string;
+ defaultModel: string;
+ // A few known-good model ids to offer; the field stays free-text so a user can
+ // enter anything their key supports.
+ models: string[];
+ keyHint: string;
+ keyUrl: string;
+};
+
+export const PROVIDERS: Record<Provider, ProviderInfo> = {
+ anthropic: {
+ id: "anthropic",
+ label: "Anthropic (Claude)",
+ defaultModel: "claude-haiku-4-5",
+ models: ["claude-haiku-4-5", "claude-sonnet-5", "claude-opus-4-8"],
+ keyHint: "sk-ant-…",
+ keyUrl: "https://console.anthropic.com/settings/keys",
+ },
+ openai: {
+ id: "openai",
+ label: "OpenAI",
+ defaultModel: "gpt-4o-mini",
+ models: ["gpt-4o-mini", "gpt-4o"],
+ keyHint: "sk-…",
+ keyUrl: "https://platform.openai.com/api-keys",
+ },
+ gemini: {
+ id: "gemini",
+ label: "Google Gemini",
+ defaultModel: "gemini-2.5-flash",
+ models: ["gemini-2.5-flash", "gemini-2.5-pro"],
+ keyHint: "AIza…",
+ keyUrl: "https://aistudio.google.com/app/apikey",
+ },
+};
+
+export type AskOptions = {
+ provider: Provider;
+ apiKey: string;
+ model?: string;
+ system: string;
+ messages: ChatMessage[];
+ maxTokens?: number;
+ signal?: AbortSignal;
+ onDelta?: (chunk: string) => void;
+};
+
+// Stream a completion from the chosen provider, invoking onDelta for each text
+// chunk and resolving with the full concatenated answer.
+export async function askStream(opts: AskOptions): Promise<string> {
+ switch (opts.provider) {
+ case "anthropic":
+ return askAnthropic(opts);
+ case "openai":
+ return askOpenAI(opts);
+ case "gemini":
+ return askGemini(opts);
+ default:
+ throw new Error(`unknown provider: ${opts.provider}`);
+ }
+}
+
+// ─── shared SSE plumbing ───
+
+async function* sseLines(
+ res: Response,
+ signal?: AbortSignal,
+): AsyncGenerator<string> {
+ if (!res.body) throw new Error("no response body");
+ const reader = res.body.getReader();
+ const decoder = new TextDecoder();
+ let buf = "";
+ try {
+ while (true) {
+ if (signal?.aborted) throw new DOMException("aborted", "AbortError");
+ const { done, value } = await reader.read();
+ if (done) break;
+ buf += decoder.decode(value, { stream: true });
+ // SSE events are separated by a blank line; yield each `data:` payload.
+ let idx: number;
+ while ((idx = buf.indexOf("\n")) >= 0) {
+ const line = buf.slice(0, idx).trim();
+ buf = buf.slice(idx + 1);
+ if (line.startsWith("data:")) yield line.slice(5).trim();
+ }
+ }
+ } finally {
+ reader.releaseLock();
+ }
+}
+
+async function ensureOk(res: Response, provider: string): Promise<void> {
+ if (res.ok) return;
+ let detail = "";
+ try {
+ detail = await res.text();
+ } catch {
+ /* ignore */
+ }
+ throw new Error(
+ `${provider} request failed (${res.status}). ${detail.slice(0, 300)}`,
+ );
+}
+
+function emit(full: string[], chunk: string, onDelta?: (c: string) => void): void {
+ if (!chunk) return;
+ full.push(chunk);
+ onDelta?.(chunk);
+}
+
+// ─── Anthropic ───
+
+async function askAnthropic(opts: AskOptions): Promise<string> {
+ const res = await fetch("https://api.anthropic.com/v1/messages", {
+ method: "POST",
+ signal: opts.signal,
+ headers: {
+ "content-type": "application/json",
+ "x-api-key": opts.apiKey,
+ "anthropic-version": "2023-06-01",
+ "anthropic-dangerous-direct-browser-access": "true",
+ },
+ body: JSON.stringify({
+ model: opts.model || PROVIDERS.anthropic.defaultModel,
+ max_tokens: opts.maxTokens ?? 1024,
+ system: opts.system,
+ messages: opts.messages.map((m) => ({ role: m.role, content: m.content })),
+ stream: true,
+ }),
+ });
+ await ensureOk(res, "Anthropic");
+ const full: string[] = [];
+ for await (const data of sseLines(res, opts.signal)) {
+ if (!data || data === "[DONE]") continue;
+ let evt: unknown;
+ try {
+ evt = JSON.parse(data);
+ } catch {
+ continue;
+ }
+ const e = evt as {
+ type?: string;
+ delta?: { type?: string; text?: string };
+ };
+ if (e.type === "content_block_delta" && e.delta?.type === "text_delta") {
+ emit(full, e.delta.text ?? "", opts.onDelta);
+ }
+ }
+ return full.join("");
+}
+
+// ─── OpenAI ───
+
+async function askOpenAI(opts: AskOptions): Promise<string> {
+ const res = await fetch("https://api.openai.com/v1/chat/completions", {
+ method: "POST",
+ signal: opts.signal,
+ headers: {
+ "content-type": "application/json",
+ authorization: `Bearer ${opts.apiKey}`,
+ },
+ body: JSON.stringify({
+ model: opts.model || PROVIDERS.openai.defaultModel,
+ stream: true,
+ messages: [
+ { role: "system", content: opts.system },
+ ...opts.messages.map((m) => ({ role: m.role, content: m.content })),
+ ],
+ }),
+ });
+ await ensureOk(res, "OpenAI");
+ const full: string[] = [];
+ for await (const data of sseLines(res, opts.signal)) {
+ if (!data || data === "[DONE]") continue;
+ let evt: unknown;
+ try {
+ evt = JSON.parse(data);
+ } catch {
+ continue;
+ }
+ const e = evt as { choices?: { delta?: { content?: string } }[] };
+ const chunk = e.choices?.[0]?.delta?.content;
+ if (chunk) emit(full, chunk, opts.onDelta);
+ }
+ return full.join("");
+}
+
+// ─── Google Gemini ───
+
+async function askGemini(opts: AskOptions): Promise<string> {
+ const model = opts.model || PROVIDERS.gemini.defaultModel;
+ const url =
+ `https://generativelanguage.googleapis.com/v1beta/models/` +
+ `${encodeURIComponent(model)}:streamGenerateContent?alt=sse&key=${encodeURIComponent(opts.apiKey)}`;
+ const res = await fetch(url, {
+ method: "POST",
+ signal: opts.signal,
+ headers: { "content-type": "application/json" },
+ body: JSON.stringify({
+ system_instruction: { parts: [{ text: opts.system }] },
+ // Gemini uses role "model" for assistant turns.
+ contents: opts.messages.map((m) => ({
+ role: m.role === "assistant" ? "model" : "user",
+ parts: [{ text: m.content }],
+ })),
+ }),
+ });
+ await ensureOk(res, "Gemini");
+ const full: string[] = [];
+ for await (const data of sseLines(res, opts.signal)) {
+ if (!data) continue;
+ let evt: unknown;
+ try {
+ evt = JSON.parse(data);
+ } catch {
+ continue;
+ }
+ const e = evt as {
+ candidates?: { content?: { parts?: { text?: string }[] } }[];
+ };
+ const parts = e.candidates?.[0]?.content?.parts ?? [];
+ for (const p of parts) emit(full, p.text ?? "", opts.onDelta);
+ }
+ return full.join("");
+}
diff --git a/export/app/lib/askRetrieval.ts b/export/app/lib/askRetrieval.ts
@@ -0,0 +1,183 @@
+import {
+ transcriptPageFileName,
+ type ChannelTranscriptsManifest,
+} from "yt-dlp-transcript-common/lib/manifest";
+import type { TranscriptDetail } from "yt-dlp-transcript-common/lib/transcripts";
+import { formatDuration } from "yt-dlp-transcript-common/lib/format";
+
+// Browser-side retrieval for the /ask chat. Reads the site's own published
+// static shards via the documented corpus.json contract — same for a single
+// site (same-origin) or a hub (fanning out over member origins, which serve
+// CORS-* shards). No server, no new files. Shards are fetched force-cache so
+// the service worker / HTTP cache absorbs repeat questions.
+
+export type ChannelRef = {
+ slug: string;
+ name: string;
+ base: string; // origin ("" = same-origin); member origin in hub mode
+ siteTitle?: string;
+};
+
+export type CorpusInfo = { channels: ChannelRef[]; hub: boolean; title: string };
+
+export type RetrievedVideo = {
+ key: string;
+ videoId: string;
+ title: string;
+ channel: string;
+ siteTitle?: string;
+ uploadDate: string;
+ url?: string;
+ snippets: { clock: string; seconds: number; text: string }[];
+};
+
+async function getJson<T>(url: string, signal?: AbortSignal): Promise<T> {
+ const res = await fetch(url, { cache: "force-cache", signal });
+ if (!res.ok) throw new Error(`GET ${url} -> ${res.status}`);
+ return (await res.json()) as T;
+}
+
+type CorpusChannelJson = { slug: string; name?: string };
+type SiteCorpusJson = {
+ channels?: CorpusChannelJson[];
+ site?: { title?: string };
+};
+type HubCorpusJson = {
+ kind?: string;
+ hub?: { title?: string };
+ sites?: { siteId: string; title: string; url: string }[];
+};
+
+// Load the channel list from /corpus.json. On a hub, fetch every member's own
+// corpus.json and tag each channel with its origin + site title.
+export async function loadCorpus(signal?: AbortSignal): Promise<CorpusInfo> {
+ const root = await getJson<SiteCorpusJson & HubCorpusJson>(
+ "/corpus.json",
+ signal,
+ );
+ if (root.kind === "hub" && Array.isArray(root.sites)) {
+ const channels: ChannelRef[] = [];
+ for (const site of root.sites) {
+ const base = site.url.replace(/\/+$/, "");
+ try {
+ const c = await getJson<SiteCorpusJson>(`${base}/corpus.json`, signal);
+ for (const ch of c.channels ?? []) {
+ channels.push({
+ slug: ch.slug,
+ name: ch.name ?? ch.slug,
+ base,
+ siteTitle: site.title,
+ });
+ }
+ } catch {
+ // skip an unreachable member
+ }
+ }
+ return { channels, hub: true, title: root.hub?.title ?? "the federation" };
+ }
+ const channels: ChannelRef[] = (root.channels ?? []).map((ch) => ({
+ slug: ch.slug,
+ name: ch.name ?? ch.slug,
+ base: "",
+ }));
+ return { channels, hub: false, title: root.site?.title ?? "this archive" };
+}
+
+function clock(seconds: number): string {
+ const s = Math.max(0, Math.floor(seconds));
+ return s === 0 ? "0:00" : formatDuration(s);
+}
+
+// Scan the channels' transcript shards for the query and return the top matching
+// videos with a few cue snippets each. Bounded by `limit` and `maxPages` so a
+// question can't fetch an unbounded slice of a large (or hub-wide) corpus.
+export async function retrieve(
+ channels: ChannelRef[],
+ query: string,
+ opts: {
+ limit?: number;
+ snippetsPerVideo?: number;
+ maxPages?: number;
+ signal?: AbortSignal;
+ } = {},
+): Promise<{ videos: RetrievedVideo[]; truncated: boolean }> {
+ const limit = opts.limit ?? 12;
+ const perVideo = opts.snippetsPerVideo ?? 4;
+ const maxPages = opts.maxPages ?? 60;
+ const q = query.toLowerCase();
+ const terms = q.split(/\s+/).filter((t) => t.length >= 3);
+ const match = (text: string): boolean => {
+ const t = text.toLowerCase();
+ return terms.length === 0 ? t.includes(q) : terms.some((term) => t.includes(term));
+ };
+
+ const videos: RetrievedVideo[] = [];
+ let pages = 0;
+ let truncated = false;
+
+ outer: for (const ch of channels) {
+ let manifest: ChannelTranscriptsManifest;
+ try {
+ manifest = await getJson(
+ `${ch.base}/transcripts/${ch.slug}/manifest.json`,
+ opts.signal,
+ );
+ } catch {
+ continue;
+ }
+ for (let p = 0; p < manifest.pageCount; p++) {
+ if (pages >= maxPages) {
+ truncated = true;
+ break outer;
+ }
+ let records: TranscriptDetail[];
+ try {
+ records = await getJson(
+ `${ch.base}/transcripts/${ch.slug}/${transcriptPageFileName(p)}`,
+ opts.signal,
+ );
+ } catch {
+ continue;
+ }
+ pages++;
+ for (const rec of records) {
+ const snippets: RetrievedVideo["snippets"] = [];
+ for (const cue of rec.cues ?? []) {
+ if (!match(cue.text)) continue;
+ snippets.push({
+ clock: clock(cue.start),
+ seconds: cue.start,
+ text: cue.text.trim().replace(/\s+/g, " ").slice(0, 240),
+ });
+ if (snippets.length >= perVideo) break;
+ }
+ if (snippets.length === 0) continue;
+ videos.push({
+ key: `${ch.base}|${rec.id}`,
+ videoId: rec.id,
+ title: rec.title,
+ channel: ch.name,
+ siteTitle: ch.siteTitle,
+ uploadDate: rec.uploadDate,
+ url: rec.webpageUrl,
+ snippets,
+ });
+ if (videos.length >= limit) break outer;
+ }
+ }
+ }
+ return { videos, truncated };
+}
+
+// Assemble numbered retrieved excerpts into the context block that gets appended
+// to the user's question, plus the matching system instruction to cite by index.
+export function buildContext(videos: RetrievedVideo[]): string {
+ if (videos.length === 0) return "(no matching transcript excerpts were found)";
+ return videos
+ .map((v, i) => {
+ const head = `[${i + 1}] "${v.title}" — ${v.channel}${v.siteTitle ? ` (${v.siteTitle})` : ""}`;
+ const lines = v.snippets.map((s) => ` [${s.clock}] ${s.text}`).join("\n");
+ return `${head}\n${lines}`;
+ })
+ .join("\n\n");
+}
diff --git a/export/app/use-with-ai/page.tsx b/export/app/use-with-ai/page.tsx
@@ -0,0 +1,139 @@
+import type { Metadata } from "next";
+import Link from "next/link";
+import { currentSite } from "../lib/site";
+import { instanceMode } from "../lib/mode";
+import { hasArchives } from "../lib/archives";
+
+export const metadata: Metadata = { title: "Use with AI" };
+
+// A human-facing hub for the "bring your own AI" surface: the in-browser chat,
+// the machine-readable discovery files (llms.txt / corpus.json), and the MCP
+// server. Hub-aware — on a hub build it frames everything as federation-wide.
+// Fully static server component; no data fetching.
+export default function UseWithAiPage() {
+ const site = currentSite();
+ const isHub = instanceMode() === "hub";
+ const base = site.siteUrl?.replace(/\/+$/, "") ?? "";
+ const abs = (p: string) => (base ? `${base}${p}` : p);
+ const scope = isHub ? "the whole federation" : "this archive";
+ const envVar = isHub ? "TRANSCRIPT_HUB_URL" : "TRANSCRIPT_SITE_URL";
+ const target = base || (isHub ? "https://your-hub.example" : "https://your-site.example");
+
+ const mcpSnippet = `{
+ "mcpServers": {
+ "${site.siteId}": {
+ "command": "pnpm",
+ "args": [
+ "-C", "/path/to/yt-dlp-transcript-browser",
+ "--filter", "yt-dlp-transcript-mcp",
+ "exec", "tsx", "src/index.ts"
+ ],
+ "env": { "${envVar}": "${target}" }
+ }
+ }
+}`;
+
+ return (
+ <div className="mx-auto flex max-w-3xl flex-col gap-10">
+ <header className="flex flex-col gap-3 border-b border-border pb-6">
+ <p className="font-mono text-xs uppercase tracking-[0.18em] text-brand">
+ Use with AI · {site.headerTitle}
+ </p>
+ <h1 className="font-display text-3xl font-semibold leading-tight text-foreground">
+ Ask an AI about {scope}
+ </h1>
+ <p className="max-w-prose text-sm text-muted-foreground">
+ Nothing is hosted or paid for here — you bring your own AI. Chat in your
+ browser with your own API key, or point a tool like Claude Code at the
+ machine-readable index and let it browse the transcripts itself.
+ </p>
+ </header>
+
+ <section className="flex flex-col gap-3">
+ <h2 className="font-mono text-xs uppercase tracking-[0.14em] text-muted-foreground">
+ Chat in your browser
+ </h2>
+ <div className="flex flex-col gap-3 rounded-lg border border-border bg-card/40 p-5">
+ <p className="text-sm text-muted-foreground">
+ A retrieval-augmented chat that searches the transcripts and answers
+ with citations. It runs entirely in your browser and calls your own
+ provider (Anthropic, OpenAI, or Google Gemini) with a key you supply —
+ the key stays on your device and requests go straight to the provider.
+ </p>
+ <div>
+ <Link
+ href="/ask"
+ className="inline-flex items-center gap-2 rounded-md bg-primary px-4 py-2 text-sm font-medium text-primary-foreground transition-colors hover:bg-brand-strong"
+ >
+ Open the chat →
+ </Link>
+ </div>
+ </div>
+ </section>
+
+ <section className="flex flex-col gap-3">
+ <h2 className="font-mono text-xs uppercase tracking-[0.14em] text-muted-foreground">
+ For coding agents & LLM tools
+ </h2>
+ <div className="flex flex-col gap-3 rounded-lg border border-border bg-card/40 p-5">
+ <p className="text-sm text-muted-foreground">
+ {scope[0].toUpperCase() + scope.slice(1)} publishes a small, fixed set
+ of discovery files. An agent (e.g. Claude Code via <code className="font-mono">WebFetch</code>)
+ can read these and navigate every transcript without any per-video
+ pages — the paginated shard scheme is documented inline.
+ </p>
+ <ul className="flex flex-col gap-2 text-sm">
+ <li>
+ <a href={abs("/llms.txt")} className="font-mono text-brand hover:underline">
+ /llms.txt
+ </a>
+ <span className="text-muted-foreground"> — an LLM-readable overview and link map.</span>
+ </li>
+ <li>
+ <a href={abs("/corpus.json")} className="font-mono text-brand hover:underline">
+ /corpus.json
+ </a>
+ <span className="text-muted-foreground">
+ {" "}— the channel index and exactly how to fetch any transcript
+ from the JSON shards{isHub ? " across every member site" : ""}.
+ </span>
+ </li>
+ </ul>
+ </div>
+ </section>
+
+ <section className="flex flex-col gap-3">
+ <h2 className="font-mono text-xs uppercase tracking-[0.14em] text-muted-foreground">
+ MCP server
+ </h2>
+ <div className="flex flex-col gap-3 rounded-lg border border-border bg-card/40 p-5">
+ <p className="text-sm text-muted-foreground">
+ The repo ships an MCP server that exposes {scope} to Claude Code,
+ Claude Desktop, Cursor, and other MCP clients as tools
+ (<code className="font-mono">search_transcripts</code>,
+ {" "}<code className="font-mono">get_transcript</code>, …). It reads the
+ same static shards — over HTTP or from a local build
+ {isHub ? ", federating every member site" : ""}. Add it to a client:
+ </p>
+ <pre className="overflow-x-auto rounded-md border border-border bg-muted/50 p-3 font-mono text-xs text-foreground">
+ <code>{mcpSnippet}</code>
+ </pre>
+ <p className="text-xs text-muted-foreground/80">
+ Setup details and the <code className="font-mono">claude mcp add</code>{" "}
+ command are in <code className="font-mono">mcp/README.md</code>.
+ </p>
+ </div>
+ </section>
+
+ {hasArchives() && (
+ <p className="text-xs text-muted-foreground/70">
+ Ingesting in bulk instead? The{" "}
+ <a href="/downloads" className="text-brand hover:underline">
+ Downloads page
+ </a>{" "}
+ has whole-channel transcript zips.
+ </p>
+ )}
+ </div>
+ );
+}
diff --git a/mcp/README.md b/mcp/README.md
@@ -0,0 +1,75 @@
+# yt-dlp-transcript-mcp
+
+An [MCP](https://modelcontextprotocol.io) server that exposes a transcript
+archive — a single site, or a federated hub of sites — to Claude Code, Claude
+Desktop, Cursor, and any other MCP client.
+
+It is a **local tool you run yourself**. It does not change the archive: it only
+reads the site's already-published static JSON shards (`corpus.json` +
+`transcripts/<slug>/…`), either from disk or over HTTP. Nothing is hosted for you.
+
+## Tools
+
+| Tool | What it does |
+|------|--------------|
+| `list_channels` | List channels (name, slug, video count; site in hub mode). |
+| `search_transcripts` | Search captions for a term/phrase (or regex); returns matching videos with timestamped snippets. Optional `channel` filter. |
+| `get_transcript` | One video's full transcript as clean markdown (metadata + timestamped captions). |
+| `get_video_metadata` | One video's metadata (title, channel, date, duration, description, tags, source URL) without the transcript body. |
+
+## Data source (pick one)
+
+Resolved from flags or env — precedence hub > remote > local:
+
+| Mode | Flag | Env | Notes |
+|------|------|-----|-------|
+| Hub | `--hub <url>` | `TRANSCRIPT_HUB_URL` | Federate across every member of a hub's `corpus.json`. |
+| Remote | `--remote <url>` | `TRANSCRIPT_SITE_URL` | One deployed site origin. |
+| Local | `--local <dir>` | `TRANSCRIPT_LOCAL_DIR` | A composed public dir on disk (default `./export/public`). |
+
+## Run it
+
+From the monorepo (cwd is set to the package dir by `pnpm --filter … exec`, so
+the TypeScript path alias resolves):
+
+```sh
+# a deployed site
+pnpm --filter yt-dlp-transcript-mcp exec tsx src/index.ts --remote https://rekietalyzer.pages.dev
+
+# a hub, federating every member site
+pnpm --filter yt-dlp-transcript-mcp exec tsx src/index.ts --hub https://archilyzer.com
+
+# local shards on disk
+pnpm --filter yt-dlp-transcript-mcp exec tsx src/index.ts --local ../export/public
+```
+
+Logs go to stderr; stdout is the MCP JSON-RPC channel.
+
+## Add to Claude Code
+
+```sh
+claude mcp add rekietalyzer \
+ --env TRANSCRIPT_SITE_URL=https://rekietalyzer.pages.dev \
+ -- pnpm -C /ABS/PATH/TO/yt-dlp-transcript-browser --filter yt-dlp-transcript-mcp exec tsx src/index.ts
+```
+
+## Add to any MCP client (mcp.json)
+
+```json
+{
+ "mcpServers": {
+ "rekietalyzer": {
+ "command": "pnpm",
+ "args": [
+ "-C", "/ABS/PATH/TO/yt-dlp-transcript-browser",
+ "--filter", "yt-dlp-transcript-mcp",
+ "exec", "tsx", "src/index.ts"
+ ],
+ "env": { "TRANSCRIPT_SITE_URL": "https://rekietalyzer.pages.dev" }
+ }
+ }
+}
+```
+
+Swap `TRANSCRIPT_SITE_URL` for `TRANSCRIPT_HUB_URL` to federate a whole hub, or
+`TRANSCRIPT_LOCAL_DIR` to read a local build.
diff --git a/mcp/package.json b/mcp/package.json
@@ -0,0 +1,22 @@
+{
+ "name": "yt-dlp-transcript-mcp",
+ "version": "0.1.0",
+ "private": true,
+ "type": "module",
+ "description": "MCP server exposing a yt-dlp transcript archive (or a federated hub) to Claude Code and other MCP clients — reads the site's static JSON shards.",
+ "bin": {
+ "yt-dlp-transcript-mcp": "src/index.ts"
+ },
+ "scripts": {
+ "start": "tsx src/index.ts",
+ "typecheck": "tsc --noEmit -p tsconfig.json"
+ },
+ "dependencies": {
+ "@modelcontextprotocol/sdk": "^1.12.0"
+ },
+ "devDependencies": {
+ "@types/node": "^20.19.39",
+ "tsx": "^4.21.0",
+ "typescript": "^5.6.0"
+ }
+}
diff --git a/mcp/src/index.ts b/mcp/src/index.ts
@@ -0,0 +1,57 @@
+#!/usr/bin/env tsx
+import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
+import {
+ LocalSource,
+ RemoteSource,
+ HubSource,
+ type ShardSource,
+} from "./source";
+import { createServer } from "./server";
+
+// Resolve the data source from flags or env. Precedence: hub > remote > local >
+// positional > default. All logging goes to stderr — stdout is the MCP
+// JSON-RPC channel and must not be polluted.
+//
+// --hub <url> | TRANSCRIPT_HUB_URL federate over a hub's member sites
+// --remote <url> | TRANSCRIPT_SITE_URL one deployed site origin
+// --local <dir> | TRANSCRIPT_LOCAL_DIR a composed public dir on disk
+// <positional> an http(s) URL → remote, otherwise a local dir
+// (default) ./export/public
+export function resolveSource(argv: string[]): ShardSource {
+ const flag = (name: string): string | undefined => {
+ const i = argv.indexOf(name);
+ return i >= 0 && i + 1 < argv.length ? argv[i + 1] : undefined;
+ };
+
+ const hub = flag("--hub") ?? process.env.TRANSCRIPT_HUB_URL;
+ if (hub) return new HubSource(hub);
+
+ const remote =
+ flag("--remote") ?? flag("--url") ?? process.env.TRANSCRIPT_SITE_URL;
+ if (remote) return new RemoteSource(remote);
+
+ const local = flag("--local") ?? process.env.TRANSCRIPT_LOCAL_DIR;
+ if (local) return new LocalSource(local);
+
+ const positional = argv.find((a) => !a.startsWith("-"));
+ if (positional) {
+ return /^https?:\/\//i.test(positional)
+ ? new RemoteSource(positional)
+ : new LocalSource(positional);
+ }
+
+ return new LocalSource("export/public");
+}
+
+async function main(): Promise<void> {
+ const source = resolveSource(process.argv.slice(2));
+ console.error(`[yt-dlp-transcript-mcp] source: ${source.label}`);
+ const server = createServer(source);
+ await server.connect(new StdioServerTransport());
+ console.error("[yt-dlp-transcript-mcp] ready on stdio");
+}
+
+main().catch((err) => {
+ console.error(err);
+ process.exit(1);
+});
diff --git a/mcp/src/search.ts b/mcp/src/search.ts
@@ -0,0 +1,180 @@
+import { formatDuration } from "yt-dlp-transcript-common/lib/format";
+import type { TranscriptDetail } from "yt-dlp-transcript-common/lib/transcripts";
+import type { ChannelRef, ShardSource } from "./source";
+
+export type Snippet = { clock: string; seconds: number; text: string };
+
+export type SearchHit = {
+ videoId: string;
+ channelSlug: string;
+ channelName: string;
+ siteTitle?: string;
+ title: string;
+ uploadDate: string;
+ webpageUrl?: string;
+ matches: number;
+ snippets: Snippet[];
+};
+
+export type SearchResult = {
+ hits: SearchHit[];
+ scanned: { channels: number; pages: number };
+ truncated: boolean;
+};
+
+// A hard ceiling on shard pages fetched per query so a rare term over a large
+// (or hub-wide) corpus can't run away. Reaching it sets `truncated`.
+const MAX_PAGES = 400;
+
+function clock(seconds: number): string {
+ const s = Math.max(0, Math.floor(seconds));
+ return s === 0 ? "0:00" : formatDuration(s);
+}
+
+function makeMatcher(query: string, regex: boolean): (text: string) => boolean {
+ if (regex) {
+ const re = new RegExp(query, "i");
+ return (t) => re.test(t);
+ }
+ const needle = query.toLowerCase();
+ return (t) => t.toLowerCase().includes(needle);
+}
+
+function truncate(text: string, max = 240): string {
+ const t = text.trim().replace(/\s+/g, " ");
+ return t.length > max ? t.slice(0, max - 1) + "…" : t;
+}
+
+// Scan a source's transcript shards for `query`, returning up to `limit` matched
+// videos (each with a few snippet cues + timestamps). A plain server-side scan
+// with the site's own match semantics (substring by default, or a regex) — no
+// browser index needed. Stops early at `limit` and at MAX_PAGES.
+export async function searchTranscripts(
+ source: ShardSource,
+ opts: {
+ query: string;
+ channel?: string;
+ regex?: boolean;
+ limit?: number;
+ snippetsPerVideo?: number;
+ },
+): Promise<SearchResult> {
+ const limit = opts.limit ?? 20;
+ const snippetsPerVideo = opts.snippetsPerVideo ?? 4;
+ const match = makeMatcher(opts.query, opts.regex === true);
+
+ let channels = await source.listChannels();
+ if (opts.channel) {
+ const want = opts.channel.toLowerCase();
+ channels = channels.filter(
+ (c) =>
+ c.slug.toLowerCase() === want ||
+ c.key.toLowerCase() === want ||
+ c.name.toLowerCase() === want,
+ );
+ }
+
+ const hits: SearchHit[] = [];
+ let pagesScanned = 0;
+ let channelsScanned = 0;
+ let truncated = false;
+
+ outer: for (const ch of channels) {
+ let manifest;
+ try {
+ manifest = await source.transcriptsManifest(ch);
+ } catch {
+ continue; // unreachable/missing channel — skip
+ }
+ channelsScanned++;
+ for (let page = 0; page < manifest.pageCount; page++) {
+ if (pagesScanned >= MAX_PAGES) {
+ truncated = true;
+ break outer;
+ }
+ let records: TranscriptDetail[];
+ try {
+ records = await source.transcriptPage(ch, page);
+ } catch {
+ continue;
+ }
+ pagesScanned++;
+ for (const rec of records) {
+ const titleHit = match(rec.title ?? "");
+ const snippets: Snippet[] = [];
+ let matches = 0;
+ for (const cue of rec.cues ?? []) {
+ if (!match(cue.text)) continue;
+ matches++;
+ if (snippets.length < snippetsPerVideo) {
+ snippets.push({
+ clock: clock(cue.start),
+ seconds: cue.start,
+ text: truncate(cue.text),
+ });
+ }
+ }
+ if (matches === 0 && !titleHit) continue;
+ hits.push({
+ videoId: rec.id,
+ channelSlug: ch.slug,
+ channelName: ch.name,
+ ...(ch.siteTitle ? { siteTitle: ch.siteTitle } : {}),
+ title: rec.title,
+ uploadDate: rec.uploadDate,
+ webpageUrl: rec.webpageUrl,
+ matches: matches || 1,
+ snippets,
+ });
+ if (hits.length >= limit) break outer;
+ }
+ }
+ }
+
+ return {
+ hits,
+ scanned: { channels: channelsScanned, pages: pagesScanned },
+ truncated,
+ };
+}
+
+// Locate a single video across the source's channels via each channel's
+// slugToPage map, returning the full record + its channel. `channelHint`
+// (slug/key/name) short-circuits the scan when the caller knows the channel.
+export async function findVideo(
+ source: ShardSource,
+ videoId: string,
+ channelHint?: string,
+): Promise<{ ch: ChannelRef; record: TranscriptDetail } | null> {
+ let channels = await source.listChannels();
+ if (channelHint) {
+ const want = channelHint.toLowerCase();
+ const filtered = channels.filter(
+ (c) =>
+ c.slug.toLowerCase() === want ||
+ c.key.toLowerCase() === want ||
+ c.name.toLowerCase() === want,
+ );
+ // Prefer the hinted channel(s), but fall back to a full scan if not found.
+ if (filtered.length > 0) channels = filtered;
+ }
+ for (const ch of channels) {
+ let manifest;
+ try {
+ manifest = await source.transcriptsManifest(ch);
+ } catch {
+ continue;
+ }
+ const page = manifest.slugToPage[videoId];
+ if (page === undefined) continue;
+ let records: TranscriptDetail[];
+ try {
+ records = await source.transcriptPage(ch, page);
+ } catch {
+ continue;
+ }
+ const record = records.find((r) => r.id === videoId);
+ if (record) return { ch, record };
+ }
+ return null;
+}
diff --git a/mcp/src/server.ts b/mcp/src/server.ts
@@ -0,0 +1,215 @@
+import { Server } from "@modelcontextprotocol/sdk/server/index.js";
+import {
+ CallToolRequestSchema,
+ ListToolsRequestSchema,
+} from "@modelcontextprotocol/sdk/types.js";
+import { transcriptToMarkdown } from "yt-dlp-transcript-common/lib/transcriptToMarkdown";
+import { formatDate } from "yt-dlp-transcript-common/lib/format";
+import type { ShardSource } from "./source";
+import { searchTranscripts, findVideo } from "./search";
+
+type ToolResult = {
+ content: { type: "text"; text: string }[];
+ isError?: boolean;
+};
+
+function text(s: string): ToolResult {
+ return { content: [{ type: "text", text: s }] };
+}
+function errorText(s: string): ToolResult {
+ return { content: [{ type: "text", text: s }], isError: true };
+}
+
+const TOOLS = [
+ {
+ name: "list_channels",
+ description:
+ "List the channels in this transcript archive (in hub mode, across every " +
+ "federated member site). Returns each channel's display name, slug, video " +
+ "count, and owning site.",
+ inputSchema: { type: "object", properties: {}, additionalProperties: false },
+ },
+ {
+ name: "search_transcripts",
+ description:
+ "Search transcript captions for a term or phrase and return matching " +
+ "videos with timestamped snippets. Substring match by default; set regex " +
+ "to true for a case-insensitive regular expression. Optionally restrict to " +
+ "one channel (by slug or name).",
+ inputSchema: {
+ type: "object",
+ properties: {
+ query: { type: "string", description: "Term, phrase, or regex to find." },
+ channel: {
+ type: "string",
+ description: "Optional channel slug or name to restrict the search to.",
+ },
+ regex: {
+ type: "boolean",
+ description: "Treat query as a case-insensitive regex (default false).",
+ },
+ limit: {
+ type: "number",
+ description: "Max matching videos to return (default 20).",
+ },
+ },
+ required: ["query"],
+ additionalProperties: false,
+ },
+ },
+ {
+ name: "get_transcript",
+ description:
+ "Fetch one video's full transcript as clean markdown (metadata header + " +
+ "timestamped captions). Provide the video id; optionally the channel to " +
+ "skip the cross-channel lookup.",
+ inputSchema: {
+ type: "object",
+ properties: {
+ video_id: { type: "string", description: "The video id." },
+ channel: { type: "string", description: "Optional owning channel slug/name." },
+ timestamps: {
+ type: "boolean",
+ description: "Prefix each caption line with a timestamp (default true).",
+ },
+ },
+ required: ["video_id"],
+ additionalProperties: false,
+ },
+ },
+ {
+ name: "get_video_metadata",
+ description:
+ "Fetch one video's metadata (title, channel, upload date, duration, " +
+ "description, tags, source URL) without the transcript body.",
+ inputSchema: {
+ type: "object",
+ properties: {
+ video_id: { type: "string", description: "The video id." },
+ channel: { type: "string", description: "Optional owning channel slug/name." },
+ },
+ required: ["video_id"],
+ additionalProperties: false,
+ },
+ },
+];
+
+// Build a configured MCP server over a data source. The same four tools work
+// for local / remote / hub sources — only the ShardSource differs.
+export function createServer(source: ShardSource): Server {
+ const server = new Server(
+ { name: "yt-dlp-transcript-mcp", version: "0.1.0" },
+ { capabilities: { tools: {} } },
+ );
+
+ server.setRequestHandler(ListToolsRequestSchema, async () => ({ tools: TOOLS }));
+
+ server.setRequestHandler(CallToolRequestSchema, async (req) => {
+ const name = req.params.name;
+ const args = (req.params.arguments ?? {}) as Record<string, unknown>;
+ try {
+ switch (name) {
+ case "list_channels":
+ return await handleListChannels(source);
+ case "search_transcripts":
+ return await handleSearch(source, args);
+ case "get_transcript":
+ return await handleGetTranscript(source, args);
+ case "get_video_metadata":
+ return await handleGetMetadata(source, args);
+ default:
+ return errorText(`unknown tool: ${name}`);
+ }
+ } catch (e) {
+ return errorText(`${name} failed: ${(e as Error).message}`);
+ }
+ });
+
+ return server;
+}
+
+async function handleListChannels(source: ShardSource): Promise<ToolResult> {
+ const channels = await source.listChannels();
+ if (channels.length === 0) return text(`No channels found in ${source.label}.`);
+ const lines = channels.map((c) => {
+ const parts = [`slug: ${c.slug}`];
+ if (c.videoCount != null) parts.push(`${c.videoCount} videos`);
+ if (c.siteTitle) parts.push(`site: ${c.siteTitle}`);
+ return `- ${c.name} (${parts.join(", ")})`;
+ });
+ return text(`${channels.length} channel(s) in ${source.label}:\n${lines.join("\n")}`);
+}
+
+async function handleSearch(
+ source: ShardSource,
+ args: Record<string, unknown>,
+): Promise<ToolResult> {
+ const query = String(args.query ?? "").trim();
+ if (!query) return errorText("query is required");
+ const result = await searchTranscripts(source, {
+ query,
+ channel: typeof args.channel === "string" ? args.channel : undefined,
+ regex: args.regex === true,
+ limit: typeof args.limit === "number" ? args.limit : undefined,
+ });
+ const footer =
+ `\n\n(scanned ${result.scanned.pages} page(s) across ` +
+ `${result.scanned.channels} channel(s)${result.truncated ? "; scan truncated at the page cap" : ""})`;
+ if (result.hits.length === 0) {
+ return text(`No matches for "${query}".${footer}`);
+ }
+ const blocks = result.hits.map((h) => {
+ const head =
+ `### ${h.title}\n` +
+ `- video_id: ${h.videoId} | channel: ${h.channelName}` +
+ (h.siteTitle ? ` | site: ${h.siteTitle}` : "") +
+ ` | uploaded: ${formatDate(h.uploadDate)} | matches: ${h.matches}` +
+ (h.webpageUrl ? `\n- source: ${h.webpageUrl}` : "");
+ const snips = h.snippets.map((s) => ` - [${s.clock}] ${s.text}`).join("\n");
+ return snips ? `${head}\n${snips}` : head;
+ });
+ return text(
+ `${result.hits.length} video(s) matching "${query}":\n\n${blocks.join("\n\n")}${footer}`,
+ );
+}
+
+async function handleGetTranscript(
+ source: ShardSource,
+ args: Record<string, unknown>,
+): Promise<ToolResult> {
+ const videoId = String(args.video_id ?? "").trim();
+ if (!videoId) return errorText("video_id is required");
+ const found = await findVideo(
+ source,
+ videoId,
+ typeof args.channel === "string" ? args.channel : undefined,
+ );
+ if (!found) return errorText(`video not found: ${videoId}`);
+ const md = transcriptToMarkdown(found.record, {
+ timestamps: args.timestamps !== false,
+ includeTags: true,
+ });
+ return text(md);
+}
+
+async function handleGetMetadata(
+ source: ShardSource,
+ args: Record<string, unknown>,
+): Promise<ToolResult> {
+ const videoId = String(args.video_id ?? "").trim();
+ if (!videoId) return errorText("video_id is required");
+ const found = await findVideo(
+ source,
+ videoId,
+ typeof args.channel === "string" ? args.channel : undefined,
+ );
+ if (!found) return errorText(`video not found: ${videoId}`);
+ const { cues, ...meta } = found.record;
+ return text(
+ JSON.stringify(
+ { ...meta, channelName: found.ch.name, cueCount: cues?.length ?? 0 },
+ null,
+ 2,
+ ),
+ );
+}
diff --git a/mcp/src/source.ts b/mcp/src/source.ts
@@ -0,0 +1,203 @@
+import { readFile, readdir } from "node:fs/promises";
+import path from "node:path";
+import {
+ transcriptPageFileName,
+ type ChannelTranscriptsManifest,
+} from "yt-dlp-transcript-common/lib/manifest";
+import type { TranscriptDetail } from "yt-dlp-transcript-common/lib/transcripts";
+
+// A channel the source can serve. `siteId`/`siteUrl` are only populated in hub
+// mode (so results can be attributed to the owning member site); `key` is the
+// stable, source-unique handle a tool passes back to fetch this channel's data.
+export type ChannelRef = {
+ key: string;
+ slug: string;
+ name: string;
+ videoCount?: number;
+ siteId?: string;
+ siteTitle?: string;
+ siteUrl?: string;
+};
+
+// A read-only view over a transcript corpus's paginated JSON shards. Three
+// implementations (local dir / remote origin / federated hub) all speak the
+// same three-call contract, which mirrors the documented shard scheme in
+// corpus.json: list channels, get a channel's manifest (slug -> page map), get
+// a page of full transcript records.
+export interface ShardSource {
+ readonly label: string;
+ listChannels(): Promise<ChannelRef[]>;
+ transcriptsManifest(ch: ChannelRef): Promise<ChannelTranscriptsManifest>;
+ transcriptPage(ch: ChannelRef, page: number): Promise<TranscriptDetail[]>;
+}
+
+// Shape of the channels we read out of a site corpus.json (Layer 1). Kept loose
+// — we only need slug/name/count.
+type CorpusJsonChannel = { slug: string; name?: string; videoCount?: number };
+type SiteCorpusJson = { channels?: CorpusJsonChannel[] };
+type HubCorpusJson = {
+ kind?: string;
+ sites?: { siteId: string; title: string; url: string }[];
+};
+
+// ─── Local: read composed shards from a directory on disk ───
+// `dir` is a composed public dir (or any dir containing transcripts/<slug>/…).
+// Prefers corpus.json for the channel list (names + counts); falls back to
+// listing the transcripts/ subdirectories so it works even pre-Layer-1.
+export class LocalSource implements ShardSource {
+ readonly label: string;
+ constructor(private dir: string) {
+ this.label = `local:${dir}`;
+ }
+
+ async listChannels(): Promise<ChannelRef[]> {
+ try {
+ const raw = await readFile(path.join(this.dir, "corpus.json"), "utf8");
+ const corpus = JSON.parse(raw) as SiteCorpusJson;
+ if (Array.isArray(corpus.channels) && corpus.channels.length > 0) {
+ return corpus.channels.map((c) => ({
+ key: c.slug,
+ slug: c.slug,
+ name: c.name ?? c.slug,
+ videoCount: c.videoCount,
+ }));
+ }
+ } catch {
+ // no corpus.json — fall back to a directory listing
+ }
+ const transcriptsDir = path.join(this.dir, "transcripts");
+ let entries: string[] = [];
+ try {
+ entries = await readdir(transcriptsDir);
+ } catch {
+ return [];
+ }
+ const channels: ChannelRef[] = [];
+ for (const slug of entries.sort()) {
+ // A channel dir has a manifest.json; skip stray files.
+ try {
+ await readFile(path.join(transcriptsDir, slug, "manifest.json"), "utf8");
+ channels.push({ key: slug, slug, name: slug });
+ } catch {
+ // not a channel dir
+ }
+ }
+ return channels;
+ }
+
+ async transcriptsManifest(ch: ChannelRef): Promise<ChannelTranscriptsManifest> {
+ const raw = await readFile(
+ path.join(this.dir, "transcripts", ch.slug, "manifest.json"),
+ "utf8",
+ );
+ return JSON.parse(raw) as ChannelTranscriptsManifest;
+ }
+
+ async transcriptPage(ch: ChannelRef, page: number): Promise<TranscriptDetail[]> {
+ const raw = await readFile(
+ path.join(this.dir, "transcripts", ch.slug, transcriptPageFileName(page)),
+ "utf8",
+ );
+ return JSON.parse(raw) as TranscriptDetail[];
+ }
+}
+
+// ─── Remote: fetch shards from a deployed site origin over HTTP ───
+export class RemoteSource implements ShardSource {
+ readonly label: string;
+ private base: string;
+ constructor(baseUrl: string) {
+ this.base = baseUrl.replace(/\/+$/, "");
+ this.label = `remote:${this.base}`;
+ }
+
+ private async getJson<T>(p: string): Promise<T> {
+ const res = await fetch(`${this.base}${p}`);
+ if (!res.ok) {
+ throw new Error(`GET ${this.base}${p} -> ${res.status} ${res.statusText}`);
+ }
+ return (await res.json()) as T;
+ }
+
+ async listChannels(): Promise<ChannelRef[]> {
+ const corpus = await this.getJson<SiteCorpusJson>("/corpus.json");
+ return (corpus.channels ?? []).map((c) => ({
+ key: c.slug,
+ slug: c.slug,
+ name: c.name ?? c.slug,
+ videoCount: c.videoCount,
+ siteUrl: this.base,
+ }));
+ }
+
+ transcriptsManifest(ch: ChannelRef): Promise<ChannelTranscriptsManifest> {
+ return this.getJson(`/transcripts/${ch.slug}/manifest.json`);
+ }
+
+ transcriptPage(ch: ChannelRef, page: number): Promise<TranscriptDetail[]> {
+ return this.getJson(`/transcripts/${ch.slug}/${transcriptPageFileName(page)}`);
+ }
+}
+
+// ─── Hub: federate over every member site listed in the hub corpus.json ───
+// Each member is its own RemoteSource; channels are namespaced by site so keys
+// stay unique, and manifest/page calls dispatch to the owning member.
+export class HubSource implements ShardSource {
+ readonly label: string;
+ private hubBase: string;
+ private members = new Map<string, RemoteSource>(); // siteId -> source
+
+ constructor(hubUrl: string) {
+ this.hubBase = hubUrl.replace(/\/+$/, "");
+ this.label = `hub:${this.hubBase}`;
+ }
+
+ private memberFor(siteId: string): RemoteSource {
+ const m = this.members.get(siteId);
+ if (!m) throw new Error(`unknown hub member site: ${siteId}`);
+ return m;
+ }
+
+ async listChannels(): Promise<ChannelRef[]> {
+ const res = await fetch(`${this.hubBase}/corpus.json`);
+ if (!res.ok) {
+ throw new Error(
+ `GET ${this.hubBase}/corpus.json -> ${res.status} ${res.statusText}`,
+ );
+ }
+ const hub = (await res.json()) as HubCorpusJson;
+ const sites = hub.sites ?? [];
+ const all: ChannelRef[] = [];
+ // Sequential member fetches keep it simple and polite; the channel count is
+ // small. A failing member is skipped rather than failing the whole list.
+ for (const site of sites) {
+ const remote = new RemoteSource(site.url);
+ this.members.set(site.siteId, remote);
+ try {
+ const channels = await remote.listChannels();
+ for (const c of channels) {
+ all.push({
+ ...c,
+ key: `${site.siteId}/${c.slug}`,
+ siteId: site.siteId,
+ siteTitle: site.title,
+ siteUrl: site.url,
+ });
+ }
+ } catch {
+ // skip an unreachable member
+ }
+ }
+ return all;
+ }
+
+ transcriptsManifest(ch: ChannelRef): Promise<ChannelTranscriptsManifest> {
+ if (!ch.siteId) throw new Error("hub channel ref missing siteId");
+ return this.memberFor(ch.siteId).transcriptsManifest(ch);
+ }
+
+ transcriptPage(ch: ChannelRef, page: number): Promise<TranscriptDetail[]> {
+ if (!ch.siteId) throw new Error("hub channel ref missing siteId");
+ return this.memberFor(ch.siteId).transcriptPage(ch, page);
+ }
+}
diff --git a/mcp/tsconfig.json b/mcp/tsconfig.json
@@ -0,0 +1,13 @@
+{
+ "extends": "../tsconfig.base.json",
+ "compilerOptions": {
+ "lib": ["esnext"],
+ "types": ["node"],
+ "baseUrl": ".",
+ "paths": {
+ "yt-dlp-transcript-common/*": ["../common/*"]
+ }
+ },
+ "include": ["src/**/*.ts"],
+ "exclude": ["node_modules"]
+}
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
@@ -283,6 +283,22 @@ importers:
specifier: ^5.9.3
version: 5.9.3
+ mcp:
+ dependencies:
+ '@modelcontextprotocol/sdk':
+ specifier: ^1.12.0
+ version: 1.29.0(zod@4.3.6)
+ devDependencies:
+ '@types/node':
+ specifier: ^20.19.39
+ version: 20.19.39
+ tsx:
+ specifier: ^4.21.0
+ version: 4.21.0
+ typescript:
+ specifier: ^5.6.0
+ version: 5.9.3
+
packages:
'@alloc/quick-lru@5.2.0':
@@ -659,6 +675,12 @@ packages:
'@harperfast/extended-iterable@1.0.3':
resolution: {integrity: sha512-sSAYhQca3rDWtQUHSAPeO7axFIUJOI6hn1gjRC5APVE1a90tuyT8f5WIgRsFhhWA7htNkju2veB9eWL6YHi/Lw==}
+ '@hono/node-server@1.19.14':
+ resolution: {integrity: sha512-GwtvgtXxnWsucXvbQXkRgqksiH2Qed37H9xHZocE5sA3N8O8O8/8FA3uclQXxXVzc9XBZuEOMK7+r02FmSpHtw==}
+ engines: {node: '>=18.14.1'}
+ peerDependencies:
+ hono: ^4
+
'@humanfs/core@0.19.2':
resolution: {integrity: sha512-UhXNm+CFMWcbChXywFwkmhqjs3PRCmcSa/hfBgLIb7oQ5HNb1wS0icWsGtSAUNgefHeI+eBrA8I1fxmbHsGdvA==}
engines: {node: '>=18.18.0'}
@@ -883,6 +905,16 @@ packages:
cpu: [x64]
os: [win32]
+ '@modelcontextprotocol/sdk@1.29.0':
+ resolution: {integrity: sha512-zo37mZA9hJWpULgkRpowewez1y6ML5GsXJPY8FI0tBBCd77HEvza4jDqRKOXgHNn867PVGCyTdzqpz0izu5ZjQ==}
+ engines: {node: '>=18'}
+ peerDependencies:
+ '@cfworker/json-schema': ^4.1.1
+ zod: ^3.25 || ^4.0
+ peerDependenciesMeta:
+ '@cfworker/json-schema':
+ optional: true
+
'@msgpackr-extract/msgpackr-extract-darwin-arm64@3.0.3':
resolution: {integrity: sha512-QZHtlVgbAdy2zAqNA9Gu1UpIuI8Xvsd1v8ic6B2pZmeFnFcMWiPLfWXh7TVw4eGEZ/C9TH281KwhVoeQUKbyjw==}
cpu: [arm64]
@@ -2060,6 +2092,10 @@ packages:
'@zeit/schemas@2.36.0':
resolution: {integrity: sha512-7kjMwcChYEzMKjeex9ZFXkt1AyNov9R5HZtjBKVsmVpw7pa7ZtlCGvCBC2vnnXctaYN+aRI61HjIqeetZW5ROg==}
+ accepts@2.0.0:
+ resolution: {integrity: sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng==}
+ engines: {node: '>= 0.6'}
+
acorn-jsx@5.3.2:
resolution: {integrity: sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==}
peerDependencies:
@@ -2070,6 +2106,14 @@ packages:
engines: {node: '>=0.4.0'}
hasBin: true
+ ajv-formats@3.0.1:
+ resolution: {integrity: sha512-8iUql50EUR+uUcdRQ3HDqa6EVyo3docL8g5WJ3FNcWmu62IbkGUue/pEyLBW8VGKKucTPgqeks4fIU1DA4yowQ==}
+ peerDependencies:
+ ajv: ^8.0.0
+ peerDependenciesMeta:
+ ajv:
+ optional: true
+
ajv@6.15.0:
resolution: {integrity: sha512-fgFx7Hfoq60ytK2c7DhnF8jIvzYgOMxfugjLOSMHjLIPgenqa7S7oaagATUq99mV6IYvN2tRmC0wnTYX6iPbMw==}
@@ -2178,6 +2222,10 @@ packages:
engines: {node: '>=6.0.0'}
hasBin: true
+ body-parser@2.3.0:
+ resolution: {integrity: sha512-2cGmJupaNgg+QUwVLAucDuWuoMZ6EX9iHDRswZ5lsNYEmwPaRknMPCLZz07yTzVq/83p4o/wzbDZbBrTvGGTIw==}
+ engines: {node: '>=18'}
+
bowser@2.14.1:
resolution: {integrity: sha512-tzPjzCxygAKWFOJP011oxFHs57HzIhOEracIgAePE4pqB3LikALKnSzUyU4MGs9/iCEUuHlAJTjTc5M+u7YEGg==}
@@ -2293,9 +2341,33 @@ packages:
resolution: {integrity: sha512-kRGRZw3bLlFISDBgwTSA1TMBFN6J6GWDeubmDE3AF+3+yXL8hTWv8r5rkLbqYXY4RjPk/EzHnClI3zQf1cFmHA==}
engines: {node: '>= 0.6'}
+ content-disposition@1.1.0:
+ resolution: {integrity: sha512-5jRCH9Z/+DRP7rkvY83B+yGIGX96OYdJmzngqnw2SBSxqCFPd0w2km3s5iawpGX8krnwSGmF0FW5Nhr0Hfai3g==}
+ engines: {node: '>=18'}
+
+ content-type@1.0.5:
+ resolution: {integrity: sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==}
+ engines: {node: '>= 0.6'}
+
+ content-type@2.0.0:
+ resolution: {integrity: sha512-j/O/d7GcZCyNl7/hwZAb606rzqkyvaDctLmckbxLzHvFBzTJHuGEdodATcP3yIRoDrLHkIATJuvzbFlp/ki2cQ==}
+ engines: {node: '>=18'}
+
convert-source-map@2.0.0:
resolution: {integrity: sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==}
+ cookie-signature@1.2.2:
+ resolution: {integrity: sha512-D76uU73ulSXrD1UXF4KE2TMxVVwhsnCgfAyTg9k8P6KGZjlXKrOLe4dJQKI3Bxi5wjesZoFXJWElNWBjPZMbhg==}
+ engines: {node: '>=6.6.0'}
+
+ cookie@0.7.2:
+ resolution: {integrity: sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==}
+ engines: {node: '>= 0.6'}
+
+ cors@2.8.6:
+ resolution: {integrity: sha512-tJtZBBHA6vjIAaF6EnIaq6laBBP9aq/Y3ouVJjEfoHbRBcHBAHYcMh/w8LDrk2PvIMMq8gmopa5D4V8RmbrxGw==}
+ engines: {node: '>= 0.10'}
+
cross-spawn@7.0.6:
resolution: {integrity: sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==}
engines: {node: '>= 8'}
@@ -2409,6 +2481,10 @@ packages:
resolution: {integrity: sha512-8QmQKqEASLd5nx0U1B1okLElbUuuttJ/AnYmRXbbbGDWh6uS208EjD4Xqq/I9wK7u0v6O08XhTWnt5XtEbR6Dg==}
engines: {node: '>= 0.4'}
+ depd@2.0.0:
+ resolution: {integrity: sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==}
+ engines: {node: '>= 0.8'}
+
detect-libc@2.1.2:
resolution: {integrity: sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==}
engines: {node: '>=8'}
@@ -2430,6 +2506,9 @@ packages:
eastasianwidth@0.2.0:
resolution: {integrity: sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA==}
+ ee-first@1.1.1:
+ resolution: {integrity: sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==}
+
electron-to-chromium@1.5.344:
resolution: {integrity: sha512-4MxfbmNDm+KPh066EZy+eUnkcDPcZ35wNmOWzFuh/ijvHsve6kbLTLURy88uCNK5FbpN+yk2nQY6BYh1GEt+wg==}
@@ -2439,6 +2518,10 @@ packages:
emoji-regex@9.2.2:
resolution: {integrity: sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg==}
+ encodeurl@2.0.0:
+ resolution: {integrity: sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==}
+ engines: {node: '>= 0.8'}
+
enhanced-resolve@5.21.0:
resolution: {integrity: sha512-otxSQPw4lkOZWkHpB3zaEQs6gWYEsmX4xQF68ElXC/TWvGxGMSGOvoNbaLXm6/cS/fSfHtsEdw90y20PCd+sCA==}
engines: {node: '>=10.13.0'}
@@ -2484,6 +2567,9 @@ packages:
resolution: {integrity: sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==}
engines: {node: '>=6'}
+ escape-html@1.0.3:
+ resolution: {integrity: sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==}
+
escape-string-regexp@4.0.0:
resolution: {integrity: sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==}
engines: {node: '>=10'}
@@ -2612,6 +2698,10 @@ packages:
resolution: {integrity: sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==}
engines: {node: '>=0.10.0'}
+ etag@1.8.1:
+ resolution: {integrity: sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==}
+ engines: {node: '>= 0.6'}
+
eventemitter3@4.0.7:
resolution: {integrity: sha512-8guHBZCwKnFhYdHr2ysuRWErTwhoN2X8XELRlrRwpmfeY2jjuUN4taQMsULKUVo1K4DvZl+0pgfyoysHxvmvEw==}
@@ -2619,6 +2709,14 @@ packages:
resolution: {integrity: sha512-mQw+2fkQbALzQ7V0MY0IqdnXNOeTtP4r0lN9z7AAawCXgqea7bDii20AYrIBrFd/Hx0M2Ocz6S111CaFkUcb0Q==}
engines: {node: '>=0.8.x'}
+ eventsource-parser@3.1.0:
+ resolution: {integrity: sha512-kJezFj9YFAMLeORyi7aCLxLbD5/qWMQnoMVlVPyHIll7lgRJCc3JVln9Vgl9nwQi0YkMnhdGTMNn7CkRRAptMg==}
+ engines: {node: '>=18.0.0'}
+
+ eventsource@3.0.7:
+ resolution: {integrity: sha512-CRT1WTyuQoD771GW56XEZFQ/ZoSfWid1alKGDYMmkt2yl8UXrVR4pspqWNEcqKvVIzg6PAltWjxcSSPrboA4iA==}
+ engines: {node: '>=18.0.0'}
+
execa@5.1.1:
resolution: {integrity: sha512-8uSpZZocAZRBAPIEINJj3Lo9HyGitllczc27Eh5YYojjMFMn8yHMDMaUHE2Jqfq05D/wucwI4JGURyXt1vchyg==}
engines: {node: '>=10'}
@@ -2627,6 +2725,16 @@ packages:
resolution: {integrity: sha512-9Be3ZoN4LmYR90tUoVu2te2BsbzHfhJyfEiAVfz7N5/zv+jduIfLrV2xdQXOHbaD6KgpGdO9PRPM1Y4Q9QkPkA==}
engines: {node: ^18.19.0 || >=20.5.0}
+ express-rate-limit@8.5.2:
+ resolution: {integrity: sha512-5Kb34ipNX694DH48vN9irak1Qx30nb0PLYHXfJgw4YEjiC3ZEmZJhwOp+VfiCYwFzvFTdB9QkArYS5kXa2cx2A==}
+ engines: {node: '>= 16'}
+ peerDependencies:
+ express: '>= 4.11'
+
+ express@5.2.1:
+ resolution: {integrity: sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw==}
+ engines: {node: '>= 18'}
+
fast-deep-equal@3.1.3:
resolution: {integrity: sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==}
@@ -2671,6 +2779,10 @@ packages:
resolution: {integrity: sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==}
engines: {node: '>=8'}
+ finalhandler@2.1.1:
+ resolution: {integrity: sha512-S8KoZgRZN+a5rNwqTxlZZePjT/4cnm0ROV70LedRHZ0p8u9fRID0hJUZQpkKLzro8LfmC8sx23bY6tVNxv8pQA==}
+ engines: {node: '>= 18.0.0'}
+
find-up@5.0.0:
resolution: {integrity: sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==}
engines: {node: '>=10'}
@@ -2689,6 +2801,14 @@ packages:
resolution: {integrity: sha512-dKx12eRCVIzqCxFGplyFKJMPvLEWgmNtUrpTiJIR5u97zEhRG8ySrtboPHZXx7daLxQVrl643cTzbab2tkQjxg==}
engines: {node: '>= 0.4'}
+ forwarded@0.2.0:
+ resolution: {integrity: sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==}
+ engines: {node: '>= 0.6'}
+
+ fresh@2.0.0:
+ resolution: {integrity: sha512-Rx/WycZ60HOaqLKAi6cHRKKI7zxWbJ31MhntmtwMoaTeF7XFH9hhBp8vITaMidfljRQ6eYWCKkaTK+ykVJHP2A==}
+ engines: {node: '>= 0.8'}
+
fs-extra@11.3.4:
resolution: {integrity: sha512-CTXd6rk/M3/ULNQj8FBqBWHYBVYybQ3VPBw0xGKFe3tuH7ytT6ACnvzpIQ3UZtB8yvUKC2cXn1a+x+5EVQLovA==}
engines: {node: '>=14.14'}
@@ -2811,6 +2931,14 @@ packages:
hls.js@1.6.16:
resolution: {integrity: sha512-VSIRpLfRwlAAdGL4wiTucx2ScRipo0ed1FBatWkyt832jC4CReKstga6yIhYVwGu9LOBjuX9wzmRMeQdBJtzEA==}
+ hono@4.12.27:
+ resolution: {integrity: sha512-1yrb/+w6HWQJrUCLkJ2IF5jNIPvvFkblV5RNOYl6bV+OA6p9GLcMpHFFGTosSvHvcAUibuUukRqhlYI4z32C7Q==}
+ engines: {node: '>=16.9.0'}
+
+ http-errors@2.0.1:
+ resolution: {integrity: sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==}
+ engines: {node: '>= 0.8'}
+
human-signals@2.1.0:
resolution: {integrity: sha512-B4FFZ6q/T2jhhksgkbEW3HBvWIfDW85snkQgawt07S7J5QXTk6BkNV+0yAeZrM5QpMAdYlocGoljn0sJ/WQkFw==}
engines: {node: '>=10.17.0'}
@@ -2819,6 +2947,10 @@ packages:
resolution: {integrity: sha512-eKCa6bwnJhvxj14kZk5NCPc6Hb6BdsU9DZcOnmQKSnO1VKrfV0zCvtttPZUsBvjmNDn8rpcJfpwSYnHBjc95MQ==}
engines: {node: '>=18.18.0'}
+ iconv-lite@0.7.3:
+ resolution: {integrity: sha512-IKXpvIzjnC9XTAUbVBcMfGS0EPaIXtW6v+zr+RRp+hqULEpo0owZax6wyRwPOJbWbzjYspQwusTsfVr0ifh4uQ==}
+ engines: {node: '>=0.10.0'}
+
ieee754@1.2.1:
resolution: {integrity: sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==}
@@ -2852,6 +2984,14 @@ packages:
resolution: {integrity: sha512-5Hh7Y1wQbvY5ooGgPbDaL5iYLAPzMTUrjMulskHLH6wnv/A+1q5rgEaiuqEjB+oxGXIVZs1FF+R/KPN3ZSQYYg==}
engines: {node: '>=12'}
+ ip-address@10.2.0:
+ resolution: {integrity: sha512-/+S6j4E9AHvW9SWMSEY9Xfy66O5PWvVEJ08O0y5JGyEKQpojb0K0GKpz/v5HJ/G0vi3D2sjGK78119oXZeE0qA==}
+ engines: {node: '>= 12'}
+
+ ipaddr.js@1.9.1:
+ resolution: {integrity: sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==}
+ engines: {node: '>= 0.10'}
+
is-array-buffer@3.0.5:
resolution: {integrity: sha512-DDfANUiiG2wC1qawP66qlTugJeL5HyzMpfr8lLK+jMQirGzNod0B12cFB/9q838Ru27sBwfw78/rdoU7RERz6A==}
engines: {node: '>= 0.4'}
@@ -2936,6 +3076,9 @@ packages:
resolution: {integrity: sha512-9UoipoxYmSk6Xy7QFgRv2HDyaysmgSG75TFQs6S+3pDM7ZhKTF/bskZV+0UlABHzKjNVhPjYCLfeZUEg1wXxig==}
engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0}
+ is-promise@4.0.0:
+ resolution: {integrity: sha512-hvpoI6korhJMnej285dSg6nu1+e6uxs7zG3BYAm5byqDsgJNWwxzM6z6iZiAgQR4TJ30JmBTOwqZUw3WlyH3AQ==}
+
is-regex@1.2.1:
resolution: {integrity: sha512-MjYsKHO5O7mCsmRGxWcLWheFqN9DJ/2TmngvjKXihe6efViPqc274+Fx/4fYj/r03+ESvBdTXK0V6tA3rgez1g==}
engines: {node: '>= 0.4'}
@@ -3002,6 +3145,9 @@ packages:
resolution: {integrity: sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ==}
hasBin: true
+ jose@6.2.3:
+ resolution: {integrity: sha512-YYVDInQKFJfR/xa3ojUTl8c2KoTwiL1R5Wg9YCydwH0x0B9grbzlg5HC7mMjCtUJjbQ/YnGEZIhI5tCgfTb4Hw==}
+
js-tokens@4.0.0:
resolution: {integrity: sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==}
@@ -3023,6 +3169,9 @@ packages:
json-schema-traverse@1.0.0:
resolution: {integrity: sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==}
+ json-schema-typed@8.0.2:
+ resolution: {integrity: sha512-fQhoXdcvc3V28x7C7BMs4P5+kNlgUURe2jmUT1T//oBRMDrqy1QPelJimwZGo7Hg9VPV3EQV5Bnq4hbFy2vetA==}
+
json-stable-stringify-without-jsonify@1.0.1:
resolution: {integrity: sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==}
@@ -3175,9 +3324,17 @@ packages:
resolution: {integrity: sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==}
engines: {node: '>= 0.4'}
+ media-typer@1.1.0:
+ resolution: {integrity: sha512-aisnrDP4GNe06UcKFnV5bfMNPBUw4jsLGaWwWfnH3v02GnBuXX2MCVn5RbrWo0j3pczUilYblq7fQ7Nw2t5XKw==}
+ engines: {node: '>= 0.8'}
+
memoize-one@5.2.1:
resolution: {integrity: sha512-zYiwtZUcYyXKo/np96AGZAckk+FWWsUdJ3cHGGmld7+AhvcWmQyGCYUh1hc4Q/pkOhb65dQR/pqCyK0cOaHz4Q==}
+ merge-descriptors@2.0.0:
+ resolution: {integrity: sha512-Snk314V5ayFLhp3fkUREub6WtjBfPdCPY1Ln8/8munuLuiYhsABgBVWsozAG+MWMbVEvcdcpbi9R7ww22l9Q3g==}
+ engines: {node: '>=18'}
+
merge-stream@2.0.0:
resolution: {integrity: sha512-abv/qOcuPfk3URPfDzmZU1LKmuw8kT+0nIHvKrKgFrwifol/doWcdA4ZqsWQ8ENrFKkd67Mfpo/LovbIUsbt3w==}
@@ -3201,6 +3358,10 @@ packages:
resolution: {integrity: sha512-lc/aahn+t4/SWV/qcmumYjymLsWfN3ELhpmVuUFjgsORruuZPVSwAQryq+HHGvO/SI2KVX26bx+En+zhM8g8hQ==}
engines: {node: '>= 0.6'}
+ mime-types@3.0.2:
+ resolution: {integrity: sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A==}
+ engines: {node: '>=18'}
+
mimic-fn@2.1.0:
resolution: {integrity: sha512-OqbOk5oEQeAZ8WXWydlu9HJjz9WVdEIvamMCcXmuqUYjTknH/sqsWvhQ3vgwKFRR1HpjvNBKQ37nbJgYzGqGcg==}
engines: {node: '>=6'}
@@ -3245,6 +3406,10 @@ packages:
resolution: {integrity: sha512-myRT3DiWPHqho5PrJaIRyaMv2kgYf0mUVgBNOYMuCH5Ki1yEiQaf/ZJuQ62nvpc44wL5WDbTX7yGJi1Neevw8w==}
engines: {node: '>= 0.6'}
+ negotiator@1.0.0:
+ resolution: {integrity: sha512-8Ofs/AUQh8MaEcrlq5xOX0CQ9ypTF5dl78mjlMNfOK08fzpgTHQRQPBxcPlEtIw0yRpws+Zo/3r+5WRby7u3Gg==}
+ engines: {node: '>= 0.6'}
+
next@16.2.3:
resolution: {integrity: sha512-9V3zV4oZFza3PVev5/poB9g0dEafVcgNyQ8eTRop8GvxZjV2G15FC5ARuG1eFD42QgeYkzJBJzHghNP8Ad9xtA==}
engines: {node: '>=20.9.0'}
@@ -3320,10 +3485,17 @@ packages:
resolution: {integrity: sha512-gXah6aZrcUxjWg2zR2MwouP2eHlCBzdV4pygudehaKXSGW4v2AsRQUK+lwwXhii6KFZcunEnmSUoYp5CXibxtA==}
engines: {node: '>= 0.4'}
+ on-finished@2.4.1:
+ resolution: {integrity: sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==}
+ engines: {node: '>= 0.8'}
+
on-headers@1.1.0:
resolution: {integrity: sha512-737ZY3yNnXy37FHkQxPzt4UZ2UWPWiCZWLvFZ4fu5cueciegX0zGPnrlY6bwRg4FdQOe9YU8MkmJwGhoMybl8A==}
engines: {node: '>= 0.8'}
+ once@1.4.0:
+ resolution: {integrity: sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==}
+
onetime@5.1.2:
resolution: {integrity: sha512-kbpaSSGJTWdAY5KPVeMOKXSrPtr8C8C7wodJbcsd51jRnmD+GZu8Y0VoU6Dm5Z4vWr0Ig/1NKuWRKf7j5aaYSg==}
engines: {node: '>=6'}
@@ -3359,6 +3531,10 @@ packages:
resolution: {integrity: sha512-TXfryirbmq34y8QBwgqCVLi+8oA3oWx2eAnSn62ITyEhEYaWRlVZ2DvMM9eZbMs/RfxPu/PK/aBLyGj4IrqMHw==}
engines: {node: '>=18'}
+ parseurl@1.3.3:
+ resolution: {integrity: sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==}
+ engines: {node: '>= 0.8'}
+
path-exists@4.0.0:
resolution: {integrity: sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==}
engines: {node: '>=8'}
@@ -3380,6 +3556,9 @@ packages:
path-to-regexp@3.3.0:
resolution: {integrity: sha512-qyCH421YQPS2WFDxDjftfc1ZR5WKQzVzqsp4n9M2kQhVOo/ByahFoUNJfl58kOcEGfQ//7weFTDhm+ss8Ecxgw==}
+ path-to-regexp@8.4.2:
+ resolution: {integrity: sha512-qRcuIdP69NPm4qbACK+aDogI5CBDMi1jKe0ry5rSQJz8JVLsC7jV8XpiJjGRLLol3N+R5ihGYcrPLTno6pAdBA==}
+
picocolors@1.1.1:
resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==}
@@ -3391,6 +3570,10 @@ packages:
resolution: {integrity: sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==}
engines: {node: '>=12'}
+ pkce-challenge@5.0.1:
+ resolution: {integrity: sha512-wQ0b/W4Fr01qtpHlqSqspcj3EhBvimsdh0KlHhH8HRZnMsEa0ea2fTULOXOS9ccQr3om+GcGRk4e+isrZWV8qQ==}
+ engines: {node: '>=16.20.0'}
+
playwright-core@1.59.1:
resolution: {integrity: sha512-HBV/RJg81z5BiiZ9yPzIiClYV/QMsDCKUyogwH9p3MCP6IYjUFu/MActgYAvK0oWyV9NlwM3GLBjADyWgydVyg==}
engines: {node: '>=18'}
@@ -3424,10 +3607,18 @@ packages:
prop-types@15.8.1:
resolution: {integrity: sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg==}
+ proxy-addr@2.0.7:
+ resolution: {integrity: sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==}
+ engines: {node: '>= 0.10'}
+
punycode@2.3.1:
resolution: {integrity: sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==}
engines: {node: '>=6'}
+ qs@6.15.3:
+ resolution: {integrity: sha512-O9gl3zCl5h5blw1KGUzQKhA5oUXSl8rwUIM5o0S3nCXMliSvy5Dzx7/DJcI+SwgICv+IneSZwhBh1oSyEHA71A==}
+ engines: {node: '>=0.6'}
+
queue-microtask@1.2.3:
resolution: {integrity: sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==}
@@ -3448,6 +3639,14 @@ packages:
resolution: {integrity: sha512-kA5WQoNVo4t9lNx2kQNFCxKeBl5IbbSNBl1M/tLkw9WCn+hxNBAW5Qh8gdhs63CJnhjJ2zQWFoqPJP2sK1AV5A==}
engines: {node: '>= 0.6'}
+ range-parser@1.3.0:
+ resolution: {integrity: sha512-hek2mFQpPuI4E1BBKrSto+BU3e3x4xuarsbiwr3+lf7p44juvFMV0XFWQAP3xUyqXA4RrXLIoaSUGbSt056ZMw==}
+ engines: {node: '>= 0.6'}
+
+ raw-body@3.0.2:
+ resolution: {integrity: sha512-K5zQjDllxWkf7Z5xJdV0/B0WTNqx6vxG70zJE4N0kBs4LovmEYWJzQGxC9bS9RAKu3bgM40lrd5zoLJ12MQ5BA==}
+ engines: {node: '>= 0.10'}
+
rc@1.2.8:
resolution: {integrity: sha512-y3bGgqKj3QBdxLbLkomlohkvsA8gdAiUQlSBJnBhfn+BPxg4bc62d8TcBW15wavDfgexCgccckhcZvywyQYPOw==}
hasBin: true
@@ -3567,6 +3766,10 @@ packages:
resolution: {integrity: sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw==}
engines: {iojs: '>=1.0.0', node: '>=0.10.0'}
+ router@2.2.0:
+ resolution: {integrity: sha512-nLTrUKm2UyiL7rlhapu/Zl45FwNgkZGaCpZbIHajDYgwlJCOzLSk+cIPAnsEqV955GjILJnKbdQC1nVPz+gAYQ==}
+ engines: {node: '>= 18'}
+
run-parallel@1.2.0:
resolution: {integrity: sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==}
@@ -3585,6 +3788,9 @@ packages:
resolution: {integrity: sha512-x/+Cz4YrimQxQccJf5mKEbIa1NzeCRNI5Ecl/ekmlYaampdNLPalVyIcCZNNH3MvmqBugV5TMYZXv0ljslUlaw==}
engines: {node: '>= 0.4'}
+ safer-buffer@2.1.2:
+ resolution: {integrity: sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==}
+
scheduler@0.27.0:
resolution: {integrity: sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==}
@@ -3597,9 +3803,17 @@ packages:
engines: {node: '>=10'}
hasBin: true
+ send@1.2.1:
+ resolution: {integrity: sha512-1gnZf7DFcoIcajTjTwjwuDjzuz4PPcY2StKPlsGAQ1+YH20IRVrBaXSWmdjowTJ6u8Rc01PoYOGHXfP1mYcZNQ==}
+ engines: {node: '>= 18'}
+
serve-handler@6.1.7:
resolution: {integrity: sha512-CinAq1xWb0vR3twAv9evEU8cNWkXCb9kd5ePAHUKJBkOsUpR1wt/CvGdeca7vqumL1U5cSaeVQ6zZMxiJ3yWsg==}
+ serve-static@2.2.1:
+ resolution: {integrity: sha512-xRXBn0pPqQTVQiC8wyQrKs2MOlX24zQ0POGaj0kultvoOCstBQM5yvOhAVSUwOMjQtTvsPWoNCHfPGwaaQJhTw==}
+ engines: {node: '>= 18'}
+
serve@14.2.6:
resolution: {integrity: sha512-QEjUSA+sD4Rotm1znR8s50YqA3kYpRGPmtd5GlFxbaL9n/FdUNbqMhxClqdditSk0LlZyA/dhud6XNRTOC9x2Q==}
engines: {node: '>= 14'}
@@ -3617,6 +3831,9 @@ packages:
resolution: {integrity: sha512-RJRdvCo6IAnPdsvP/7m6bsQqNnn1FCBX5ZNtFL98MmFF/4xAIJTIg1YbHW5DC2W5SKZanrC6i4HsJqlajw/dZw==}
engines: {node: '>= 0.4'}
+ setprototypeof@1.2.0:
+ resolution: {integrity: sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==}
+
sharp@0.34.5:
resolution: {integrity: sha512-Ou9I5Ft9WNcCbXrU9cMgPBcCK8LiwLqcbywW3t4oDV37n1pzpuNLsYiAV8eODnjbtQlSDwZ2cUEeQz4E54Hltg==}
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
@@ -3645,6 +3862,10 @@ packages:
resolution: {integrity: sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw==}
engines: {node: '>= 0.4'}
+ side-channel@1.1.1:
+ resolution: {integrity: sha512-6x6dK6zJdpTzF4sQeNYxwtvBzf6Eg4GtlesS94HOvTudUeyK2WXAaIfmDgsyslYrRBeFIlsi54AYsFGUuhmvrQ==}
+ engines: {node: '>= 0.4'}
+
signal-exit@3.0.7:
resolution: {integrity: sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==}
@@ -3665,6 +3886,10 @@ packages:
stable-hash@0.0.5:
resolution: {integrity: sha512-+L3ccpzibovGXFK+Ap/f8LOS0ahMrHTf3xu7mMLSpEGU0EO9ucaysSylKo9eRDFNhWve/y275iPmIZ4z39a9iA==}
+ statuses@2.0.2:
+ resolution: {integrity: sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==}
+ engines: {node: '>= 0.8'}
+
stop-iteration-iterator@1.1.0:
resolution: {integrity: sha512-eLoXW/DHyl62zxY4SCaIgnRhuMr6ri4juEYARS8E6sCEqzKpOiE521Ucofdx+KnDZl5xmvGYaaKCk5FEOxJCoQ==}
engines: {node: '>= 0.4'}
@@ -3776,6 +4001,10 @@ packages:
resolution: {integrity: sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==}
engines: {node: '>=8.0'}
+ toidentifier@1.0.1:
+ resolution: {integrity: sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==}
+ engines: {node: '>=0.6'}
+
ts-api-utils@2.5.0:
resolution: {integrity: sha512-OJ/ibxhPlqrMM0UiNHJ/0CKQkoKF243/AEmplt3qpRgkW8VG7IfOS41h7V8TjITqdByHzrjcS/2si+y4lIh8NA==}
engines: {node: '>=18.12'}
@@ -3804,6 +4033,10 @@ packages:
resolution: {integrity: sha512-RAH822pAdBgcNMAfWnCBU3CFZcfZ/i1eZjwFU/dsLKumyuuP3niueg2UAukXYF0E2AAoc82ZSSf9J0WQBinzHA==}
engines: {node: '>=12.20'}
+ type-is@2.1.0:
+ resolution: {integrity: sha512-faYHw0anBbc/kWF3zFTEnxSFOAGUX9GFbOBthvDdLsIlEoWOFOtS0zgCiQYwIskL9iGXZL3kAXD8OoZ4GmMATA==}
+ engines: {node: '>= 18'}
+
typed-array-buffer@1.0.3:
resolution: {integrity: sha512-nAYYwfY3qnzX30IkA6AQZjVbtK6duGontcQm1WSG1MD94YLqK0515GNApXkoxKOWMusVssAHWLh9SeaoefYFGw==}
engines: {node: '>= 0.4'}
@@ -3847,6 +4080,10 @@ packages:
resolution: {integrity: sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw==}
engines: {node: '>= 10.0.0'}
+ unpipe@1.0.0:
+ resolution: {integrity: sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==}
+ engines: {node: '>= 0.8'}
+
unrs-resolver@1.11.1:
resolution: {integrity: sha512-bSjt9pjaEBnNiGgc9rUiHGKv5l4/TGzDmYw3RhnkJGtLhbnnA/5qJj7x3dNDCRx/PJxu774LlH8lCOlB4hEfKg==}
@@ -3928,6 +4165,9 @@ packages:
resolution: {integrity: sha512-si7QWI6zUMq56bESFvagtmzMdGOtoxfR+Sez11Mobfc7tm+VkUckk9bW2UeffTGVUbOksxmSw0AA2gs8g71NCQ==}
engines: {node: '>=12'}
+ wrappy@1.0.2:
+ resolution: {integrity: sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==}
+
yallist@3.1.1:
resolution: {integrity: sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==}
@@ -3943,6 +4183,11 @@ packages:
resolution: {integrity: sha512-CzhO+pFNo8ajLM2d2IW/R93ipy99LWjtwblvC1RsoSUMZgyLbYFr221TnSNT7GjGdYui6P459mw9JH/g/zW2ug==}
engines: {node: '>=18'}
+ zod-to-json-schema@3.25.2:
+ resolution: {integrity: sha512-O/PgfnpT1xKSDeQYSCfRI5Gy3hPf91mKVDuYLUHZJMiDFptvP41MSnWofm8dnCm0256ZNfZIM7DSzuSMAFnjHA==}
+ peerDependencies:
+ zod: ^3.25.28 || ^4
+
zod-validation-error@4.0.2:
resolution: {integrity: sha512-Q6/nZLe6jxuU80qb/4uJ4t5v2VEZ44lzQjPDhYJNztRQ4wyWc6VF3D3Kb/fAuPetZQnhS3hnajCf9CsWesghLQ==}
engines: {node: '>=18.0.0'}
@@ -4392,6 +4637,10 @@ snapshots:
'@harperfast/extended-iterable@1.0.3': {}
+ '@hono/node-server@1.19.14(hono@4.12.27)':
+ dependencies:
+ hono: 4.12.27
+
'@humanfs/core@0.19.2':
dependencies:
'@humanfs/types': 0.15.0
@@ -4545,6 +4794,28 @@ snapshots:
'@lmdb/lmdb-win32-x64@3.5.4':
optional: true
+ '@modelcontextprotocol/sdk@1.29.0(zod@4.3.6)':
+ dependencies:
+ '@hono/node-server': 1.19.14(hono@4.12.27)
+ ajv: 8.18.0
+ ajv-formats: 3.0.1(ajv@8.18.0)
+ content-type: 1.0.5
+ cors: 2.8.6
+ cross-spawn: 7.0.6
+ eventsource: 3.0.7
+ eventsource-parser: 3.1.0
+ express: 5.2.1
+ express-rate-limit: 8.5.2(express@5.2.1)
+ hono: 4.12.27
+ jose: 6.2.3
+ json-schema-typed: 8.0.2
+ pkce-challenge: 5.0.1
+ raw-body: 3.0.2
+ zod: 4.3.6
+ zod-to-json-schema: 3.25.2(zod@4.3.6)
+ transitivePeerDependencies:
+ - supports-color
+
'@msgpackr-extract/msgpackr-extract-darwin-arm64@3.0.3':
optional: true
@@ -5704,12 +5975,21 @@ snapshots:
'@zeit/schemas@2.36.0': {}
+ accepts@2.0.0:
+ dependencies:
+ mime-types: 3.0.2
+ negotiator: 1.0.0
+
acorn-jsx@5.3.2(acorn@8.16.0):
dependencies:
acorn: 8.16.0
acorn@8.16.0: {}
+ ajv-formats@3.0.1(ajv@8.18.0):
+ optionalDependencies:
+ ajv: 8.18.0
+
ajv@6.15.0:
dependencies:
fast-deep-equal: 3.1.3
@@ -5837,6 +6117,20 @@ snapshots:
baseline-browser-mapping@2.10.23: {}
+ body-parser@2.3.0:
+ dependencies:
+ bytes: 3.1.2
+ content-type: 2.0.0
+ debug: 4.4.3
+ http-errors: 2.0.1
+ iconv-lite: 0.7.3
+ on-finished: 2.4.1
+ qs: 6.15.3
+ raw-body: 3.0.2
+ type-is: 2.1.0
+ transitivePeerDependencies:
+ - supports-color
+
bowser@2.14.1: {}
boxen@7.0.0:
@@ -5968,8 +6262,23 @@ snapshots:
content-disposition@0.5.2: {}
+ content-disposition@1.1.0: {}
+
+ content-type@1.0.5: {}
+
+ content-type@2.0.0: {}
+
convert-source-map@2.0.0: {}
+ cookie-signature@1.2.2: {}
+
+ cookie@0.7.2: {}
+
+ cors@2.8.6:
+ dependencies:
+ object-assign: 4.1.1
+ vary: 1.1.2
+
cross-spawn@7.0.6:
dependencies:
path-key: 3.1.1
@@ -6068,6 +6377,8 @@ snapshots:
has-property-descriptors: 1.0.2
object-keys: 1.1.1
+ depd@2.0.0: {}
+
detect-libc@2.1.2: {}
detect-node-es@1.1.0: {}
@@ -6089,12 +6400,16 @@ snapshots:
eastasianwidth@0.2.0: {}
+ ee-first@1.1.1: {}
+
electron-to-chromium@1.5.344: {}
emoji-regex@8.0.0: {}
emoji-regex@9.2.2: {}
+ encodeurl@2.0.0: {}
+
enhanced-resolve@5.21.0:
dependencies:
graceful-fs: 4.2.11
@@ -6232,6 +6547,8 @@ snapshots:
escalade@3.2.0: {}
+ escape-html@1.0.3: {}
+
escape-string-regexp@4.0.0: {}
escape-string-regexp@5.0.0: {}
@@ -6488,10 +6805,18 @@ snapshots:
esutils@2.0.3: {}
+ etag@1.8.1: {}
+
eventemitter3@4.0.7: {}
events@3.3.0: {}
+ eventsource-parser@3.1.0: {}
+
+ eventsource@3.0.7:
+ dependencies:
+ eventsource-parser: 3.1.0
+
execa@5.1.1:
dependencies:
cross-spawn: 7.0.6
@@ -6519,6 +6844,44 @@ snapshots:
strip-final-newline: 4.0.0
yoctocolors: 2.1.2
+ express-rate-limit@8.5.2(express@5.2.1):
+ dependencies:
+ express: 5.2.1
+ ip-address: 10.2.0
+
+ express@5.2.1:
+ dependencies:
+ accepts: 2.0.0
+ body-parser: 2.3.0
+ content-disposition: 1.1.0
+ content-type: 1.0.5
+ cookie: 0.7.2
+ cookie-signature: 1.2.2
+ debug: 4.4.3
+ depd: 2.0.0
+ encodeurl: 2.0.0
+ escape-html: 1.0.3
+ etag: 1.8.1
+ finalhandler: 2.1.1
+ fresh: 2.0.0
+ http-errors: 2.0.1
+ merge-descriptors: 2.0.0
+ mime-types: 3.0.2
+ on-finished: 2.4.1
+ once: 1.4.0
+ parseurl: 1.3.3
+ proxy-addr: 2.0.7
+ qs: 6.15.3
+ range-parser: 1.3.0
+ router: 2.2.0
+ send: 1.2.1
+ serve-static: 2.2.1
+ statuses: 2.0.2
+ type-is: 2.1.0
+ vary: 1.1.2
+ transitivePeerDependencies:
+ - supports-color
+
fast-deep-equal@3.1.3: {}
fast-equals@5.4.0: {}
@@ -6557,6 +6920,17 @@ snapshots:
dependencies:
to-regex-range: 5.0.1
+ finalhandler@2.1.1:
+ dependencies:
+ debug: 4.4.3
+ encodeurl: 2.0.0
+ escape-html: 1.0.3
+ on-finished: 2.4.1
+ parseurl: 1.3.3
+ statuses: 2.0.2
+ transitivePeerDependencies:
+ - supports-color
+
find-up@5.0.0:
dependencies:
locate-path: 6.0.0
@@ -6575,6 +6949,10 @@ snapshots:
dependencies:
is-callable: 1.2.7
+ forwarded@0.2.0: {}
+
+ fresh@2.0.0: {}
+
fs-extra@11.3.4:
dependencies:
graceful-fs: 4.2.11
@@ -6692,10 +7070,24 @@ snapshots:
hls.js@1.6.16: {}
+ hono@4.12.27: {}
+
+ http-errors@2.0.1:
+ dependencies:
+ depd: 2.0.0
+ inherits: 2.0.4
+ setprototypeof: 1.2.0
+ statuses: 2.0.2
+ toidentifier: 1.0.1
+
human-signals@2.1.0: {}
human-signals@8.0.1: {}
+ iconv-lite@0.7.3:
+ dependencies:
+ safer-buffer: 2.1.2
+
ieee754@1.2.1: {}
ignore@5.3.2: {}
@@ -6721,6 +7113,10 @@ snapshots:
internmap@2.0.3: {}
+ ip-address@10.2.0: {}
+
+ ipaddr.js@1.9.1: {}
+
is-array-buffer@3.0.5:
dependencies:
call-bind: 1.0.9
@@ -6802,6 +7198,8 @@ snapshots:
is-port-reachable@4.0.0: {}
+ is-promise@4.0.0: {}
+
is-regex@1.2.1:
dependencies:
call-bound: 1.0.4
@@ -6866,6 +7264,8 @@ snapshots:
jiti@2.6.1: {}
+ jose@6.2.3: {}
+
js-tokens@4.0.0: {}
js-yaml@4.1.1:
@@ -6880,6 +7280,8 @@ snapshots:
json-schema-traverse@1.0.0: {}
+ json-schema-typed@8.0.2: {}
+
json-stable-stringify-without-jsonify@1.0.1: {}
json5@1.0.2:
@@ -7014,8 +7416,12 @@ snapshots:
math-intrinsics@1.1.0: {}
+ media-typer@1.1.0: {}
+
memoize-one@5.2.1: {}
+ merge-descriptors@2.0.0: {}
+
merge-stream@2.0.0: {}
merge2@1.4.1: {}
@@ -7033,6 +7439,10 @@ snapshots:
dependencies:
mime-db: 1.33.0
+ mime-types@3.0.2:
+ dependencies:
+ mime-db: 1.54.0
+
mimic-fn@2.1.0: {}
minimatch@10.2.5:
@@ -7073,6 +7483,8 @@ snapshots:
negotiator@0.6.4: {}
+ negotiator@1.0.0: {}
+
next@16.2.3(@babel/core@7.29.0)(@playwright/test@1.59.1)(react-dom@19.2.4(react@19.2.4))(react@19.2.4):
dependencies:
'@next/env': 16.2.3
@@ -7164,8 +7576,16 @@ snapshots:
define-properties: 1.2.1
es-object-atoms: 1.1.1
+ on-finished@2.4.1:
+ dependencies:
+ ee-first: 1.1.1
+
on-headers@1.1.0: {}
+ once@1.4.0:
+ dependencies:
+ wrappy: 1.0.2
+
onetime@5.1.2:
dependencies:
mimic-fn: 2.1.0
@@ -7205,6 +7625,8 @@ snapshots:
parse-ms@4.0.0: {}
+ parseurl@1.3.3: {}
+
path-exists@4.0.0: {}
path-is-inside@1.0.2: {}
@@ -7217,12 +7639,16 @@ snapshots:
path-to-regexp@3.3.0: {}
+ path-to-regexp@8.4.2: {}
+
picocolors@1.1.1: {}
picomatch@2.3.2: {}
picomatch@4.0.4: {}
+ pkce-challenge@5.0.1: {}
+
playwright-core@1.59.1: {}
playwright@1.59.1:
@@ -7257,8 +7683,18 @@ snapshots:
object-assign: 4.1.1
react-is: 16.13.1
+ proxy-addr@2.0.7:
+ dependencies:
+ forwarded: 0.2.0
+ ipaddr.js: 1.9.1
+
punycode@2.3.1: {}
+ qs@6.15.3:
+ dependencies:
+ es-define-property: 1.0.1
+ side-channel: 1.1.1
+
queue-microtask@1.2.3: {}
radix-ui@1.6.0(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4):
@@ -7326,6 +7762,15 @@ snapshots:
range-parser@1.2.0: {}
+ range-parser@1.3.0: {}
+
+ raw-body@3.0.2:
+ dependencies:
+ bytes: 3.1.2
+ http-errors: 2.0.1
+ iconv-lite: 0.7.3
+ unpipe: 1.0.0
+
rc@1.2.8:
dependencies:
deep-extend: 0.6.0
@@ -7468,6 +7913,16 @@ snapshots:
reusify@1.1.0: {}
+ router@2.2.0:
+ dependencies:
+ debug: 4.4.3
+ depd: 2.0.0
+ is-promise: 4.0.0
+ parseurl: 1.3.3
+ path-to-regexp: 8.4.2
+ transitivePeerDependencies:
+ - supports-color
+
run-parallel@1.2.0:
dependencies:
queue-microtask: 1.2.3
@@ -7493,12 +7948,30 @@ snapshots:
es-errors: 1.3.0
is-regex: 1.2.1
+ safer-buffer@2.1.2: {}
+
scheduler@0.27.0: {}
semver@6.3.1: {}
semver@7.7.4: {}
+ send@1.2.1:
+ dependencies:
+ debug: 4.4.3
+ encodeurl: 2.0.0
+ escape-html: 1.0.3
+ etag: 1.8.1
+ fresh: 2.0.0
+ http-errors: 2.0.1
+ mime-types: 3.0.2
+ ms: 2.1.3
+ on-finished: 2.4.1
+ range-parser: 1.3.0
+ statuses: 2.0.2
+ transitivePeerDependencies:
+ - supports-color
+
serve-handler@6.1.7:
dependencies:
bytes: 3.0.0
@@ -7509,6 +7982,15 @@ snapshots:
path-to-regexp: 3.3.0
range-parser: 1.2.0
+ serve-static@2.2.1:
+ dependencies:
+ encodeurl: 2.0.0
+ escape-html: 1.0.3
+ parseurl: 1.3.3
+ send: 1.2.1
+ transitivePeerDependencies:
+ - supports-color
+
serve@14.2.6:
dependencies:
'@zeit/schemas': 2.36.0
@@ -7547,6 +8029,8 @@ snapshots:
es-errors: 1.3.0
es-object-atoms: 1.1.1
+ setprototypeof@1.2.0: {}
+
sharp@0.34.5:
dependencies:
'@img/colour': 1.1.0
@@ -7613,6 +8097,14 @@ snapshots:
side-channel-map: 1.0.1
side-channel-weakmap: 1.0.2
+ side-channel@1.1.1:
+ dependencies:
+ es-errors: 1.3.0
+ object-inspect: 1.13.4
+ side-channel-list: 1.0.1
+ side-channel-map: 1.0.1
+ side-channel-weakmap: 1.0.2
+
signal-exit@3.0.7: {}
signal-exit@4.1.0: {}
@@ -7626,6 +8118,8 @@ snapshots:
stable-hash@0.0.5: {}
+ statuses@2.0.2: {}
+
stop-iteration-iterator@1.1.0:
dependencies:
es-errors: 1.3.0
@@ -7750,6 +8244,8 @@ snapshots:
dependencies:
is-number: 7.0.0
+ toidentifier@1.0.1: {}
+
ts-api-utils@2.5.0(typescript@5.9.3):
dependencies:
typescript: 5.9.3
@@ -7778,6 +8274,12 @@ snapshots:
type-fest@2.19.0: {}
+ type-is@2.1.0:
+ dependencies:
+ content-type: 2.0.0
+ media-typer: 1.1.0
+ mime-types: 3.0.2
+
typed-array-buffer@1.0.3:
dependencies:
call-bound: 1.0.4
@@ -7837,6 +8339,8 @@ snapshots:
universalify@2.0.1: {}
+ unpipe@1.0.0: {}
+
unrs-resolver@1.11.1:
dependencies:
napi-postinstall: 0.3.4
@@ -7971,6 +8475,8 @@ snapshots:
string-width: 5.1.2
strip-ansi: 7.2.0
+ wrappy@1.0.2: {}
+
yallist@3.1.1: {}
yocto-queue@0.1.0: {}
@@ -7979,6 +8485,10 @@ snapshots:
yoctocolors@2.1.2: {}
+ zod-to-json-schema@3.25.2(zod@4.3.6):
+ dependencies:
+ zod: 4.3.6
+
zod-validation-error@4.0.2(zod@4.3.6):
dependencies:
zod: 4.3.6
diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml
@@ -3,6 +3,7 @@ packages:
- editor
- export
- homepage
+ - mcp
allowBuilds:
esbuild: true