commit 8667b5a6fa2f9057109e0afdcffbd0ff8ffd0f83
parent 5b8456aff59dd3ee8c4c599d4cc4416ee9f3963f
Author: I Mean I'm Just Saying <imeanimjustsaying@kiwifarms.st>
Date: Tue, 22 Sep 2026 16:13:51 -0400
mcp: a tag filter takes the posts corpus out of the search
Curated tags are assigned to VIDEO records, so every post fails a tag
filter — but both search paths were still scanning the post corpus to
drop it, a manifest probe and a shard read per posting channel spent on
a foregone conclusion. The export viewer already answers this way
(SearchSessionContext's globalScopeSlugs), and it learned the harder
version of the lesson: counting rows that a later fold discards gives a
header nothing on the page accounts for.
Skipping silently would be its own trap — "no post matched" and "posts
were never searched" read identically on the wire — so postsScanned
carries `skippedForTagFilter` and the footer of both search_transcripts
and enumerate_matches says which one happened.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Diffstat:
4 files changed, 89 insertions(+), 3 deletions(-)
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 (