Archilyzer · Source

archilyzer

Archilyzer
git clone https://archilyzer.pages.dev/source/archilyzer.git
Log | Files | Refs | README | LICENSE

commit 5eb7f7af962d2780240d23a5ed49d88cba208fcd
parent 1b686f5b21335162ad5835f6b87ec41d2ddb1ea8
Author: I Mean I'm Just Saying <imeanimjustsaying@kiwifarms.st>
Date:   Tue, 22 Sep 2026 16:35:26 -0400

merge: export/tags-followups — tag chip groups fold, leaf sections use the scope table, hub /ask prerenders, MCP tag filter skips posts

B1, B2, B4, B5 of the curated-tags follow-ups release. The hub /ask prerender needed the
whole provider stack, not just a search session; e2e:2origin is green for the first time
since the hub /ask page existed (3/3), export 188/188, hub 6/6. Reviewed by Sonnet.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

Diffstat:
Mcommon/components/FiltersPanel.tsx | 186++++++++++++++++++++++++++++++++++++++++++++++++++++++++-----------------------
Mcommon/components/QueryLeafView.tsx | 6+++++-
Mcommon/components/SearchResults.tsx | 13++++++-------
Mexport/app/ask/AskHub.tsx | 29++++++++++++++++++++++++++---
Aexport/e2e-hub/ask.spec.ts | 55+++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mexport/e2e/inline-channel-chips.spec.ts | 11+++++++++++
Mexport/e2e/query-tree.spec.ts | 36++++++++++++++++++++++++++++++++++++
Mexport/e2e/tag-chips.spec.ts | 74++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mmcp/src/protocol.test.ts | 9+++++++++
Mmcp/src/search.test.ts | 45+++++++++++++++++++++++++++++++++++++++++++++
Mmcp/src/search.ts | 30+++++++++++++++++++++++++++---
Mmcp/src/server.ts | 8++++++++
12 files changed, 435 insertions(+), 67 deletions(-)

diff --git a/common/components/FiltersPanel.tsx b/common/components/FiltersPanel.tsx @@ -13,7 +13,7 @@ // to be the outer one. It also doubles as the inline collapse, driven by the // session's persisted `filtersCollapsed`. -import { Fragment, useCallback, useMemo } from "react"; +import { Fragment, useCallback, useMemo, useState } from "react"; import { ChevronRightIcon } from "lucide-react"; import { MISSING_STATES, @@ -36,6 +36,7 @@ import { groupPublishedTags, selectableTags, } from "../lib/publishedTags"; +import type { PublishedTag } from "../lib/curatedTags"; export default function FiltersPanel({ // Inside the sheet the sheet itself IS the disclosure, so the wrapper stays @@ -777,6 +778,62 @@ function TagChipRow() { .sort(); }, [publishable, draftTags]); + // Collapse is per-group and LOCAL, unlike the channel groups' persisted + // `collapsedGroups`: a tag row is a handful of chips a reader folds away to + // get the results back on screen, not a filter shape worth carrying into + // every future session — and a group that came back folded would hide the + // vocabulary this site is built around. Every group starts open. + const [collapsedTagGroups, setCollapsedTagGroups] = useState<Set<string>>( + () => new Set(), + ); + const toggleTagGroup = useCallback((key: string) => { + setCollapsedTagGroups((prev) => { + const next = new Set(prev); + if (next.has(key)) next.delete(key); + else next.add(key); + return next; + }); + }, []); + + const renderChip = (tag: PublishedTag) => { + const selected = draftTags.has(tag.id); + return ( + <button + key={tag.id} + type="button" + aria-pressed={selected} + data-testid="tag-chip" + data-tag-id={tag.id} + title={ + selected + ? `Clear "${tag.label}"` + : `Keep only videos tagged "${tag.label}"` + } + onClick={() => toggleDraftTag(tag.id)} + className={cn( + "flex items-center gap-1.5 rounded border px-2.5 py-1.5 text-sm select-none min-w-0 transition-colors", + selected + ? "border-primary bg-primary/10 text-foreground" + : "border-border bg-card/60 hover:bg-accent hover:text-accent-foreground", + )} + > + {tag.color && ( + <span + aria-hidden="true" + className="inline-block size-2 shrink-0 rounded-full" + style={{ background: tag.color }} + /> + )} + <span className="truncate">{tag.label}</span> + {/* The count is this site's, computed at index time — the same number + the chip's filter will produce. */} + <span className="text-xs text-muted-foreground shrink-0"> + {tag.count} + </span> + </button> + ); + }; + if (groups.length === 0 && unpublished.length === 0) return null; return ( @@ -794,62 +851,85 @@ function TagChipRow() { : `${draftTags.size} selected — a video with ANY of them`} </span> </div> - {/* One wrapping flow, phone-first: a group is a label plus its chips and - wraps as a unit, so a narrow viewport gets one group per line rather - than a torn row. */} + {/* One wrapping flow, phone-first: a group is a disclosure — its label + over its chips — and wraps as a unit, so a narrow viewport gets one + group per line rather than a torn row, and a reader on a phone can + fold a group away to get the results back on screen. */} <div className="flex flex-wrap items-center gap-x-4 gap-y-2"> - {groups.map((group) => ( - <div - key={group.id || "ungrouped"} - data-testid="tag-chip-group" - data-group-id={group.id} - className="flex flex-wrap items-center gap-1.5 min-w-0" - > - {group.label && ( - <span className="text-xs text-muted-foreground shrink-0"> - {group.label}: - </span> - )} - {group.tags.map((tag) => { - const selected = draftTags.has(tag.id); - return ( - <button - key={tag.id} - type="button" - aria-pressed={selected} - data-testid="tag-chip" - data-tag-id={tag.id} - title={ - selected - ? `Clear "${tag.label}"` - : `Keep only videos tagged "${tag.label}"` - } - onClick={() => toggleDraftTag(tag.id)} + {groups.map((group) => { + const key = group.id || "ungrouped"; + const chips = group.tags.map(renderChip); + // The trailing ungrouped bucket has no label, so there is nothing to + // put in a summary and nothing to name what folding it away hides: + // it stays a plain wrapping row. + if (!group.label) { + return ( + <div + key={key} + data-testid="tag-chip-group" + data-group-id={group.id} + className="flex flex-wrap items-center gap-1.5 min-w-0" + > + {chips} + </div> + ); + } + const isOpen = !collapsedTagGroups.has(key); + // What the summary has to say when the chips are hidden: a folded + // group that is narrowing the results must still admit it, or the + // reader is left with a short list and no visible cause. + const selectedCount = group.tags.reduce( + (n, tag) => (draftTags.has(tag.id) ? n + 1 : n), + 0, + ); + return ( + <details + key={key} + open={isOpen} + data-testid="tag-chip-group" + data-group-id={group.id} + className="min-w-0 max-w-full" + > + <summary + onClick={(e) => { + // Drive the open state from React rather than the browser's + // default toggle — same rationale as the channel group + // chips above. + e.preventDefault(); + toggleTagGroup(key); + }} + className="cursor-pointer select-none flex items-center gap-1.5 py-1 text-xs text-muted-foreground min-w-0" + title={ + isOpen + ? `Hide the ${group.label} tags` + : `Show the ${group.label} tags` + } + > + <ChevronRightIcon + aria-hidden="true" className={cn( - "flex items-center gap-1.5 rounded border px-2.5 py-1.5 text-sm select-none min-w-0 transition-colors", - selected - ? "border-primary bg-primary/10 text-foreground" - : "border-border bg-card/60 hover:bg-accent hover:text-accent-foreground", - )} - > - {tag.color && ( - <span - aria-hidden="true" - className="inline-block size-2 shrink-0 rounded-full" - style={{ background: tag.color }} - /> + "size-3.5 shrink-0 transition-transform", + isOpen && "rotate-90", )} - <span className="truncate">{tag.label}</span> - {/* The count is this site's, computed at index time — the - same number the chip's filter will produce. */} - <span className="text-xs text-muted-foreground shrink-0"> - {tag.count} + /> + <span className="font-medium text-foreground truncate"> + {group.label} + </span> + {selectedCount > 0 && ( + <span + data-testid="tag-group-selected" + className="shrink-0" + > + {selectedCount} selected </span> - </button> - ); - })} - </div> - ))} + )} + </summary> + <div className="flex flex-wrap items-center gap-1.5 min-w-0 pt-1.5"> + {chips} + </div> + </details> + ); + })} {unpublished.length > 0 && ( <div data-testid="tag-chip-group" diff --git a/common/components/QueryLeafView.tsx b/common/components/QueryLeafView.tsx @@ -43,7 +43,11 @@ type Props = { // (lib/curatedTags.ts) — the chip row in the filter panel. The code token, the // URL and the MCP `scopes` enum stay `"tags"`: renaming those would break every // saved query and share link to fix a word on a screen. -const SCOPE_LABELS: Record<LayerScope, string> = { +// Exported because the result list labels its per-leaf sections with the same +// words (SearchResults.tsx): a reader picks "Keywords" in the builder and has +// to find "Keywords" over the hits it produced, so there is one table, not two +// that drift. +export const SCOPE_LABELS: Record<LayerScope, string> = { transcripts: "Transcripts", chat: "Live chat", posts: "Posts", diff --git a/common/components/SearchResults.tsx b/common/components/SearchResults.tsx @@ -33,6 +33,7 @@ import { formatTimestamp } from "../lib/vtt"; import { vodExpiry } from "../lib/vodExpiry"; import { Button } from "./ui/button"; import { LayerSwatch } from "./LayerSwatch"; +import { SCOPE_LABELS } from "./QueryLeafView"; import { ChartShapeControls } from "./charts/ChartShapeControls"; import { SearchChartPanel } from "./charts/SearchChartPanel"; import { @@ -697,14 +698,12 @@ const ResultCard = memo(function ResultCard({ data-leaf-section={leafId} > <LayerSwatch leafId={leafId} size="xs" /> + {/* The builder's table, not a second copy of it: this chain + predated the description and tags scopes and called both + of them "Transcripts", which is where the hits did NOT come + from. */} <span className="text-[10px] uppercase tracking-wide text-muted-foreground"> - {leafInfo.scope === "metadata" - ? "Title / channel" - : leafInfo.scope === "chat" - ? "Live chat" - : leafInfo.scope === "posts" - ? "Posts" - : "Transcripts"} + {SCOPE_LABELS[leafInfo.scope]} </span> <span className="font-mono text-xs text-muted-foreground truncate"> {leafInfo.query} diff --git a/export/app/ask/AskHub.tsx b/export/app/ask/AskHub.tsx @@ -5,10 +5,14 @@ // across every shelved archive. Mirrors HubHome's MultiSiteDataProvider wiring. import { useMemo } from "react"; +import { PlayerProvider } from "yt-dlp-transcript-common/components/PlayerProvider"; +import TranscriptModal from "yt-dlp-transcript-common/components/TranscriptModal"; +import PostModal from "yt-dlp-transcript-common/components/PostModal"; import { MultiSiteDataProvider, type FederatedSite, } from "yt-dlp-transcript-common/components/SearchDataContext"; +import { SearchSessionProvider } from "yt-dlp-transcript-common/components/SearchSessionContext"; import { useRegistry } from "yt-dlp-transcript-common/components/siteRegistry"; import AskChat from "./AskChat"; @@ -24,9 +28,28 @@ export default function AskHub() { [sites], ); + // The whole provider stack, in the order SiteWorkspace mounts it for a single + // site — PlayerProvider, then the data source, then the session — because + // hub mode bypasses that shell entirely and /ask is on its own here. + // + // None of these are optional: AskChat's retrieval reads the committed query + // tree, the filters and runQueryTree out of useSearchSession, and the session + // itself calls usePlayer (it opens a transcript at a cited timestamp). A + // missing provider THROWS, so the hub build's prerender of /ask failed and + // took the whole route with it — which is why e2e:2origin was red. The fix is + // the stack, never an opt-out of prerendering. return ( - <MultiSiteDataProvider sites={federated}> - <AskChat /> - </MultiSiteDataProvider> + <PlayerProvider> + <MultiSiteDataProvider sites={federated}> + <SearchSessionProvider> + <AskChat /> + </SearchSessionProvider> + </MultiSiteDataProvider> + {/* The viewers a citation opens into, siblings of the session exactly as + HubHome and SiteWorkspace mount them — a cited link with nothing to + open is the failure this avoids. */} + <TranscriptModal /> + <PostModal /> + </PlayerProvider> ); } diff --git a/export/e2e-hub/ask.spec.ts b/export/e2e-hub/ask.spec.ts @@ -0,0 +1,55 @@ +import { expect, test, type Page } from "@playwright/test"; + +// The hub's /ask route. +// +// Hub mode bypasses the workspace shell that mounts the search session for a +// single site, so AskHub has to supply its own — AskChat's retrieval reads the +// committed query tree and filters out of it. When it did not, `next build` +// with INSTANCE_MODE=hub threw while prerendering /ask ("useSearchSession must +// be used within a SearchSessionProvider") and took the whole route with it. +// This spec is the cheap guard on the rendered page; the build itself is the +// other half, covered by the 2-origin suite which builds the hub for real. + +// No built-in pool: this route has to stand up on a hub with an empty shelf, +// which is what a fresh hub is. +async function stubBuiltins(page: Page) { + await page.route("**/hub-sites.json", (r) => + r.fulfill({ + status: 200, + contentType: "application/json", + headers: { "access-control-allow-origin": "*" }, + body: "[]", + }), + ); +} + +test.describe("hub /ask", () => { + test("renders the chat with its composer", async ({ page }) => { + const errors: string[] = []; + page.on("pageerror", (e) => errors.push(String(e))); + + await stubBuiltins(page); + await page.goto("/ask"); + + // The route's own header — hub mode says "the federation", not + // "the transcripts". + await expect( + page.getByRole("heading", { name: /Ask a question about the federation/ }), + ).toBeVisible(); + + // The composer is AskChat's whole point, and it only renders once the + // provider stack AskHub now mounts is there. Located by its placeholder, + // the way ask-chat.spec.ts does it — the first textbox on the page is the + // provider panel's API-key field, not this. + const box = page.getByPlaceholder( + /Ask about the transcripts|Loading transcripts/, + ); + await expect(box).toBeVisible(); + await expect( + page.getByRole("button", { name: "Ask", exact: true }), + ).toBeVisible(); + + // A missing provider surfaces as a client-side throw, not a blank page. + expect(errors).toEqual([]); + }); +}); diff --git a/export/e2e/inline-channel-chips.spec.ts b/export/e2e/inline-channel-chips.spec.ts @@ -116,6 +116,17 @@ async function installRoutes(page: Page) { generatedAt: new Date().toISOString(), }); }); + // No curated tags: this fixture is about channel chips, and the tag groups + // are <details> too — a real /tags.json from the dev server's public/ would + // put open tag disclosures inside the same "details details" the + // expand-nothing assertions below count. + await page.route("**/tags.json", async (route) => { + await route.fulfill({ + status: 404, + contentType: "application/json", + body: "{}", + }); + }); } async function waitForHydration(page: Page) { diff --git a/export/e2e/query-tree.spec.ts b/export/e2e/query-tree.spec.ts @@ -208,6 +208,42 @@ test.describe("composite search — query tree", () => { await expectResultSlugs(page, [CHAT_LARGE_SLUG]); }); + test("a leaf section is labelled with its own scope", async ({ page }) => { + // The section bar over a card's hits used to be a four-way ternary that + // predated the description and tags scopes, so hits from either were + // filed under "Transcripts" — the one place they demonstrably did not + // come from. It reads the builder's own table now, which is also why + // `tags` says "Keywords": the curated vocabulary is what "Tags" means. + const tree: SGroup = { + k: "g", + o: "OR", + c: [ + { k: "l", q: "zebra", s: "description" }, + { k: "l", q: "gaming", s: "tags" }, + ], + }; + await page.goto(`/?qt=${qt(tree)}`); + await expectResultSlugs(page, [TRANSCRIPT_ONLY_SLUG, CHAT_LARGE_SLUG]); + + await expect( + page.locator( + `[data-result-slug="${TRANSCRIPT_ONLY_SLUG}"] [data-leaf-section]`, + ), + ).toContainText("Description"); + await expect( + page.locator( + `[data-result-slug="${CHAT_LARGE_SLUG}"] [data-leaf-section]`, + ), + ).toContainText("Keywords"); + // Neither says Transcripts, which is what the old chain said for both. + // textContent, not innerText: the bar is CSS-uppercased. + const sections = page.locator("[data-leaf-section]"); + await expect(sections).toHaveCount(2); + expect((await sections.allTextContents()).join(" ")).not.toContain( + "Transcripts", + ); + }); + test("legacy URL: ?q=alpha auto-migrates to a single-leaf transcripts query", async ({ page, }) => { diff --git a/export/e2e/tag-chips.spec.ts b/export/e2e/tag-chips.spec.ts @@ -50,6 +50,21 @@ function chip(page: Page, id: string) { return page.locator(`[data-testid="tag-chip"][data-tag-id="${id}"]`); } +function tagGroup(page: Page, groupId: string) { + return page.locator( + `[data-testid="tag-chip-group"][data-group-id="${groupId}"]`, + ); +} + +// Fold/unfold by clicking the group label in its summary — the same gesture +// the channel group chips take (channel-group-chips.spec.ts). +async function collapseTagGroup(page: Page, groupId: string, label: string) { + await tagGroup(page, groupId) + .locator("summary") + .getByText(label, { exact: true }) + .click(); +} + function card(page: Page, id: string) { return page.locator(`[data-result-slug="${slugOf(id)}"]`); } @@ -90,6 +105,44 @@ test.describe("curated tag chips", () => { await expect(chip(page, TAG_COLLAB)).toHaveAttribute("aria-pressed", "false"); }); + test("a tag group collapses and its selection count survives on the summary", async ({ + page, + }) => { + // A site can publish several groups of several chips each, and on a + // phone that row is the whole viewport before a single result. Each + // group is a <details>, open by default — folding one away must not + // hide that it is still narrowing the list, so the count moves to the + // summary. + const group = tagGroup(page, "eva"); + await expect(group).toHaveAttribute("open", ""); + await expect(chip(page, TAG_COLLAB)).toBeVisible(); + // Nothing selected: no count on the summary, not a zero. + await expect(group.getByTestId("tag-group-selected")).toHaveCount(0); + + await chip(page, TAG_COLLAB).click(); + await expect(group.getByTestId("tag-group-selected")).toHaveText( + "1 selected", + ); + + await collapseTagGroup(page, "eva", "Eva"); + await expect(group).not.toHaveAttribute("open"); + await expect(chip(page, TAG_COLLAB)).toBeHidden(); + // Still visible, still saying what it is doing to the results. + await expect(group.getByTestId("tag-group-selected")).toHaveText( + "1 selected", + ); + + // ...and the selection survives the fold, chip state included. + await collapseTagGroup(page, "eva", "Eva"); + await expect(group).toHaveAttribute("open", ""); + await expect(chip(page, TAG_COLLAB)).toHaveAttribute( + "aria-pressed", + "true", + ); + await apply(page); + await expect(card(page, VIDEO_TRANSCRIPT_ONLY)).toHaveCount(0); + }); + test("a tagged card shows its tags; an untagged one shows none", async ({ page, }) => { @@ -293,6 +346,27 @@ test.describe("curated tag chips", () => { await expect(trigger).toContainText("1"); await expect(card(page, VIDEO_TRANSCRIPT_ONLY)).toHaveCount(0); }); + + test("a group folds away inside the sheet", async ({ page }) => { + // 390px is where a multi-group row costs the most, so the fold has to + // work in the sheet and not only in the inline panel. + await installRoutes(page); + await installTagRoutes(page); + await page.goto("/"); + await waitForHydration(page); + + await page.getByTestId("filters-trigger").click(); + const group = tagGroup(page, "eva"); + await expect(chip(page, TAG_COLLAB)).toBeVisible(); + + await collapseTagGroup(page, "eva", "Eva"); + await expect(group).not.toHaveAttribute("open"); + await expect(chip(page, TAG_COLLAB)).toBeHidden(); + // The summary is still one line inside the sheet, not a torn row. + const box = await group.locator("summary").boundingBox(); + expect(box).not.toBeNull(); + expect(box!.width).toBeLessThanOrEqual(390); + }); }); test.describe("on a site that publishes none", () => { diff --git a/mcp/src/protocol.test.ts b/mcp/src/protocol.test.ts @@ -259,6 +259,7 @@ test("stdio: a site with no /tags.json says so instead of returning nothing", as }), ); assert.match(search, /No matches for "coffee"/); + assert.match(search, /posts: skipped — a tag filter was given/); assert.match(search, /publishes no \/tags\.json/); assert.match(search, /not evidence of absence/); }); @@ -309,6 +310,14 @@ test("stdio: enumerate_matches filters by tag and names the filter", async (t) = assert.match(tagged, /filters — tags: eva-collab/); // …and the complete-set line is still the honest one. assert.match(tagged, /complete set: yes/); + // The posts corpus is not part of that count and the footer says why: a post + // carries no curated tags, so a tag filter drops the corpus whole. Without + // the sentence "no post matches" and "posts were never searched" read the + // same on the wire. + assert.match( + tagged, + /posts: skipped — a tag filter was given and posts carry no curated tags \(the export UI does the same\)/, + ); }); test("stdio: prompts are served on both eras", async (t) => { diff --git a/mcp/src/search.test.ts b/mcp/src/search.test.ts @@ -1117,6 +1117,51 @@ test("posts: a posts-scope spec leaf matches post text", async () => { assert.deepEqual(r.hits.map((h) => h.videoId).sort(), ["p1", "p2"]); }); +test("posts: a tag filter takes the posts corpus out of the search", async () => { + // Curated tags live on video records, so every post fails a tag filter. + // Scanning them to drop them all costs a manifest probe and a shard read per + // posting channel — and counting them before the drop is the bug the export + // viewer had (tag-chips.spec.ts). The corpus is skipped, and the result says + // so rather than leaving a caller to read "0 posts" as "searched, unmatched". + const src = new StubSource(); + const r = await searchTranscripts(src, { + query: "zephyrpost", + filters: { ...KEEP_ALL, curatedTags: ["eva-collab"] }, + }); + assert.equal(r.total, 0); + assert.equal(r.postsScanned.skippedForTagFilter, true); + assert.equal(r.postsScanned.requested, false); + assert.equal(r.postsScanned.channels, 0, "no manifest was even probed"); +}); + +test("posts: without a tag filter the same query still searches them", async () => { + // The control: posts are silenced BY the tag filter, never by this change. + const src = new StubSource(); + const r = await searchTranscripts(src, { + query: "zephyrpost", + filters: { ...KEEP_ALL }, + }); + assert.equal(r.total, 2); + assert.equal(r.postsScanned.skippedForTagFilter, false); + assert.equal(r.postsScanned.requested, true); +}); + +test("posts: a posts-scope spec leaf under a tag filter matches nothing", async () => { + // The spec path is the other half: an explicit posts leaf carries its own + // slug set, so the default-scope exclusion above does not cover it. + const src = new StubSource(); + const root = newGroup({ + op: "AND", + children: [newLeaf({ id: "l1", query: "zephyrpost", scope: "posts" })], + }); + const channels = await src.listChannels(); + const r = await runSearchSpec(src, channels, { + tree: root, + filters: { ...KEEP_ALL, curatedTags: ["eva-collab"] }, + }); + assert.equal(r.total, 0); +}); + test("posts: findPost resolves by id and getThread returns the whole thread", async () => { const src = new StubSource(); const found = await findPost(src, "p2"); diff --git a/mcp/src/search.ts b/mcp/src/search.ts @@ -234,7 +234,16 @@ export type SearchResult = { // across 0 channel(s)" — indistinguishable from "searched everything, found // nothing". `channels` counts the channels that actually HAVE a posts index, // so 0 with `requested` true means the post corpus is empty here. - postsScanned: { requested: boolean; channels: number; pages: number }; + // `skippedForTagFilter` is the one way `requested: false` is worth saying out + // loud: a post carries no curated tags, so a tag filter drops the whole posts + // corpus rather than reporting it as searched-and-unmatched. The export UI + // makes the same call (SearchSessionContext's globalScopeSlugs). + postsScanned: { + requested: boolean; + channels: number; + pages: number; + skippedForTagFilter: boolean; + }; // What duplicate collapsing did to the count. `available: false` means this // corpus ships no duplicates.json, so no claim about mirrors can be made // either way — distinct from "checked, found none". @@ -541,8 +550,18 @@ export async function searchTranscripts( // content_types:["video"] must not be widened by a posts scope. const wantVideos = contentTypes.includes("video") && (!scopes || scopes.some((s) => s !== "posts")); - const wantPosts = + const postsAsked = contentTypes.includes("post") && (!scopes || scopes.includes("posts")); + // A tag filter takes the posts corpus out of the search entirely: curated + // tags live on VIDEO records (lib/curatedTags.ts), so every post would fail + // the filter, and scanning them to drop them all is a manifest probe and a + // shard read per posting channel spent on a foregone conclusion. Counting + // them as hits would be worse: the export viewer hit exactly that bug (see + // tag-chips.spec.ts) — rows dropped by the display fold that the header had + // already counted. + const postsSkippedForTagFilter = + postsAsked && (opts.filters?.curatedTags?.length ?? 0) > 0; + const wantPosts = postsAsked && !postsSkippedForTagFilter; // Counted apart from the video pass so "no posts index anywhere in scope" is // distinguishable from "searched the posts and found nothing". let postChannelsScanned = 0; @@ -774,6 +793,7 @@ export async function searchTranscripts( requested: wantPosts, channels: postChannelsScanned, pages: postPagesScanned, + skippedForTagFilter: postsSkippedForTagFilter, }, duplicates, truncated, @@ -1131,7 +1151,11 @@ export async function runSearchSpec( // Only run when the tree actually has a posts leaf: a video-only spec must // not pay a manifest probe per channel. Post records reuse the same evalNode // with `postText` set and no cues, so AND/OR/negate semantics are identical. - const wantsPosts = [...matchers.values()].some((m) => m.scope === "posts"); + // …and the same exclusion as the plain path: a posts leaf under a tag filter + // can only ever match nothing, because a post carries no curated tags. + const wantsPosts = + [...matchers.values()].some((m) => m.scope === "posts") && + (filters?.curatedTags?.length ?? 0) === 0; if (wantsPosts) { postsOuter: for (const ch of channels) { let pm; diff --git a/mcp/src/server.ts b/mcp/src/server.ts @@ -1366,6 +1366,14 @@ function incompletePageBanner( // `scanned 0 page(s) across 0 channel(s)`, which reads like nothing ran. function describePostsPass(result: SearchResult): string { const p = result.postsScanned; + // Said out loud, because the alternative is a caller concluding from silence + // that the posts were searched and matched nothing. + if (p.skippedForTagFilter) { + return ( + "posts: skipped — a tag filter was given and posts carry no curated " + + "tags (the export UI does the same)" + ); + } if (!p.requested) return ""; if (p.channels === 0) { return (