Anilyzer

Changelog

[0.8.6] - 2026-08-11

  • MCP: the corpus's signature question is now askable, and it answers in under a second. runSearchSpec — the engine that supports availability-state, upload-date, media-type and age filters, plus the chat/description/tags scopes — had exactly one call site: the open_link handler. Every filter was therefore unreachable unless a human pasted a viewer share link, which meant "what did the videos that have since been DELETED say about X" — the question that motivated the last sweep — had no path at all. search_transcripts and enumerate_matches now take the filters as flat arguments from one shared schema constant: states (available / maybemissing / deleted / private / membersonly / unlisted), date_from/date_to, media_type, age, scopes (transcripts / chat / description / tags / metadata / posts) and exclude for video-level NOT — "cup" but not "world cup", the one boolean case that actually bites. Both tools gain them together, deliberately from the same constant, because a filter reachable from one and not the other would make the two disagree about coverage, which is precisely the failure the stateless rebuild set out to make impossible. Arbitrary boolean trees stay open_link's job — that is what a share link is for, and asking a model to author a qt= tree in a tool call would trade a real capability for a new class of malformed input. Unrecognised tokens are named in the footer rather than dropped: a typo'd state would otherwise widen the search back to the whole corpus and return a perfectly legitimate-looking answer. Non-timed layers (description / tags / channel name) emit [description]-tagged snippets with no timestamp link, since citing a description line as @ 0:00 would assert that someone said it.
  • MCP: a filtered query now reads a fraction of the corpus instead of all of it. summaries/ is a global index of every video — slug, channel, title, upload date, livestream, age-restricted, presence state — that parses in well under a second, against ~1.3 GB and tens of seconds for the transcripts. The MCP already read it in availabilityMap() and threw away everything except the presence state. It now keeps the whole record, and because each channel manifest carries slugToPage, a filtered query's exact page set is computable before a single transcript byte is read. Measured on the live 170-page corpus, on an idle box, in one run: the removed-videos question drops from a 170-page / 1,288 MB full scan to 8 pages / 62.6 MB — 0.82 s against the 16.4 s the same term costs unfiltered; a one-year date range to 32 pages / 202 MB (2.6 s). The invariant that makes it safe is that the index only ever prunes pages; the record predicate still decides every hit — both call the same passesFilters, so they cannot drift, and a video the index has never heard of gets its page read unconditionally. A stale, partial or missing summaries set therefore costs time, never correctness. It pays in proportion to how selective the filter is (a broad attribute spread across the corpus gets almost nothing) and says which path ran"filter-pruned: planned 8 of 170 page(s)" — so a slow query is explicable. An unfiltered query never reads the index at all: a filter that excludes nothing is treated as no filter, so planning can never make a whole-corpus scan slower.
  • MCP: nothing is read twice any more. transcriptsManifest and transcriptPage had zero caching in all three transports, and every page read was a sequential await. So a 20-id get_transcripts batch re-read the same 7.4 MB page once per id, and without a channel hint did ~300 manifest reads; a sweep paid that per batch. Manifests (~551 KB for the whole corpus) are now cached outright; pages go in a byte-budgeted LRU — default 48 MB of raw page bytes, TRANSCRIPT_MCP_PAGE_CACHE_MB to change it. Denominated in bytes rather than entries because page sizes differ by an order of magnitude across corpora, and 48 MB is chosen from measurement rather than taste: a parsed page retains about 2.7× its file bytes, so the ceiling is ~130 MB resident — which matters on a box that also runs a GPU digest sweep. Caches hold promises, so concurrent callers for the same page coalesce onto one read. Pages are also read concurrently now, in windows clamped to the remaining max_pages budget and folded back in page order — so hit ordering is unchanged and max_pages:1 still reads exactly one page. A 20-id batch: 550 ms cold, 24 ms warm, 4 page reads. list_channels({refresh:true}) drops every new cache too — the explicit escape hatch for a corpus rebuilt under a long-lived server, still deliberately not a TTL.
  • MCP: counts are of recordings, not uploads — so some totals will now be lower. The site has shipped duplicates.json (the cross-platform mirror detector's output) all along and the MCP never opened it, so a sweep counted the same recording twice whenever it was mirrored to another platform or re-uploaded on another channel. search_transcripts and enumerate_matches now collapse cluster members to one row and report it: "12 mirror(s) collapsed across 9 cluster(s) — the 43 above are distinct RECORDINGS, not uploads". Nothing is hidden — the collapsed copies are named on the row they fold into, and collapse_duplicates:false gives one row per upload. And the surviving copy is never dropped: the kept row is the cluster's canonical member only when that member is itself among the matches, otherwise simply the first match, because a mirror is frequently the only surviving copy of a deleted upload and preferring an absent canonical would delete exactly the evidence a states:["deleted"] question is asking for. A report generated before and after this will disagree on totals. That is a fix, not a regression — the earlier number was double-counting mirrors.
  • MCP: "coverage partial" now tells you which channels went unread. HARD_VIDEO_CAP truncates in channel iteration order, never at random, so a capped result was a channel-biased sample whose total read like a real count (measured: a common term returned exactly 2000). The partial banner now names the channels that were fully scanned, the one it stopped inside and at which page, and the ones it never reached — turning "partial" from alarming into actionable, since you can re-run scoped to the remainder.
  • MCP: get_video_metadata returns everything the archive knows about a video. It returned the transcript record minus cues; three shipped layers it never opened are now joined in. From stats/: view/like/comment counts, cue count, platform state, and transcript coverage — surfaced not as a float but as a warning when it is low ("⚠ TRANSCRIPT COVERS ONLY 41% OF THE RUNTIME … do NOT conclude from this transcript that something was never said"), because a truncated download is a correctness trap disguised as metadata. From duplicates.json: the other archived copies of the same recording, each stating whether the two were measured as aligned — and when they were not, including when alignment was simply never measured, saying so and refusing to map a timestamp across, since a mirror with a different intro carries the same words at shifted times and a translated citation would look perfectly plausible while pointing at the wrong moment of a different upload. From digests/: AI chapters and topic tags where they exist (120 of ~31,000 videos — sparse enough that a subsystem would be over-building), with a borrowed digest flagged loudly as describing the other upload. Every one of these layers is optional at the interface level and degrades to a stated absence, because they genuinely are optional in the published contract: compose-site.ts only writes duplicates.json when there is a publishable cluster, digests exist for a handful of channels, and corpus.json doesn't even declare duplicates.json or stats/. A site that ships none of them behaves exactly as before.
  • MCP: a local corpus now cites the archive it was built for, instead of sending you to YouTube. LocalSource.publicOrigin() returned null unconditionally, so the server registered here — which runs --local …/export/public — fell back to platform watch pages for every citation. A composed public dir is not an anonymous pile of JSON: it names its own deployed origin in corpus.json (site.url), which is now read the first time the channel list is loaded. Citations land in the archive, at the cited second, with the transcript around it and the neighbouring videos one click away — rather than on the platform page, where the archive's whole point (that a copy still exists here) is invisible. Verified live: https://hasanalyzer.pages.dev/?v=FearAnd%2FV037tgaMBBI&t=1553. TRANSCRIPT_PLATFORM_LINKS=1 restores the old behaviour, which is the right choice when a local build's declared site URL is not actually deployed; a dir with no corpus.json still falls back to platform links.
  • MCP: a benchmark, so the next speed claim is a number. New mcp/bench/ drives the real server over stdio through the same command line the client is registered with, and times a fixed query set. It reports two kinds of number and treats them differently, because this box is shared and a build was running during the session this work started (load average 27): pages read and bytes parsed are structural — properties of the query plan, identical on an idle box and a hammered one — while wall time is contingent, so the bench checks the load average first and refuses to run above --max-load (default 0.7/core) unless forced, in which case every wall figure is stamped UNRELIABLE in both the table and the JSON. A number taken under load cannot later be quoted as if it weren't. Every run prints a corpus fingerprint first, because the composed dir gets rebuilt — one rebuild landed mid-session and swapped a 30,923-video composition for a 3,330-video one — and a before/after that silently spans two corpora is worse than no measurement at all. A companion pnpm smoke runs the same server against the real corpus and asserts the invariants that only 1.3 GB of real shards can break, including that enumerate_matches and search_transcripts report the same deduped total. See mcp/bench/{bench,smoke}.ts, mcp/src/{source,search,server,instructions}.ts, mcp/src/scanPlan.test.ts.

[0.8.5] - 2026-08-11

  • MCP: the server no longer remembers which corpus you're reading — because remembering it was silently getting it wrong. use_source switched a mutable "active corpus" and persisted the choice to a state file so it survived reconnects. That was the bug. The server registered here runs --local …/export/public, but its state file held {"activeSpec":{"kind":"remote","url":"https://hasanalyzer.pages.dev"}} from some earlier session — so every call since had been reading a different archive, and nothing in any result said so. There is now no active source and nothing is persisted: every read tool takes its own source handle, and a call that omits it reads the server's startup corpus. The handle is the serialised spec in canonical form — default, local:/dir, remote:https://site, hub:https://hub, or hub:https://hub#alpha,beta for a subset — not an opaque token, so it survives a restart and a human reading one in a transcript knows exactly what was searched. Shorthands (a bare site or hub URL, probed to tell one from the other; a hub member's siteId or title) normalise to canonical and are echoed back. Every result now ends with (corpus: <handle>) — errors included, implemented once in the dispatch wrapper so a new tool cannot forget it; it is corpus: and not source: because - source: already means "this video's URL" in the output. use_source survives one release as an unadvertised alias that resolves a target and tells you the handle to pass; reset_source is gone. Leftover state files are inert and can be deleted. Caching moved with it: a source instance is built once per handle and listChannels is memoised per instance, which also fixes a pre-existing cost — a 20-id get_transcripts batch against a remote used to fetch corpus.json twenty times. See mcp/src/sourceRegistry.ts (new; sourceController.ts deleted), mcp/src/{server,source,index}.ts, mcp/src/sourceRegistry.test.ts.
  • MCP: claiming you covered the corpus is now hard to do by accident. A sweep returned total 319; showing 1–200; has_more: yes, was never paged, and reported 319 videos swept having seen 200. The paging instruction was already in the sweep prompt and was ignored — so prose is not the enforcement mechanism. Worse, paging was also the expensive option: the engine materialises the entire match set and only then slices, so each page re-scanned the whole corpus. New enumerate_matches returns a query's complete id/title/channel/date worklist plus the batch count in one scan — a new tool rather than a flag, because a flag that silently changes the output shape is exactly what gets ignored. If a cap is hit, the first line reads ⚠ COVERAGE PARTIAL … this is a SAMPLE, not the full set, never a quiet footnote. search_transcripts now prints ⚠ INCOMPLETE PAGE — N total, showing a–b. Do NOT report a count from this page. above the hits (the old footer stays, so existing consumers keep working). Verified on the real 30-channel corpus: enumerate and search agree exactly at 57, 86 and at the 2000-video cap, where both correctly flag partial coverage.
  • MCP: /sweep and /ask stop shredding what you type. Claude Code parses an MCP prompt's arguments as whitespace-splitting zipped against the declared argument names — not quote-aware, the last argument does not absorb the remainder, and tokens past the declared count are dropped silently. With nine declared arguments, a real request became link="This", channel="search", group="deleted", directive="videos.", batch_size="Why" — rendered literally into ceil(N / Why) — and the entire actual question vanished without a warning. The entry points are now tools (sweep_plan, ask_plan) taking one free-text request, called by two thin .claude/commands/ shims that pass $ARGUMENTS, the whole raw string, untokenised. A URL with ?a=b&c=d and a full sentence of punctuation now arrive intact. Settings are still available as key=value, but against a closed whitelist: an unrecognised x=y stays in the question and warns (with a typo hint if it's one edit from a real key) instead of being eaten, and every value is validated — batch_size an integer 1–20, parse_model a single token, report a single .md path with no .. — so a bad one becomes a default plus a line, never arithmetic. The MCP sweep prompt still serves form-based clients (Claude Desktop, Cursor) through the same parser — but since it also still appears in Claude Code's slash list, where it is unusable, it now refuses a word-split request instead of sweeping the wrong thing: it names the arguments that cannot be what they claim to be, replays the words that survived in the order they were typed, and hands back the /sweep line to use instead. The plans also pre-resolve what they can — the corpus handle, your channel/group tokens validated against the live corpus, the group roster when you gave no scope — turning a three-call preamble into none, and an unresolvable channel now halts the plan rather than quietly widening it. See mcp/src/{promptRequest,instructions}.ts (new) and their tests.
  • MCP: three parameters that were declared and silently ignored now work. open_link's overrides.query_scope:"posts" was in the schema, dropped by the parser and excluded by the type, so asking to re-target a link at the social-post corpus quietly searched transcripts instead. get_transcripts.content_types was declared and never read, so a batch of post ids came back "not found" — ids now fall through to the post corpus. And a posts search over channels with no posts index reported total 0; scanned 0 page(s) across 0 channel(s), indistinguishable from "searched everything, found nothing"; it now says "no channel in scope ships a posts index — the post corpus is EMPTY here, not merely unmatched", with the posts pass counted separately from the video pass.
  • MCP: open_link does the whole job in one call, and get_transcripts can carry several queries. open_link was preview-then-apply:true, where apply switched the global active source — one extra round trip and the mutation this release exists to remove. It now decodes, resolves the origin to a handle, searches, and returns plan + results + handle together; dry_run:true gets the plan alone. get_transcripts gains queries (up to 8): the windows merge in one pass per video and each header reports a per-query count, so a term that matched nothing in that video is visible rather than absorbed — which kills the re-read-per-quote pattern.
  • MCP: on the 2026-07-28 protocol. The server now speaks MCP revision 2026-07-28 via @modelcontextprotocol/server@2's serveStdio, which owns the era decision — modern (negotiated by server/discover) or legacy (the 2025 initialize handshake) — and serves both from one definition. Confirmed negotiating modern in practice, not just in principle. Cache hints are a construction-time policy (tools/list, prompts/list and server/discover are literal constants with no corpus data, so public for an hour; every read result stays uncached) and an invalid one throws at startup rather than on the wire. Because InMemoryTransport only ever exercises the 2025 era, a new protocol.test.ts spawns the real process over stdio and asserts both eras serve an identical, order-pinned tool list. See mcp/src/protocol.test.ts.

[0.8.4] - 2026-08-04

  • A video that has gone missing now says so, and says how confidently. Availability used to be three unlabelled buckets — available, unlisted, deleted — with no marking on the result cards themselves: a deleted video looked exactly like a live one unless you already suspected something and went hunting in the filters. It is now a state on every card: DELETED, PRIVATE, MEMBERS, UNLISTED, or MISSING?. The last one is new and it is the point of the change. Checking a channel's listing is cheap (one request per channel); confirming why an individual video vanished is slow, so on a large channel there is a long window where the archive knows a video has dropped out of its channel but not yet what happened to it. The site used to spend that window insisting the video was fine. It now shows MISSING? — amber, with the question mark, because it is a suspicion and not a finding — and swaps in the confirmed reason once the per-video check catches up. A video re-checked after the scan that turns out to be present simply loses the flag.
  • The availability filter follows the same shape. "Available" and "Missing" are now a parent and its five leaves (unconfirmed, deleted, private, members-only, unlisted); ticking the parent takes all five, and it shows a dash when you have only some. Unlisted sits under "missing" because the rule is one sentence — it left the channel's listing — even though an unlisted video is still watchable by direct link. Saved filter profiles and old share links keep working: an existing link that asked for available/unlisted/deleted still means "all of them" under the new taxonomy rather than silently narrowing.
  • Two filter bugs fixed on the way past. Excluding social posts, and excluding unlisted videos, were both written to your saved filters but never read back — so either choice quietly reverted on reload. Both now persist.
  • The status chart stops lagging behind reality. Chart data was keyed on a video's metadata timestamp alone, so deleting a video today did not change the chart until something unrelated happened to touch that video's metadata. Availability is now read from the index directly, which also means the chart understands private and members-only rather than folding them into "available".

[0.8.3] - 2026-08-04

  • The archive now recognises far more cross-platform re-uploads as the same video. Duplicate detection compared two transcripts and called them the same only above a similarity of 0.6 — a threshold tuned for two transcripts of the same text, which quietly failed the case detection exists for. The two sides of a YouTube↔Rumble mirror are transcribed by different speech-recognition engines, and word-level disagreement between them lands a five-word-window comparison at roughly 0.35–0.60, i.e. just under the old cutoff. The threshold is now 0.35, which takes the archive from 2,846 duplicate clusters over 5,746 videos to 7,434 clusters over 14,997 videos — so a search result is far more likely to tell you the same recording exists elsewhere, and to offer you the jump. The change was bracketed at four values on the full archive before being made, and every step is a strict superset: no video that was previously flagged as a duplicate stopped being one. What it newly admits was inspected rather than counted — 96–97% have byte-identical titles, 99% span two platforms, and the handful of same-channel cases were read individually. Nothing about how a duplicate is decided changed: pairs are still confirmed by comparing real transcripts, a pair that merely shares a title and a runtime is still an internal review item that never reaches you, and the "jump to this moment in the other copy" button still only carries your timestamp when the two were measured as aligned.
  • AI chapters: jump straight to the part of a video you want. Videos that have been through the local-AI digest pass now ship their derived chapters and topic tags to the site, and the player gains a third panel beside Transcript and Live chat. Open it and you get a titled list of moments — click one and the player seeks there; the chapter you're currently inside stays marked as the video plays. The layer is sparse on purpose and honest about it: only a small, growing fraction of the archive has been digested (generation is a multi-week GPU pass), so the control simply isn't shown on a video that has no digest, rather than offering a button that opens an empty panel — and if a digest can't be loaded, the panel snaps back to the transcript with a notice instead of stranding you. What ships is the composed digest: any human correction is applied and any chapter a human rejected is dropped, so you see what a person approved rather than raw model output, with hand-edited chapters marked edited and a provenance line naming the model that wrote the rest. A digest borrowed from a duplicate upload says so, prominently — when the same recording exists twice in the archive, one copy's chapters can be shared onto the other, and the panel names the source video and the measured timing offset rather than passing them off as native (plausible chapters describing a different upload is the failure that looks like success). Digests live at /digests/<channel>/ under the same paginated-shard scheme as transcripts and posts, are offline-cached by the service worker, and are described in corpus.json — which bumps to spec 3 with a digestScheme and per-channel digests manifest pointers, so an AI tool reading the corpus can navigate them and knows that an absent video means "not yet generated" rather than "nothing to say". Deep links carry the panel (?vm=digest), and share links reopen on it. Operator telemetry (why the model's proposals were rejected, the regeneration history) is deliberately not shipped — that stays in the editor. See common/lib/digests.ts, common/components/{digestCache,digestStore}.ts, common/components/{PlayerProvider,TranscriptModal,urlState}.tsx/ts, and export/e2e/modal-digest.spec.ts.
  • Search results tell you when a video exists elsewhere in the archive, and take you there. A result that belongs to a duplicate cluster now carries a Dupe badge, and a strip under the card header offers one button per other copy — the same recording mirrored to another platform, or re-uploaded on another channel. Clicking one opens that copy in the player. The jump is honest about what it knows: matching content does not imply matching timings (a mirror with a longer intro carries the same words at shifted times), so a button only carries your current timestamp when detection measured the two as aligned; otherwise it says so and opens the other copy from the start. A copy whose alignment was never measured is treated as not aligned. Only clusters whose transcripts were actually compared reach the site — a pair that merely shares a title and a runtime stays an internal review item and is never asserted to you. In hub mode the badge is limited to same-origin results, since the duplicate index is per-site. Sites with no duplicate report are entirely unaffected.
  • One search now covers video transcripts and social posts. Archived X/Twitter and Bluesky posts ship as a parallel corpus beside transcripts and live chat, and compose into the same boolean query tree — so (transcripts:"foo" OR posts:"foo") returns both kinds in one ranked, newest-first result set. LayerScope gains "posts" (whitelisted in qt= deserialization, so a shared link round-trips a posts leaf), the leaf scope selector gains Posts, and the filter row gains a Posts media kind beside Videos and Livestreams — a third kind, because a post is neither, and folding it into the video toggle would silently drop the whole corpus. Post and video slugs live in disjoint namespaces, partitioned per-leaf by the eval engine so a transcripts leaf never fetches a post and an AND across the two can't collapse to nothing. Post result cards drop what doesn't apply (no seek gutter, no livestream/age badges, no VOD expiry) and lead with the post body; opening one shows a new PostModal — a sibling of the transcript reader, not a generalization of it — with the post, its archived thread, its outbound links and its engagement counts. Date filters work unchanged: every post carries a derived uploadDate. Posts are cached and served under /posts/, offline-cached by the service worker, and CORS-readable so a federating hub merges them across origins.
  • "Ask AI" is grounded in posts as well as transcripts. Retrieval adds a posts leaf per keyword alongside the transcript and metadata leaves, sharing the same term key so ranking still counts a keyword once rather than three times. Post excerpts render without a [clock] line and are cited as a bare [n] (never [n @ mm:ss] — a post has no timeline), the source list shows a date instead of a meaningless 0:00 seek button, and "load more context" on a post returns its thread rather than a time window.
  • MCP: posts are first-class. search_transcripts gains content_types (defaulting to both, so existing agent flows pick posts up automatically) and renders post hits without moment links; new get_post and get_thread tools read one post or a whole conversation; open_link accepts a posts query scope; and the sweep prompt teaches the post citation form. All of it lands on the single ShardSource boundary, so local, remote and hub transports gain it at once. corpus.json bumps to spec 2 with a postScheme describing the new shards.

[0.8.2] - 2026-07-24

  • Admin: the editor dashboard is now a live mission-control cockpit, with URL-first channel onboarding and restart-surviving pause. An operations-side change (it doesn't affect the published site): the editor's home page becomes a live, information-first operations surface — a full-width Pipeline band (running jobs, worker occupancy, sync heartbeat, global pause/sync controls) over a Needs-work / Quick-add split and an enriched channels table, all polling the same endpoints the monitor widget uses. Adding a channel is now URL-first (offline platform/handling/slug autofill + an opt-in yt-dlp "Fetch details" probe that can register an unknown platform's own queue, always store the playlist, and optionally jump the auto-queue), and pause is first-class for both transcriptions and downloads, persisted in settings.json. See the editor changelog for detail.
  • "Ask AI" — the Report panel's citations are now clickable, precise to the exact moment cited. The chat's answer bubbles already linked their [1], [2]… markers to the cited transcript; the Report panel — the persistent document Report mode and whole-corpus sweeps maintain — rendered as plain text, so the citations a user works from most were dead. Now the report mirrors the chat: it renders each citation as an in-page link and lists its numbered sources beneath the document, and clicking one opens the transcript modal seeked to the cited line. Citations are also upgraded — in the report and the chat — to a precise-moment [n @ mm:ss] form (the source's number plus the specific line's timestamp, e.g. [3 @ 12:34]), so a citation jumps to the exact moment instead of the video's first matched snippet. A slightly-off model timestamp is snapped to the nearest real transcript line, and it stays graceful: a bare [n] still resolves to the first snippet, and an out-of-range number or unparseable time stays plain text rather than becoming a broken link. Making this work in the report (a single accumulated string, unlike a chat message that carries its own sources) required two new pieces: a persisted report source registry — every video the report cites, deduped and stored light (no excerpt text) — and global citation numbering so a report's [n] is stable across every batch/turn folded in, not renumbered per write. The registry is saved with the conversation and each saved chat, so a report's clickable citations survive a reload; New chat clears it; and on the federated hub /ask (no player) citations fall back to scroll-to-source, exactly like the chat. See export/app/ask/citations.tsx (new shared renderer — linkifyCitations + CitationLink + CitationSources, with [n @ mm:ss] parsing and nearest-snippet snapping), export/app/ask/{MessageBubble,ReportPanel,AskChat,useAskChat,askChatStorage}.tsx/ts, export/app/lib/{askRetrieval,askConversation,searchAgent}.ts (optional/global-registry numbering + mergeReportSources + the [n @ mm:ss] prompt updates), and export/e2e/ask-chat.spec.ts.
  • Loose channel chips for flagged groups, plus uniform chip layout. A channel group can now be marked inline (site.json → manifest), which renders each of its member channels as its own loose chip in the filter row — a checkbox + name styled like a collapsed group chip, with no expand affordance — flowing in sort order among the normal group chips; inline groups are also skipped by the multi-group default-collapse heuristic, so their channels are always visible. The out-of-the-box "All channels" bucket now behaves this way: a site with no configured groups switches from one open full-width card to loose per-channel chips (behavior change; add an explicit group to keep the old card). The chips themselves also get a layout fix: instead of shrink-to-fit chips that could line-break their own text, all collapsed chips share a uniform min width (sm:min-w-48), never wrap internally — long names ellipsize with the full name as a tooltip — counts right-align inside the chip, and below the sm breakpoint chips stack one per line at full width while larger screens pack them into rows. See common/lib/channelGroups.ts (ChannelGroup.inline), common/components/{WorkspaceSearchBar,SearchSessionContext}.tsx, and export/e2e/inline-channel-chips.spec.ts.

[0.8.1] - 2026-07-22

  • Channel-group filters are now compact chips. On a site with many small channel groups the filter panel used to stack every group as its own full-width card, so ten groups meant ten rows before you saw a single channel. Collapsed groups now shrink to labeled chips — a chevron, a tri-state checkbox (checked / mixed / unchecked), the group's accent dot + name, and a selected/total count — that wrap several per line. The chip's checkbox toggles the whole group on or off in one click without expanding it (a mixed group's click selects all), and clicking the name expands the group into the familiar full-width card with per-channel checkboxes and All/None. Groups start collapsed on multi-group sites (your own expand/collapse choices still stick); a collapsed chip shows the group's description as a tooltip. Selection stays flat per-channel — profiles, share links, and saved filters are unchanged. See common/components/{WorkspaceSearchBar,SearchSessionContext}.tsx, common/components/ui/checkbox.tsx, and export/e2e/channel-group-chips.spec.ts.

[0.8.0] - 2026-07-20

  • MCP server: switch which corpus you're reading on the fly — and it sticks across reconnects. The MCP server was pinned to a single corpus at launch (--hub / --remote / --local or the TRANSCRIPT_* env), so re-aiming it at a different site or a local build meant editing the MCP config and restarting. Now, connected to (say) the archilyzer hub, you can retarget it from inside a session with three new read-only tools: list_sources shows the active corpus and, in a hub context, its member sites (siteId · title · url); use_source switches the active corpus — to a single hub member (site: — which becomes a plain single-site source and so regains full channel-group + alias support), a federated subset of the hub (sites:[…], unknown tokens reported not dropped), or an arbitrary remote: URL / local: dir / hub: URL; and reset_source returns to the startup source. The selection is persisted to a small state file (under $XDG_STATE_HOME/yt-dlp-transcript-mcp, overridable with TRANSCRIPT_MCP_STATE_DIR) keyed by the startup source, so it survives a reconnect and two differently-configured servers keep independent selections. Everything the sweep/search tools do reads the current active source. Still strictly read-only — this only changes which already-published static shards are read, the same capability the startup flags already grant this locally-run tool; nothing is written to any corpus. See mcp/src/{sources,sourceController,source,server,index}.ts, mcp/src/sourceController.test.ts, and mcp/README.md.
  • MCP server: run the corpus sweep through Claude Code itself — on plan usage, no API key. The in-browser "corpus sweep" (fold every matching transcript into a running report) needs a BYO AI key, and a free key conks out fast on a real 30k-video corpus. The mcp/ server now lets Claude Code be the sweep engine instead, via a first-class sweep prompt (a slash command, /mcp__<name>__sweep query="k cups" channel="chrissie-mayr") plus the tools to drive it. search_transcripts gains paging — it reports the full total and has_more, so the whole match set can be enumerated with offset (and include_snippets:false for a cheap worklist) — and is now alias-aware: a plain query that matches a curated search alias also searches the alias regex (e.g. k cups → also cake cup), with the footer naming which aliases fired; reaching the scan cap is surfaced as PARTIAL coverage rather than hidden. A new get_transcripts tool batch-reads up to 20 videos in one call as bounded, timestamped excerpt windows around the matches (alias-correct) — or full transcripts without a query — so a sweep stays token-bounded. The sweep prompt walks Claude through search → enumerate → plan ceil(N/batch) batches → per-batch windowed read + cross-referenced upsert of cited findings (title + [mm:ss]) into a markdown report it maintains with its own Write/Edit tools. The sweep is now group-aware and multi-channel: search_transcripts scope is additive over one-or-more channels (channel/channels) and/or channel groups (group/groups, matched by group id or display namegroup="other"group="Extended Universe" — and expanded to the group's channels), with the footer naming the resolved scope and flagging any channel/group token that matched nothing so a typo isn't silently a whole-corpus scan; get_transcripts takes matching channels lookup hints; and list_channels now organizes channels under their groups with a compact id · name · N channels cheat-sheet. Crucially the sweep prompt is now pick-first: invoked with no scope it makes Claude list the groups/channels and ask which to sweep — or to confirm the whole corpus — before enumerating, rather than silently scanning everything (an explicit search_transcripts call with no selector still means "all"). (Hub mode defers per-site group resolution — multi-channel scoping still works there.) The MCP stays strictly read-only; only the report file is written, in Claude's own working directory. See mcp/src/{search,source,server}.ts, mcp/src/search.test.ts, and mcp/README.md.
  • "Ask AI" now paces itself to your key's rate limit instead of failing. A whole-corpus sweep on a free-tier key used to fire provider calls as fast as the loop could produce them, blow straight through the per-minute request cap, and stop dead on the first HTTP 429 (and a rate-limit mid-answer surfaced as a hard error, because only the sweep path ever handled 429). Now a single client-side limiter sits behind every AI call: it spaces requests to a conservative, free-tier-safe requests-per-minute default chosen per model (e.g. Gemini *-pro → 5/min, *flash → 10/min; Claude/OpenAI higher), so a paid key runs fast and a free key just runs slowly rather than erroring. When a 429 does land, the limiter honours the provider's own retry hint (the Retry-After header, or Gemini's RetryInfo retry-delay) — or an exponential backoff — and re-issues the request (safe for streaming: the retry happens before any answer text is emitted), and it self-lowers the rate after a 429 so later calls pace slower. Only a 429 that outlasts the retries falls through to the existing pause/checkpoint (the right home for a daily-quota cap you resume tomorrow). Provider settings gain an editable Requests per minute field (with the effective spacing, e.g. "≈12s between requests", and a reset-to-default), and a paced sweep shows a "Rate limited — retrying in Ns" line in its progress strip so it never looks frozen. See export/app/lib/rateLimit.ts (the whole mechanism), export/app/lib/askProvider.ts (PausableError + acquire/retry in askStream, 429 in ensureOk), export/app/lib/nativeTools/shared.ts (acquire/retry in postJson), export/app/ask/{useAskChat,ProviderSettings,PinnedResultsPanel,AskChat}.tsx, and export/e2e/ask-workspace.spec.ts.
  • "Ask AI" — an integrated grounding workspace: pick your AI target, sweep any size, save & resume chats. Search and the chat used to behave like two tabs, grounding was all-or-nothing, a sweep had hard-coded caps and couldn't be paused, and a conversation lived in a single unnamed slot that "New chat" wiped. Now they're one surface:
    • Grounding palette (the signature control). A first-class Whole search ⇄ Selection toggle in the chat always states what the AI is looking at, alongside an action cluster — Ask, Sweep, and preset directives (Summary, Contradictions, Timeline) that pre-fill the composer/sweep box. "Selection" grounds in a hand-picked subset of results shown as dismissible chips; an empty selection parks on Whole search, so nothing changes for people who never select.
    • Per-result selection + one-click Ask. Every result card gets a select checkbox (an inclusion set mirroring the channel exclusion set) and an Ask button that grounds the chat in that one video and crosses to it. The selection survives //ask and reload, and self-cleans against a new search.
    • [ Search | Chat | Split ] view modes. A persisted control fullscreens either pane or shows both side by side (lg+); below lg it degrades to Search ⇄ Chat tabs. Both panes stay mounted, so switching never re-runs the search or drops chat state.
    • Uncapped + pausable sweeps. The sweep batch size is now a real setting, and a sweep sweeps the whole target. On a rate-limit (HTTP 429) or a manual Pause, the run checkpoints the report + the remaining batches instead of aborting, and surfaces Paused: [Resume] [Stop]. Resume continues from the next batch — even after a reload or a model switch (the checkpoint carries no key/model binding). The "matched more than we can sweep" note is now driven by the true engine cap, not the >100-video display cap.
    • Saved chats library. A Saved chats row (mirroring the search "profiles" row) with Save / Save as… / Rename / Delete and a dirty dot; a saved chat is a named copy of the working conversation plus any paused-sweep checkpoint, so a research session — including a mid-sweep pause — can be saved and resumed later.
    • PWA notifications (opt-in sites). On a PWA-shipping site, a long sweep fires a system notification when it finishes or pauses while you're away; a graceful no-op everywhere else. See common/components/{SearchSessionContext,SearchResults,WorkspaceSearchBar}.tsx + common/lib/selection.ts, export/app/(workspace)/{WorkspaceView,WorkspaceNav,SiteWorkspace}.tsx, export/app/ask/{useAskChat,GroundingPalette,SavedChatsRow,PinnedResultsPanel,ProviderSettings,AskChat,askChatStorage,askNotify}.ts(x), export/app/lib/nativeTools/shared.ts (PausableError), and export/e2e/{ask-workspace,ask-chat,workspace-shell}.spec.ts.
  • "Ask AI" chat — sweep a whole search into one report, in batches. A single question can only reason over ~15 videos, and a big multi-channel search (e.g. k-cup → hundreds of matches) leaves the long tail unread. The pinned-results panel now has a whole-corpus report action: it reads the entire matched set in batches, folding each batch's findings — claims, contradictions with earlier claims, and their cited sources — into a persistent Report and discarding each batch's raw excerpts as it goes, so tokens stay bounded no matter how large the result set. Type what to focus on in the composer (e.g. "major contradictions", or leave it blank for key claims & contradictions) and click Build report from all N results; a determinate progress strip counts the batches while the Report panel's document visibly grows below. When a report already exists the action splits into Start new report (replaces it) and Add to report (folds these in) so a replacement is never implicit. The model may still drill into a thin line via fetch_context mid-sweep, and a single Stop cancels the whole run while keeping the partial report. Tool-capable providers only (like Report mode); on the Scripted transport the control is disabled with a hint. See common/components/SearchSessionContext.tsx (getFullGrounding), export/app/lib/{askConversation,searchAgent}.ts (chunk/accumulationSystemPrompt/buildAccumulationContent, runReportChunk), export/app/ask/{useAskChat,PinnedResultsPanel,ReportPanel,AskChat}.tsx, and export/e2e/ask-chat.spec.ts.
  • "Ask AI" chat — citations now open the transcript, and the answer reads your aliases in context. Clicking an inline [1], [2]… citation (or any timestamp in the source list beneath an answer) now opens the transcript/video modal seeked to that line — the same modal a search-result hit opens — instead of just scrolling to the source. It opens as a sibling overlay on the same /ask route (via replaceState, no navigation), so dismissing it (✕ / Escape) drops you back into the chat exactly where you were. On the federated hub chat (no player) citations keep the old scroll-to-source behaviour. Separately, the answer prompt now gets a focused alias block naming only the terms a search actually used (e.g. a search whose alias is Cake Cupsk-cups), so the model reads mis-transcribed spellings in the excerpts as the same term — the gather phase still sees the full glossary so it can discover terms it hasn't searched yet. See common/components/PlayerProvider.tsx (usePlayerOptional), export/app/ask/{MessageBubble,AskChat}.tsx, export/app/lib/{askRetrieval,searchAgent,askConversation}.ts, and export/e2e/ask-chat.spec.ts.
  • "Ask AI" chat — long answers no longer silently truncate, plus a debug export. Long answers (especially reports on the free Gemini tier) used to get cut off mid-sentence with no indication, because the answer was hard-capped at 2048 output tokens and the app ignored the provider's "why did it stop" signal. Now: the answer budget defaults to 8192 and there's a Max answer length setting to push it higher; when a model does hit its output limit the answer shows a clear "⚠ Cut off at the model's output limit" notice (with a nudge to raise the limit or use Report mode); and a Gemini response that comes back empty because it was safety-blocked now says so instead of rendering a blank bubble. For troubleshooting, provider settings gain Download / Copy debug JSON — a redacted snapshot of the conversation (messages, phases, finish reasons, report, settings) plus the most recent raw API calls (request, response, finish reason, token usage); your API key is never included. See export/app/lib/{askProvider,searchAgent,askDebug}.ts, export/app/lib/nativeTools/shared.ts, and export/app/ask/{useAskChat,ProviderSettings,MessageBubble}.tsx.
  • "Ask AI" chat — clickable citations and smaller niceties. The [1], [2]… citation markers in an answer are now clickable: click one to jump straight to that source in the list below (a smooth in-page scroll with a brief highlight — no new tab, no navigation). Also: a suggestion chip now focuses the composer when you pick it (so you can tweak and press Enter), and the answer Copy button now says "Copy failed" when the browser blocks clipboard access (e.g. on an insecure origin) instead of silently doing nothing. See common/components/Markdown.tsx (an optional linkComponent), export/app/ask/{MessageBubble,Composer,AskChat}.tsx.
  • "Ask AI" chat — an opt-in Report mode for long research sessions. Turn on Report mode (in the new Report panel; tool-capable providers only) and the assistant maintains a persistent Markdown report document — a running canvas it updates via an update_report tool as you keep chatting, upserting well-titled sections that persist across turns. Crucially, while it's on the conversation is compacted into the report instead of replaying every prior question and answer: each turn sends a single "report so far" summary plus the usual deduplicated excerpt pool, so a long back-and-forth stays bounded in tokens instead of growing every turn. The report renders live in the panel (with an updating… shimmer while a turn writes to it) and is saved with the conversation, so it survives reloads; New chat clears it. On the Scripted transport there's no tool to drive it, so the toggle is disabled with a hint. See export/app/lib/nativeTools/{shared,anthropic,openai,gemini}.ts (the update_report tool), export/app/lib/askConversation.ts (applyReportPatch), export/app/lib/searchAgent.ts (compaction + executor), export/app/ask/{useAskChat.ts,ReportPanel.tsx,AskChat.tsx}, and export/e2e/ask-chat.spec.ts.
  • "Ask AI" now grounds in your current search automatically. With search and chat sharing one workspace, you no longer click "Ask AI about these results" to hand a frozen copy of your results to the chat — the chat reads the live search directly. Run a search, switch to Chat, and it's already grounded in exactly those results (with the same answer only from these / may also search toggle); change the search and the grounding follows. No active search → the chat searches on its own, as before. A Detach control lets you ask a free-form question without the current search grounding it (and a Ground in my search button re-attaches). The old "Ask AI about these results" button and its one-shot hand-off are retired. See common/components/SearchSessionContext.tsx (liveGrounding), export/app/ask/useAskChat.ts, export/app/ask/{AskChat,PinnedResultsPanel}.tsx, and common/components/SearchResults.tsx.
  • Search and "Ask AI" are now one workspace — the search bar stays put when you switch between them. Previously / (search) and /ask (chat) were separate pages, and navigating from one to the other threw away your search. They now share a single shell: the search bar and its results live in a persistent layout, with a Results ⇄ Chat switch between the two views. Run a search, flip to Chat to ask about it, flip back — your search is exactly where you left it. Under the hood the search state was lifted out of the monolithic search component into a shared SearchSession (both views read the same committed search), so it's the one source of truth features build on. Hub mode is unchanged. See common/components/{SearchSessionContext,WorkspaceSearchBar,SearchResults,TranscriptSearch}.tsx, export/app/(workspace)/*, and export/e2e/workspace-shell.spec.ts.
  • Handing a search to "Ask AI" now passes the whole result set, not just the first 20. Previously only the top 20 videos reached the chat, so it literally couldn't see the rest. Now every match is handed off (up to a generous cap) and presented in tiers: the model gets a compact index of all matching videos (title, channel, date, hit count) plus full excerpts for the top ~12 — and it can pull excerpts for any other result on demand via the existing fetch_context tool, or you can click Load context on any of them. This keeps the payload bounded while letting the assistant reason over the complete set. See common/lib/aiHandoff.ts (tiered buildSearchHandoff) and export/app/lib/askConversation.ts (buildTieredGrounding).
  • "Ask AI" chat — regenerate, edit & resend, and per-answer attribution. You can now Regenerate a completed answer (reusing the excerpts it already gathered), Edit any earlier question to pull it back into the composer and re-ask from that point, and see which model produced each answer — useful when you switch providers mid-conversation. The Context panel now shows a rough token estimate (not just a character count) as a cost cue, a hand-typed model name survives switching providers and back within a session, and a failed "Load context" fetch now says so on that result instead of the spinner just quietly stopping. See export/app/ask/{MessageBubble,AskChat,PinnedResultsPanel,ContextPanel,useAskChat}.tsx and export/app/lib/askConversation.ts.
  • "Ask AI" chat polish — fewer dead ends, clearer feedback. Several first-use rough edges are fixed: the provider settings panel no longer collapses out from under you the moment you start typing your API key; Enter now sends (with Shift+Enter for a new line); a message that stops or fails part-way through streaming now keeps its actions (Copy, Retry) and still shows what was searched, instead of freezing as raw text with no way forward; a request that errors mid-answer now shows what went wrong appended to whatever streamed, rather than silently dropping the error; an empty answer says so (with a Retry) instead of rendering nothing; the pre-answer "writing…" indicator no longer double-renders with an empty bubble; a fast double-press can no longer fire two turns at once; and the streaming answer is announced to screen readers. See export/app/ask/{ProviderSettings,Composer,MessageBubble,useAskChat}.tsx.
  • The "Ask AI" chat no longer balloons its own context. A long conversation — especially one grounded in a big set of search results — used to re-send every previous turn's excerpts inside every new turn, so the context grew quadratically until answers stalled or a provider rejected the request. Excerpts are now carried forward once, in a deduplicated pool (each video's excerpts merged across turns and sent a single time), while the replayed history is just the questions and answers. Three related fixes ride along: a "context length exceeded" error from your provider is now recognised and shown as a clear, actionable message instead of being mistaken for "this model doesn't support tools" (which pointlessly retried the oversized request); a conversation that outgrows your browser's storage quota now trims its oldest turns and warns you, instead of silently failing to save so newer turns vanished on reload; and reloading the page mid-answer no longer leaves a message spinning forever — the interrupted turn shows a Retry. See export/app/lib/{askConversation,searchAgent,askProvider}.ts, export/app/lib/nativeTools/shared.ts, and export/app/ask/{useAskChat.ts,AskChat.tsx}.

[0.7.6] - 2026-07-16

  • Drill into a result — read the transcript around any hit. Pinned search results used to be fixed to their matched snippets (~240 characters), which is often too little to answer "why did they say that / what surrounded it." Now the surrounding transcript can be pulled in on demand, two ways. In the pinned panel, expand a video and click a timestamp (or context) to add the neighbouring transcript to what the assistant reads — deterministic, no extra AI call, and it works in strict mode and on every provider. And in expand mode on tool-capable providers, the assistant can do this itself via a new fetch_context tool when a snippet is too thin, showing a reading step in the live pipeline. Windows are bounded (±45s, capped cues, ≤30 excerpts per video) so full multi-hour transcripts are never dumped, and enriched excerpts persist with the conversation. See common/lib/transcriptWindow.ts, export/app/lib/nativeTools/*, export/app/lib/searchAgent.ts, export/app/ask/{useAskChat.ts,PinnedResultsPanel.tsx,PipelineStatus.tsx}, and export/e2e/ask-chat.spec.ts.
  • Hand a search's results straight to the "Ask AI" chat. The search results header gains an Ask AI about these results button (next to Copy for AI) that opens the chat grounded in exactly the videos you found, instead of the assistant deciding its own search. By default it answers only from those results (fast and predictable); a per-chat toggle — Answer only from these results — lets the assistant also search the archive, using your results as a starting point. The pinned set is shown with the search that produced it, survives reloads, and can be detached (Clear) or replaced with a new hand-off. See common/lib/aiHandoff.ts, common/components/TranscriptSearch.tsx, export/app/ask/{useAskChat.ts,PinnedResultsPanel.tsx,AskChat.tsx}, export/app/lib/searchAgent.ts, and export/e2e/{ask-chat,query-tree}.spec.ts.

[0.7.5] - 2026-07-07

  • The "Ask AI" chat now uses your curated name aliases when it searches. Asking about a term with a known alias (e.g. Graham Platner, which AI transcription often mangles to "Grand Platina") now applies the alias's regex, so misspelled mentions are found instead of missed. Previously the chat matched aliases one word at a time and a multi-word alias never fired; it now matches the whole search phrase, and each prompt explicitly tells the model to search the full aliased term (not a fragment like "Graham") and why. See export/app/lib/askRetrieval.ts (buildSearchRoot) and export/app/lib/askConversation.ts (renderAliasGlossary).
  • The chat conversation is now saved, resumable, and editable. Your conversation and the excerpts it gathered are kept in your browser, so a reload no longer loses them, and a New chat button clears them. If a request fails partway (e.g. a provider 503), the turn shows a Retry button that re-runs it reusing the excerpts already found — no re-searching. And a new Context panel lets you view and prune exactly what gets sent to the model (conversation + excerpts) as editable text, for a leaner, cheaper starting point — mid-conversation or as the seed for a fresh session. See export/app/ask/{useAskChat.ts,ContextPanel.tsx,MessageBubble.tsx}, export/app/lib/{searchAgent,askConversation}.ts, and export/e2e/ask-chat.spec.ts.

[0.7.4] - 2026-07-07

  • The export build can now fan out across sites in parallel (Docker mode). Archive-zip generation is hoisted into a new shared host step (build:archives) that warms the persistent cache once for the union of all sites' channels, so the per-site compose:site can run read-only over that cache (ARCHIVES_READONLY=1) without re-zipping or racing — which is what lets several sites build at once. No change to a site's output; this is a build-pipeline change. See common/bin/build-archives.ts, common/controller/{archiveTranscripts,archiveLiveChat}.ts (readOnly), and common/bin/compose-site.ts. (Orchestration + UI live in the editor changelog.)
  • The "Ask AI" chat now searches like an assistant — and follow-ups work. Instead of one keyword search per message, the chat now runs an agentic loop: it decides what to look up, reads the results, and can refine and search again before answering — you see each search as it happens. Crucially, a follow-up that builds on the last answer (e.g. "put that on a timeline", "summarise it") no longer throws away the earlier context and re-searches your wording — earlier excerpts stay in play, so it just reformats what it already found. Answers render as Markdown (with a Format answers toggle to fall back to plain text if anything looks off), a live status shows the search→answer pipeline, and the model is told the archive's known transcription-misspelling aliases so it searches for the right variants. A Search mode control (Auto / Native tools / Scripted) chooses between your model's native function-calling and a provider-agnostic protocol, with automatic fallback. See export/app/ask/*, export/app/lib/{searchAgent,askConversation,nativeTools/*}.ts, common/components/Markdown.tsx, and export/e2e/ask-chat.spec.ts.

[0.7.3] - 2026-07-07

  • Rebuilds now skip channels that haven't changed. The export build used to redo almost everything from scratch every run — it re-zipped every channel's transcript and live-chat download bundle, and rm -rf'd and re-copied every channel's transcript/subs pages into the served tree — even when nothing about that channel had changed. Each of these is now gated on a cheap per-channel signature: archive zips are built once into a persistent shared cache (transcripts/export/archives) keyed by a content signature and reused build-to-build and across sites, and the per-channel page trees are reconciled in place (only changed channels are re-copied, removed channels pruned). A no-change recompose drops from re-zipping/re-copying gigabytes to a few seconds. Output is identical — the signature is over inputs (video mtimes recorded by build:index, channel config, and archive options), so a channel is only rebuilt when its content actually changes; a schema bump or settings change invalidates the cache. Also parallelizes the per-channel archive compressor across a few channels at once. See common/lib/channelSignature.ts, common/controller/{archiveTranscripts,archiveLiveChat}.ts, and common/bin/compose-site.ts.

[0.7.2] - 2026-07-07

  • The Downloads page was redesigned around channels. A channel's transcript bundle and its live-chat bundle now sit together in one card — a catalog "record" headed by the channel name and its total video count and size — instead of scattered, separately-bordered rows. Each bundle shows its item count and filename, and the size moved into a padded download button (fixing the badge that used to butt against the card edge). The nested/mismatched borders are gone, the whole page adapts to every theme, and an oversize bundle still reads as unavailable with its reason.

[0.7.1] - 2026-07-06

  • The "Ask AI" chat now finds the right videos. The /ask retrieval previously skimmed the first few transcript shards and cut off early, so it kept answering from the same handful of videos no matter what you asked. It now uses the site's own transcript search engine (the one behind the search box): your question's keywords rank matches across the whole archive, snippets carry real timestamps, and it reuses the cached index — so different questions surface different, relevant videos, and on the federated hub it searches every member site.
  • Search now suggests a better query when you type a known term. When a search term matches a curated alias — e.g. typing loli, lolly, or loly — a quiet chip appears under the box offering a more robust regex like \blol(i|ly). Click Apply to swap it in (and switch that layer to regex mode) or Dismiss to ignore it; a new term re-offers. It's never forced, matching is whole-word (so lolight won't trigger it), and it's suppressed while you're already writing a regex. The dictionary is authored in the editor's new Search aliases page and shipped per site as /search-aliases.json (the global list merged with per-site overrides). See common/components/QueryLeafView.tsx, common/components/{aliasesCache,SearchDataContext}.tsx, common/lib/searchAliases.ts, and export/e2e/alias-suggestion.spec.ts.
  • Alias suggestions now handle multi-word terms and regex mode. A trigger with a space in it (e.g. graham platner) now offers its suggestion the same way single words do — matching is whole-word, in order, and case-insensitive. And a suggestion still surfaces when the box is already in regex mode as long as you've typed a plain term equal to the alias (a hand-written pattern, or an already-applied suggestion, is left untouched). See common/lib/searchAliases.ts (matchAliases).

[0.7.0] - 2026-07-06

  • 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

  • Big archives are no longer dropped — they overflow to object storage. Cloudflare Pages rejects any single asset over 25 MB, so oversize channel zips (a busy channel's live-chat log can be hundreds of MB) used to show as "too large to host." When an R2 overflow bucket is configured, those archives now upload there on deploy and the Downloads page links straight to them. Without a bucket configured, the old "unavailable" behavior stands.
  • Combined "whole site" archives were removed in favor of per-channel bundles — they duplicated the per-channel content and were always the first to blow past the size cap.
  • The Duplicates page can be turned off per site, and hides itself when empty. Sites can opt out of the cross-channel duplicate-shorts page from their editor settings, and even when it's on, the header link and page now hide automatically when a site has no detected duplicates.

[0.6.2] - 2026-07-04

  • Search results scroll smoothly again on large result sets. The results list already mounts only the on-screen video cards, but each visible card and every one of its hit rows re-rendered on every scroll frame — and each hit row re-ran its <mark> highlighting, so a video with hundreds of hits meant hundreds of redundant highlight passes per frame while scrolling. Cards and hit rows are now memoized so unchanged ones are skipped during scroll (and opening a hit only re-renders the two rows whose highlight state changes). Same results, same layout — just far less work per frame.

[0.6.1] - 2026-07-04

  • A new Downloads page lets you take the whole archive with you. Reachable from the header nav and footer (shown only when a build actually produced archives), /downloads lists the site's transcript and live-chat bundles as .zip downloads — the whole site up top, then per channel — each printing its video count and file size. A bundle too large to host (over the build's size cap) is shown as unavailable with the reason instead of a broken link. The zips are regenerated on every build.
  • Download a single video's transcript or live chat as a file. The player toolbar has a new download control () that saves whatever you're viewing — the transcript, or the live chat — as .txt, .srt, or .json, generated in your browser from the already-loaded cues (no download of the full archive needed).

[0.6.0] - 2026-07-02

  • Kick videos now play in the site's own player. Kick VODs have no embeddable player, so before this they could only link out to kick.com. The player now streams the VOD from its original HLS source directly — with the same real scrubbing, share-at-current-timestamp, and transcript-synced cue highlighting you get on YouTube, and better than the other embed-only platforms. Nothing is proxied through kick.com at view time.
  • "Likely expired" badges for stream VODs that platforms delete. Kick keeps VODs only ~30 days and Twitch keeps them 7–60 days (depending on the streamer's account), after which the video is gone and can't be played anywhere. Video cards for Kick/Twitch past that window now show a Likely expired badge (hover it for the platform's retention details). An expired Kick VOD shows a short "most likely deleted" notice with a link to the source instead of a broken player. The transcript stays fully searchable either way.

[0.5.1] - 2026-07-02

  • The default look is back to the original clean, neutral theme. Public sites now open in the neutral Base theme — the crisp white/zinc palette (near-black ink in dark mode) with a blue accent that the site had before the themed redesign — instead of the warm brass "Archive" reading room. The wordmark and headings are all-sans to match, so the default site reads the way it used to. The Archive, Selenized, and Swiss themes are all still one click away in the theme picker, and any theme you'd previously chosen is remembered. Sites that set their own brand accent keep it. (The installable-app manifest's colors follow the new default too.)

[0.5.0] - 2026-07-01

  • Fixed: some Odysee/Rumble/Twitch videos opened in a YouTube player. A video you'd opened before multi-platform support was added could get stuck playing in the YouTube embed even after its data was corrected, because the browser's local transcript cache (IndexedDB) held an old record with no platform and was never invalidated. The cache now self-heals — stale records are discarded on load — so every video plays in its correct platform's player. As a safeguard, a video whose platform somehow can't be determined now shows an "Open on source site" link instead of silently loading the wrong player.
  • Instant offline word-search, powered by a background FlexSearch index. On the Offline page, each channel now has a Build index action: it constructs a FlexSearch full-text index for that channel in a Web Worker — entirely off the main thread, so indexing a large channel never freezes the page (a live progress % shows the work). Once built, a search box does instant word/prefix search across your indexed channels and returns the matching transcript lines (with channel + timestamp) — and because the index is built from the same shards the offline download caches, it works with no connection. The worker bundles into the static site (no server needed). This is a first, self-contained surface; the main search box will gain the same acceleration next.
  • Install the archive as an app, and search it offline. Each public site is now a PWA: it ships a web app manifest (named + themed with the site's accent color) and an icon set, so your browser can install it to your home screen or desktop and open it in its own window. A service worker caches the app so it loads instantly on repeat visits and still opens without a connection. A new Offline page (linked in the footer) lists the site's channels with a Download button each — pick the channels you want and their transcripts are saved to your device so you can search them with no connection; a live Online/Offline indicator and per-channel "Available offline" state show what's ready, and you can remove a channel to free space. Downloads are opt-in (a channel's transcripts can be large) and refresh automatically when the site is rebuilt. Everything still works online-only in browsers without service-worker support.
  • Two more themes + a theme picker, and the themes now feel genuinely distinct. A palette menu next to the light/dark toggle lets you switch the whole site's look between Archive (the warm reading room), Selenized (the Solarized successor — teal-slate on warm tan, with the calibrated Selenized accents), Swiss (red/black/white editorial), and Base (neutral) — each in light or dark, and your choice is remembered. Beyond color, each theme now carries its own corner shape and typeface: Swiss goes hard-cornered in a neo-grotesque, Selenized adopts a code voice (JetBrains Mono headings + IBM Plex Sans), and Archive keeps its soft-cornered serif reading room. Switching is instant and never changes the layout. (If you'd picked the old "Terminal" theme, it becomes "Selenized" automatically.)
  • Optional per-site brand accent. A site can set its own accent color (a hex, in the editor's site config) to replace the family brass everywhere on its public build — the wordmark mark, links, badges, and highlights. It's baked onto the page at build time so it's correct on the very first paint, and applies in both light and dark. Sites that don't set one keep the family brass.
  • New look: the "Archive / Reading Room" identity. The public site adopts the family design system — a serif display face (Source Serif 4) for headings/counts, clean sans for body, and mono for the query/power layer, over a warm paper (light) / ink (dark) palette with a brass accent. A light/dark/system theme toggle in the header replaces OS-only dark mode and persists your choice. Header, footer, and the whole search UI (query builder, filters, scopes, charts) are rebuilt on a shared component kit for a calmer, more consistent feel — the simple one-box search is unchanged; the power tools just read better.
  • "New since your last visit." A dismissible banner on the home page tells you how many transcripts were added since you were last here, and first-time visitors get a one-line nudge toward the power tools (layered queries, scopes, filters, charts). Entirely client-side — nothing about your visit leaves your browser.
  • Family navigation. The header gains a "Sites" switcher to hop between sibling sites in the pool, plus an optional link back to the family hub (set the hub URL once in the editor's Settings).
  • Fixed: flash of giant social icons on first load. Footer social icons now carry an intrinsic size, so they render correctly before the stylesheet loads on a static host instead of briefly ballooning.
  • Transcript coverage is now a chart metric. Each video's stats record carries a coverage fraction (transcript's last cue end ÷ video duration), selectable as a Y-axis metric ("Transcript coverage") in the chart options — e.g. average coverage by channel surfaces channels with truncated/partial transcripts. Coverage is null for videos with no transcript or no usable duration. (Requires the one-time build:index + build:stats rebuild on the bumped stats schema; the field is back-filled for every transcribed video from its transcript.cues.json.)

[0.4.1] - 2026-06-22

  • New "Content added" charts: track when videos joined the library, not just when they were uploaded. The time axis of any chart can now bin on when we downloaded or transcribed a video, in addition to its upload date — pick the Date field in the chart options. The dashboard ships a cumulative Library growth (added) chart by default (grouped by channel), and the chart gallery gains a Content added group with cumulative download/transcription curves and a per-month stacked-by-channel breakdown. Like every chart, these filter by channel and export to CSV/PNG. (Populating acquisition dates for existing videos requires a one-time data rebuild — build:index + build:stats re-extract on the bumped stats schema; videos added before this release fall back to file timestamps.)

[0.4.0] - 2026-06-14

  • Search video descriptions and tags. Each search layer's scope dropdown gains two new options alongside Transcripts / Live chat / Title-channel: Description (matches the video's description text) and Tags (matches its keyword tags). They combine with the others through the same AND/OR/NOT query builder and regex, and — like any search — can be charted (e.g. videos whose description mentions a term, over time). Because descriptions/tags live in the per-video transcript data, a description/tags-only search loads the same data a transcript search does.
  • New chart breakdowns: Media type, Status, and Tag. Charts can now split or bucket videos by Media type (regular video / livestream / Short — Shorts were previously indistinguishable), by Status (available / unlisted / deleted — e.g. chart how many videos got deleted each month), and by Tag (a bar per keyword tag). Media type and Status are also available as series groupings. (Populating these for existing videos requires a one-time data rebuild — build:index + build:stats re-extract on the bumped schema versions.)
  • Charts are now a view of Search. The separate Charts dashboard is gone; instead, a Results / Chart toggle sits beside the result count on the search page. Flip to Chart and the current search — query, every filter, and your advanced settings — is plotted as a single chart, updating live as results stream in. A Chart options panel (right where the Filters panel lives) configures it: chart type (line/bar/stacked/area/pie), X axis (upload date binned by week/month/quarter/year, category, or duration buckets), split into series by channel/platform/language, cumulative running totals, and a data source switch between Search matches (plot matched videos or total hits) and Metadata (uploads, views, durations, top channels — aggregated over the same in-scope videos). An Examples ▾ menu one-click loads common chart shapes. Switching between Results and Chart keeps everything in place — it's the same search — and Share current search now encodes the chart too (view=chart + a compact cs= shape), so a shared link opens straight to the same chart; the URL stays in sync live, just like the query does. The old standalone /charts page and its multi-chart board are retired on the site (dashboard authoring still lives in the editor).
  • Footer links to the network's other sites. The footer now carries a subtle, low-key list of links to the other sites in the network (e.g. Jeralyzer's footer links to Rekietalyzer and Hasanalyzer). It's intentionally understated — a small, muted row beneath the downloads/social row — since it's a secondary wayfinding aid, not a primary feature. By default it's a flat list of every sibling site; a site can override that to surface closely-related subjects first under named groups (configured per site in the editor), with everything else collected under a trailing "Other sites" group. Only sites that have a public URL configured appear, and a site never links to itself. Single-site installs show nothing new.

[0.3.5] - 2026-06-04

  • Duplicates page. A new Duplicates tab in the primary nav lists shorts whose transcripts match across channels and platforms — re-uploads, mirrors, and cross-posts of the same clip — grouped into clusters (strongest match first). Each cluster is laid out like a group of search results: a header bar shows how it matched (exact or near transcript, similarity score, clip-of-longer) and whether it spans multiple channels or platforms — or is a same-channel re-upload — and every member is a search-hit-style row with its title, channel name, platform and upload date, plus the original video's URL on its own line so it's obvious the members are genuinely different videos. Clicking a member's title plays it in the transcript modal. The page header also counts how many clusters contain same-channel duplicates. A Filters section (styled like the search page's) scopes the list by platform — YouTube only by default, with Rumble/Odysee/Twitch toggleable — and by match type, relationship (same-channel / cross-channel / cross-platform / clip-of-longer), channel, and a title/channel text search; platform and channel toggles drop members on de-selected platforms/channels and hide any cluster left with fewer than two members. The list is scoped to the channels this site exposes, so every entry is openable here. Sites with no detection run yet show an empty state.

[0.3.3] - 2026-06-01

  • Videos whose English captions only existed under a regional/auto code now appear. A handful of videos had English subtitles only under codes like en-US or en-en-US (no plain en), which the index didn't recognize — so they were missing from the site even though they had a transcript. These now show up in browse and search like any other transcribed video.

[0.3.1] - 2026-05-31

  • Date-range filter in search. The filters section has a new Date row with From / To pickers that limit results to videos uploaded within the range (inclusive bounds; leave either end blank for open-ended). It works the same way the charts date range does and applies to the browse list and every search layer. The range saves in profiles and the working snapshot, rides along in shared search links, and carries over to "Chart this search" so the chart opens scoped to the same window.

[0.3.0] - 2026-05-30

  • Per-site builds (major change). The export is now built and deployed once per site from a shared index, so one editor can publish several independent sites (e.g. Jeralyzer and Rekietalyzer) over the same channel pool. Each deployed site shows only its own channels and carries its own branding (title, header, tagline, footer social links), channel groups, and charts dashboard — a channel shared between sites is downloaded once and never duplicated. The site is selected at build time via the SITE_ID env var; the heavy per-channel transcript/subs data is generated once and shared, while each site gets a filtered summaries/subs/stats bundle composed into its own static output. No user-visible change for a single-site install beyond the branding now coming from that site's config.
  • Twitch.tv videos. Twitch is now a supported platform: Twitch videos play inline via a Twitch embed, and "Twitch" appears as an option in the charts platform filter and series grouping.
  • Advanced query builder when charting search matches. A chart's search source now uses the same layered query builder as the main search page — combine any number of layers with AND/OR/NOT, nest groups, and set each layer's scope and regex — instead of the old single term box. Simple one-term searches still collapse to a single input.
  • "Chart this search." A new control beside "Share current search" turns the query you're looking at into a chart in one click: pick a starting template (mentions over time / matching videos per month / matches by channel) and jump to the Charts tab with the query pre-filled and ready to edit. Choose whether it's added to your existing board or opens as a single-chart board, and your selected channels carry over as the chart's channel filter.
  • Charts. A new Charts tab (a prominent primary nav item alongside Search) visualises the library. Build charts from video metadata — upload date (binned by week/month/quarter/year, with optional cumulative running totals), engagement metrics (views, likes, comments, follower count), duration distributions, categories, languages — or from search matches, plotting how many videos match a term (or total hits) over time. Each chart has labelled axes and hover tooltips. Pick the chart type (line/bar/stacked/area/pie), split any chart into series by channel/platform/language, and apply a dashboard-wide filter bar (date range, platform, channel, has-transcript). The editor sets the data source with a clear Metadata/Search toggle, and search terms can be plain text or regex. A chart's date/channel/platform filters narrow the search scope (not just the displayed results), so a date range keeps a search chart light. The default board leads with example search charts, date-limited to the recent ~2 years so they load quickly out of the box. Charts open with an editor-authored default dashboard plus a grouped gallery of templates — including ready-made search examples — that you can fork and customise. Your board auto-saves locally and survives reloads; Reset to default restores the editor's dashboard. Share copies a link that restores the whole board, and every chart can be exported as a PNG (download or copy to clipboard) or its data as CSV. Engagement metrics come from a new stats dataset built alongside the search index.

[0.2.2] - 2026-05-26

  • Browse all videos without searching. With no search query entered, the results list now shows every video matching the current filters (newest-first), instead of a "add a layer to search" prompt. Typing a query narrows the list as before; clearing it returns to the full browse view. This replaces the old workaround of searching regex . on titles.
  • Collapsing the query builder to one layer no longer strips a hidden setting. Deselecting "Show hits in results" on a layer and then deleting the other layers used to leave that lone layer permanently hits-off, with no control to fix it (the checkbox is hidden in the single-layer compact view). Collapsing back to compact now resets "Show hits" to on.

[0.2.1] - 2026-05-25

  • "Unlisted" availability filter. The Availability filter group now has a dedicated Unlisted checkbox alongside Available and Deleted. Availability is now a three-way split — unlisted videos are their own bucket rather than counting as "Available", so you can isolate or hide them independently. Unlisted videos remain shown by default, and the filter is saved in profiles and shareable links like the other filters.

[0.2.0] - 2026-05-22

  • Composable layered search. The single search bar is now a query builder: combine any number of layers with AND / OR / NOT and arbitrary nesting, each layer scoped to transcripts, live chat, or title/channel. Matches from every layer surface in the result list with a per-layer colour swatch. Editing one layer doesn't re-run the others, so deep nests stay fast. Simple one-keyword search still looks like a single input — the builder collapses to compact mode when there's only one layer. Legacy ?q=&m=&re= share-links auto-migrate to a one-layer tree.
  • Query-builder controls. A "Reset layers" button collapses the builder back to one empty transcripts leaf. An "Unwrap" button on every non-root group flattens its children into the parent; if the group had NOT set, the promoted children inherit a flipped NOT so the meaning of the subtree doesn't silently change. The "slow / medium / fast" cost badges next to each scope dropdown have been removed — they read as a warning on the primary mode (transcripts) without giving you anything to do about it.
  • Profile row at the top of the search. The profile selector now sits above both the query builder and the filters, and is shown even with a single channel since profiles cover both query and filters. The "Share current search" button moved into the same row. The dirty-profile indicator is now a small amber instead of the ambiguous text "(unsaved)". A Revert button appears whenever the active profile is dirty, re-applying the saved snapshot (filters and query both) so you can experiment and roll back without re-selecting the profile.
  • Collapsible filters section (channels, Type, Audience, Availability). The summary stays visible whether open or closed and shows "{N} of {Total} channels" with a compact list of the selected channel names (truncated on overflow; full list on hover). Open/closed state persists across reloads, and toggling never marks a profile dirty.
  • "Reset everything" button in the filter reset row clears the layered query and every filter and drops the active profile pointer in one shot. The narrower "Reset channels" / "Reset all filters" buttons stay alongside.
  • Virtualised search results. Results now render through a virtualised list, one element per matching video. Cards mount in full when they enter the viewport and unmount once scrolled away — much smoother on long result lists, and no empty bands while sizes settle. The "Show more videos" pagination button and the "Videos per page" advanced option are gone; everything renders in one continuous list. When the search pipeline still caps at its per-batch hit limit, a "Load more results" button appears below the list.
  • /changelog page rendering this changelog, linked from the right side of the sticky header, with per-heading copy-link buttons for permalinks.
  • Search results survive a page reload. Previously the cached hit rows would silently disappear after refresh (the "N hits" count stayed correct, but the list of timestamps was empty).
  • AND-chained search layers no longer drop hit timestamps after certain edit sequences. Previously, evaluating a layer with "Show hits in results" off and then turning it on at the same query/scope could surface the matching video without any timestamps; the workaround was toggling regex on and off.
  • Filter changes survive a refresh again when a saved profile was active. Previously, committing a filter change with a profile selected would silently revert on the next reload.
  • Index builds no longer crash on large datasets. A single live-chat track encoding to hundreds of MB could previously exhaust memory; page bodies now stream to disk instead of being buffered, and the post-build availability pass no longer makes thousands of sequential file reads.