From 61319ef5f8562223655c5d80a16070a173c4eb34 Mon Sep 17 00:00:00 2001 From: Dylan Trotter Date: Sat, 26 Sep 2026 17:37:57 +0000 Subject: [PATCH 1/2] Report a briefless thread as absent and stop backfilling dormant ones MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The sweep enqueued every briefless thread on every pass, with nothing to stop it: the first time a key is configured that is an unbounded burst across every thread in the list, and a thread whose summary keeps failing is retried every ten minutes forever. Bound it. A briefless thread is given its first brief only if it was active inside a 24h window; anything dormant longer stays briefless. Briefs are not backfilled, which is the intended behaviour, so the UI has to say so. `getBrief` therefore distinguishes work that is genuinely pending — debounced, queued, or in flight — from a thread that has no brief and is not getting one. The first is `summarizing`, the second is the new `absent`, which the popover renders as "No brief for this thread yet" plus a Summarize now button rather than a spinner that would never resolve. A failed summary drops back to absent and announces, so the UI stops claiming a summary is on its way. Co-Authored-By: Claude Opus 5 (1M context) --- plugins/thread-briefs/README.md | 5 + plugins/thread-briefs/app.test.tsx | 17 +++ plugins/thread-briefs/app.tsx | 18 +++ plugins/thread-briefs/contract.ts | 10 +- plugins/thread-briefs/server.test.ts | 111 ++++++++++++++++++ plugins/thread-briefs/server.ts | 45 +++++-- .../skills/thread-briefs/SKILL.md | 14 +++ 7 files changed, 211 insertions(+), 9 deletions(-) diff --git a/plugins/thread-briefs/README.md b/plugins/thread-briefs/README.md index 14aba71..8846933 100644 --- a/plugins/thread-briefs/README.md +++ b/plugins/thread-briefs/README.md @@ -35,6 +35,11 @@ unsent-draft pencil and displacing that everywhere would cost more than it says. are skipped), the derived status, a stage control for the manual override, and Re-summarize. It works the same on mobile and desktop; nothing depends on hover. +Briefs are **not backfilled**: a thread dormant for more than a day when the +plugin arrives stays briefless, and the popover offers **Summarize now** instead +of a spinner that would never resolve. The alternative — summarizing every old +thread — is an unbounded burst the first time a key is configured. + ## How it is built | Concern | Mechanism | diff --git a/plugins/thread-briefs/app.test.tsx b/plugins/thread-briefs/app.test.tsx index 7eb212d..dfe4c6e 100644 --- a/plugins/thread-briefs/app.test.tsx +++ b/plugins/thread-briefs/app.test.tsx @@ -104,6 +104,23 @@ describe("the header popover", () => { slot.lifecycle.unmount(); }); + it("offers to summarize a thread that has no brief, rather than spinning", async () => { + const slot = await render({ getBrief: () => ({ state: "absent" }) }); + fireEvent.click(await slot.findByRole("button", { name: "Thread brief" })); + + expect(await slot.findByText("No brief for this thread yet.")).toBeTruthy(); + // A dormant thread is never backfilled, so "Summarizing…" would never resolve. + expect(slot.queryByText("Summarizing…")).toBeNull(); + + fireEvent.click(await slot.findByRole("button", { name: "Summarize now" })); + await waitFor(() => + expect( + slot.inspection.rpcCalls.some((call) => call.method === "refresh"), + ).toBe(true), + ); + slot.lifecycle.unmount(); + }); + it("surfaces the unconfigured message instead of an empty brief", async () => { const slot = await render({ getBrief: () => ({ state: "unconfigured", message: "Add an API key." }), diff --git a/plugins/thread-briefs/app.tsx b/plugins/thread-briefs/app.tsx index 6d3d826..353006d 100644 --- a/plugins/thread-briefs/app.tsx +++ b/plugins/thread-briefs/app.tsx @@ -169,6 +169,24 @@ function BriefBody({ if (state.state === "summarizing") { return
Summarizing…
; } + if (state.state === "absent") { + // Threads that were already dormant when the plugin arrived are not + // backfilled, so say so and offer to make one rather than spinning. + return ( +
+
+ No brief for this thread yet. +
+ +
+ ); + } if (state.state === "unconfigured" || state.state === "error") { return
{state.message}
; } diff --git a/plugins/thread-briefs/contract.ts b/plugins/thread-briefs/contract.ts index e4410f3..9d0cd97 100644 --- a/plugins/thread-briefs/contract.ts +++ b/plugins/thread-briefs/contract.ts @@ -98,12 +98,18 @@ export const resolvedBriefSchema = briefFieldsSchema export type ResolvedBrief = z.infer; /** - * What the frontend sees for one thread. `summarizing` covers both "never - * summarized" and "queued for a refresh", which the UI renders the same way. + * What the frontend sees for one thread. + * + * `summarizing` means work is genuinely pending — debounced, queued, or in + * flight. `absent` means there is no brief and none is coming, which is the + * normal state for a thread that was already dormant when the plugin arrived: + * briefs are not backfilled, so the UI offers to make one on demand rather + * than claiming a summary is on its way. */ export const briefStateSchema = z.discriminatedUnion("state", [ z.object({ state: z.literal("ready"), brief: resolvedBriefSchema }).strict(), z.object({ state: z.literal("summarizing") }).strict(), + z.object({ state: z.literal("absent") }).strict(), z.object({ state: z.literal("unconfigured"), message: z.string() }).strict(), z.object({ state: z.literal("error"), message: z.string() }).strict(), ]); diff --git a/plugins/thread-briefs/server.test.ts b/plugins/thread-briefs/server.test.ts index c3a9cf4..9b63851 100644 --- a/plugins/thread-briefs/server.test.ts +++ b/plugins/thread-briefs/server.test.ts @@ -183,6 +183,117 @@ describe("summarizing", () => { expect(fetchMock).toHaveBeenCalledTimes(1); }); + it("reports absent, not summarizing, for a thread with no brief and none queued", async () => { + current = host({ fetch: fakeCompletion(SUMMARY) }); + await plugin(current.bb); + + const state = (await current.harness.behavior.callRpc("getBrief", { + threadId: "thr_1", + })) as BriefState; + // Saying "summarizing" here would be a lie the UI could never resolve. + expect(state.state).toBe("absent"); + }); + + it("reports summarizing only while work is actually pending", async () => { + current = host({ fetch: fakeCompletion(SUMMARY) }); + await plugin(current.bb); + + // A queued refresh is genuinely pending. + await current.harness.behavior.callRpc("refresh", { threadId: "thr_2" }); + const pending = (await current.harness.behavior.callRpc("getBrief", { + threadId: "thr_2", + })) as BriefState; + expect(["summarizing", "ready"]).toContain(pending.state); + }); + + it("does not backfill a thread dormant longer than the backfill window", async () => { + const fetchMock = fakeCompletion(SUMMARY); + const stale = makeThreadResponse({ + id: "thr_old", + title: "Ancient", + visibility: "visible", + status: "idle", + // Two days idle: past the 24h backfill window. + updatedAt: Date.now() - 2 * 24 * 60 * 60 * 1000, + }); + globalThis.fetch = fetchMock as unknown as typeof globalThis.fetch; + current = createFakePluginHost({ + pluginId: "thread-briefs", + settings: { apiKey: "test-key", baseUrl: "https://api.test/v1" }, + sdk: { + threads: { + get: async () => stale, + list: async () => [stale], + output: async () => ({ output: "old" }), + conversationOutline: async () => ({ + items: [ + { id: "1", role: "user", preview: "old work", attachmentSummary: null }, + ], + maxSeq: 3, + }), + interactions: { list: async () => [] }, + }, + }, + }) as typeof current; + await plugin(current!.bb); + + await current!.harness.behavior.runSchedule("brief-sweep"); + await new Promise((resolve) => setTimeout(resolve, 50)); + + expect(fetchMock).not.toHaveBeenCalled(); + const state = (await current!.harness.behavior.callRpc("getBrief", { + threadId: "thr_old", + })) as BriefState; + expect(state.state).toBe("absent"); + }); + + it("still backfills a thread quiet for a while but inside the window", async () => { + const fetchMock = fakeCompletion(SUMMARY); + // Ten minutes idle: past the quiet period, inside the 24h backfill window. + const recent = makeThreadResponse({ + id: "thr_recent", + title: "Recent", + visibility: "visible", + status: "idle", + updatedAt: Date.now() - 10 * 60 * 1000, + }); + globalThis.fetch = fetchMock as unknown as typeof globalThis.fetch; + current = createFakePluginHost({ + pluginId: "thread-briefs", + settings: { apiKey: "test-key", baseUrl: "https://api.test/v1" }, + sdk: { + threads: { + get: async () => recent, + list: async () => [recent], + output: async () => ({ output: "progress" }), + conversationOutline: async () => ({ + items: [ + { id: "1", role: "user", preview: "do it", attachmentSummary: null }, + ], + maxSeq: 4, + }), + interactions: { list: async () => [] }, + }, + }, + }) as typeof current; + await plugin(current!.bb); + + await current!.harness.behavior.runSchedule("brief-sweep"); + await waitFor(async () => (fetchMock.mock.calls.length > 0 ? true : null)); + expect(fetchMock).toHaveBeenCalledTimes(1); + }); + + it("leaves a thread still inside its quiet period alone", async () => { + // `thread` defaults to updatedAt = now, so the sweep must not touch it. + const fetchMock = fakeCompletion(SUMMARY); + current = host({ fetch: fetchMock }); + await plugin(current.bb); + + await current.harness.behavior.runSchedule("brief-sweep"); + await new Promise((resolve) => setTimeout(resolve, 50)); + expect(fetchMock).not.toHaveBeenCalled(); + }); + it("emits a row signal per stored brief", async () => { current = host({ fetch: fakeCompletion(SUMMARY) }); await plugin(current.bb); diff --git a/plugins/thread-briefs/server.ts b/plugins/thread-briefs/server.ts index 4fceefb..bd9d421 100644 --- a/plugins/thread-briefs/server.ts +++ b/plugins/thread-briefs/server.ts @@ -31,6 +31,12 @@ const REQUEST_TIMEOUT_MS = 60_000; const SWEEP_CRON = "*/10 * * * *"; /** Threads considered per sweep, newest first. */ const SWEEP_LIMIT = 200; +/** + * How far back the sweep will reach to give a briefless thread its first + * brief. Bounds the work to threads that were active recently enough to still + * be worth summarizing; anything older is summarized on request instead. + */ +const BACKFILL_WINDOW_MS = 24 * 60 * 60 * 1000; export { rpcContract }; @@ -102,8 +108,14 @@ export default async function plugin(bb: BbPluginApi) { const queue: string[] = []; /** Threads to summarize even when the activity cursor has not moved. */ const forced = new Set(); + /** The thread the single worker is summarizing right now, if any. */ + let inFlight: string | null = null; let draining = false; + /** Whether a summary for this thread is genuinely pending or running. */ + const isPending = (threadId: string) => + debounces.has(threadId) || queue.includes(threadId) || inFlight === threadId; + const enqueue = (threadId: string) => { if (!queue.includes(threadId)) queue.push(threadId); void drain(); @@ -142,6 +154,7 @@ export default async function plugin(bb: BbPluginApi) { const threadId = queue.shift(); if (threadId === undefined) break; const force = forced.delete(threadId); + inFlight = threadId; try { const changed = await summarizeThread(threadId, force); if (changed) announce(); @@ -151,6 +164,11 @@ export default async function plugin(bb: BbPluginApi) { error instanceof Error ? error.message : String(error) }`, ); + } finally { + inFlight = null; + // The thread drops back to `absent` on failure, so the UI stops + // saying "summarizing" and offers an explicit retry instead. + announce(); } } } finally { @@ -274,7 +292,10 @@ export default async function plugin(bb: BbPluginApi) { message: "Add an API key in this plugin's settings to generate briefs.", }; } - return { state: "summarizing" }; + // Only claim a summary is coming when one actually is. A thread that was + // already dormant when the plugin arrived is never backfilled, so it sits + // at `absent` until someone asks for a brief. + return isPending(threadId) ? { state: "summarizing" } : { state: "absent" }; } return { state: "ready", @@ -365,7 +386,9 @@ export default async function plugin(bb: BbPluginApi) { if (typeof values.apiKey !== "string" || values.apiKey.trim() === "") return; const threads = await bb.sdk.threads.list({ limit: SWEEP_LIMIT }); - const quietBefore = Date.now() - Math.max(1, values.quietSeconds) * 1000; + const now = Date.now(); + const quietBefore = now - Math.max(1, values.quietSeconds) * 1000; + const backfillAfter = now - BACKFILL_WINDOW_MS; for (const thread of threads) { if (thread.visibility === "hidden") continue; @@ -375,12 +398,20 @@ export default async function plugin(bb: BbPluginApi) { if (debounces.has(thread.id) || queue.includes(thread.id)) continue; const stored = await readBrief(thread.id); - // A thread with no brief yet, or one whose timestamp predates its last - // activity, is a candidate. `summarizeThread` re-checks the real cursor - // before spending a request. - if (stored === null || stored.lastSummarizedAt < thread.updatedAt) { - enqueue(thread.id); + if (stored === null) { + // No brief and no backfill: a thread already dormant when the plugin + // arrived stays briefless until someone asks, and the UI reports that + // honestly. Without this bound, every briefless thread would be + // re-enqueued on every sweep forever — an unbounded burst the first + // time a key is configured, and an endless retry for any thread whose + // summary keeps failing. + if (thread.updatedAt >= backfillAfter) enqueue(thread.id); + continue; } + // A stored brief older than the thread's last activity means activity we + // missed. `summarizeThread` re-checks the real cursor before spending a + // request. + if (stored.lastSummarizedAt < thread.updatedAt) enqueue(thread.id); } }); diff --git a/plugins/thread-briefs/skills/thread-briefs/SKILL.md b/plugins/thread-briefs/skills/thread-briefs/SKILL.md index 4aaca8c..c1b0472 100644 --- a/plugins/thread-briefs/skills/thread-briefs/SKILL.md +++ b/plugins/thread-briefs/skills/thread-briefs/SKILL.md @@ -41,6 +41,18 @@ Re-summarize is available in the header popover; it bypasses the debounce. Hidden threads (plugin workers) and deleted threads never get briefs. +**Briefs are not backfilled.** The sweep will give a briefless thread its first +brief only if it was active in the last 24 hours (`BACKFILL_WINDOW_MS`). +Anything dormant longer stays briefless and the header popover says so, with a +**Summarize now** button. This is deliberate: without the bound, every briefless +thread would be re-enqueued on every sweep forever — an unbounded burst of +requests the first time a key is configured, and an endless retry for any thread +whose summary keeps failing. + +So a thread reports `summarizing` only while work is genuinely debounced, +queued, or in flight; otherwise it reports `absent`, which the UI renders as an +offer rather than a spinner. A failed summary drops back to `absent`. + ## Stage and status `stage` is a semantic judgement from the transcript: discovery, planning, @@ -85,3 +97,5 @@ trip to stay current. `experimental_setThreadRowStatus`, which the content script feature-detects. - Briefs stuck on "Summarizing…": check `apiKey` is set and `bb plugin logs thread-briefs` for HTTP errors from `baseUrl`. +- "No brief for this thread yet" on an old thread is expected, not a fault — + briefs are not backfilled past the 24h window. Use **Summarize now**. From 1cd9d336094a457f9ce64adef3247158accec6de Mon Sep 17 00:00:00 2001 From: Dylan Trotter Date: Sat, 26 Sep 2026 18:14:24 +0000 Subject: [PATCH 2/2] Earn a first brief through activity, not a time window MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The 24h backfill window was an arbitrary cutoff that still summarized every briefless thread touched in the last day — a burst on first configure, just a smaller one. The rule wanted is simpler: a thread earns its first brief through activity. So the sweep never creates a first brief for a thread whose last activity predates this plugin load. Past that point its only job for a briefless thread is activity whose `thread.idle` we should have seen and missed. A thread dormant since before we started stays briefless at no cost, however old, and gets a brief the moment it is worked on again. Drops BACKFILL_WINDOW_MS; the cutoff is the load time we already know. Co-Authored-By: Claude Opus 5 (1M context) --- plugins/thread-briefs/README.md | 10 ++- plugins/thread-briefs/server.test.ts | 78 ++++++++++++++++--- plugins/thread-briefs/server.ts | 34 ++++---- .../skills/thread-briefs/SKILL.md | 24 +++--- 4 files changed, 106 insertions(+), 40 deletions(-) diff --git a/plugins/thread-briefs/README.md b/plugins/thread-briefs/README.md index 8846933..de913ef 100644 --- a/plugins/thread-briefs/README.md +++ b/plugins/thread-briefs/README.md @@ -35,10 +35,12 @@ unsent-draft pencil and displacing that everywhere would cost more than it says. are skipped), the derived status, a stage control for the manual override, and Re-summarize. It works the same on mobile and desktop; nothing depends on hover. -Briefs are **not backfilled**: a thread dormant for more than a day when the -plugin arrives stays briefless, and the popover offers **Summarize now** instead -of a spinner that would never resolve. The alternative — summarizing every old -thread — is an unbounded burst the first time a key is configured. +Briefs are **never backfilled** — activity earns a brief. A thread that has been +dormant since before the plugin started stays briefless, and the popover says so +with **Summarize now** rather than showing a spinner that would never resolve. +Work on it again and it gets a brief like any other thread. The alternative — +summarizing every existing thread — is an unbounded burst the first time a key is +configured. ## How it is built diff --git a/plugins/thread-briefs/server.test.ts b/plugins/thread-briefs/server.test.ts index 9b63851..8882407 100644 --- a/plugins/thread-briefs/server.test.ts +++ b/plugins/thread-briefs/server.test.ts @@ -206,14 +206,14 @@ describe("summarizing", () => { expect(["summarizing", "ready"]).toContain(pending.state); }); - it("does not backfill a thread dormant longer than the backfill window", async () => { + it("never backfills a thread whose last activity predates this load", async () => { const fetchMock = fakeCompletion(SUMMARY); const stale = makeThreadResponse({ id: "thr_old", title: "Ancient", visibility: "visible", status: "idle", - // Two days idle: past the 24h backfill window. + // Last touched well before the plugin started: no activity to react to. updatedAt: Date.now() - 2 * 24 * 60 * 60 * 1000, }); globalThis.fetch = fetchMock as unknown as typeof globalThis.fetch; @@ -247,24 +247,72 @@ describe("summarizing", () => { expect(state.state).toBe("absent"); }); - it("still backfills a thread quiet for a while but inside the window", async () => { + it("gives a long-dormant thread a brief once it sees activity", async () => { + // The whole point of not backfilling: dormant costs nothing, and the next + // turn is what earns a brief. const fetchMock = fakeCompletion(SUMMARY); - // Ten minutes idle: past the quiet period, inside the 24h backfill window. - const recent = makeThreadResponse({ - id: "thr_recent", - title: "Recent", + const old = makeThreadResponse({ + id: "thr_1", + title: "Dormant for months", visibility: "visible", status: "idle", - updatedAt: Date.now() - 10 * 60 * 1000, + updatedAt: Date.now() - 90 * 24 * 60 * 60 * 1000, }); globalThis.fetch = fetchMock as unknown as typeof globalThis.fetch; current = createFakePluginHost({ pluginId: "thread-briefs", - settings: { apiKey: "test-key", baseUrl: "https://api.test/v1" }, + settings: { apiKey: "test-key", baseUrl: "https://api.test/v1", quietSeconds: 1 }, sdk: { threads: { - get: async () => recent, - list: async () => [recent], + get: async () => old, + list: async () => [old], + output: async () => ({ output: "Picked this back up." }), + conversationOutline: async () => ({ + items: [ + { id: "1", role: "user", preview: "resume this", attachmentSummary: null }, + ], + maxSeq: 9, + }), + interactions: { list: async () => [] }, + }, + }, + }) as typeof current; + await plugin(current!.bb); + + // Nothing from the sweep, because nothing has happened. + await current!.harness.behavior.runSchedule("brief-sweep"); + await new Promise((resolve) => setTimeout(resolve, 50)); + expect(fetchMock).not.toHaveBeenCalled(); + + // Now the thread is worked on again. + await current!.harness.behavior.emitThreadEvent("thread.idle", { + thread: old, + lastAssistantText: "Picked this back up.", + }); + const state = await waitFor(async () => { + const result = (await current!.harness.behavior.callRpc("getBrief", { + threadId: "thr_1", + })) as BriefState; + return result.state === "ready" ? result : null; + }); + if (state.state !== "ready") throw new Error("unreachable"); + expect(state.brief.goal).toBe(SUMMARY.goal); + }); + + it("catches a briefless thread whose activity postdates this load", async () => { + // The sweep's one job for a briefless thread: activity that happened while + // we were running, whose `thread.idle` we apparently missed. + const fetchMock = fakeCompletion(SUMMARY); + let sweepThread = makeThreadResponse({ id: "thr_live", status: "idle" }); + globalThis.fetch = fetchMock as unknown as typeof globalThis.fetch; + current = createFakePluginHost({ + pluginId: "thread-briefs", + // A 1s quiet period keeps the test fast. + settings: { apiKey: "test-key", baseUrl: "https://api.test/v1", quietSeconds: 1 }, + sdk: { + threads: { + get: async () => sweepThread, + list: async () => [sweepThread], output: async () => ({ output: "progress" }), conversationOutline: async () => ({ items: [ @@ -278,6 +326,14 @@ describe("summarizing", () => { }) as typeof current; await plugin(current!.bb); + // Activity just after load, then let the quiet period elapse. + sweepThread = makeThreadResponse({ + id: "thr_live", + status: "idle", + updatedAt: Date.now() + 5, + }); + await new Promise((resolve) => setTimeout(resolve, 1200)); + await current!.harness.behavior.runSchedule("brief-sweep"); await waitFor(async () => (fetchMock.mock.calls.length > 0 ? true : null)); expect(fetchMock).toHaveBeenCalledTimes(1); diff --git a/plugins/thread-briefs/server.ts b/plugins/thread-briefs/server.ts index bd9d421..2150ad7 100644 --- a/plugins/thread-briefs/server.ts +++ b/plugins/thread-briefs/server.ts @@ -31,12 +31,6 @@ const REQUEST_TIMEOUT_MS = 60_000; const SWEEP_CRON = "*/10 * * * *"; /** Threads considered per sweep, newest first. */ const SWEEP_LIMIT = 200; -/** - * How far back the sweep will reach to give a briefless thread its first - * brief. Bounds the work to threads that were active recently enough to still - * be worth summarizing; anything older is summarized on request instead. - */ -const BACKFILL_WINDOW_MS = 24 * 60 * 60 * 1000; export { rpcContract }; @@ -102,6 +96,12 @@ export default async function plugin(bb: BbPluginApi) { // ------------------------------------------------------------ queue/timers const lifetime = new AbortController(); + /** + * When this plugin generation started. The cutoff for "has there been + * activity?": threads that last moved before we were running are not + * backfilled. + */ + const loadedAt = Date.now(); /** Per-thread debounce timers: the thread must stay quiet to be summarized. */ const debounces = new Map>(); /** Threads waiting for the single worker, in arrival order. */ @@ -386,9 +386,7 @@ export default async function plugin(bb: BbPluginApi) { if (typeof values.apiKey !== "string" || values.apiKey.trim() === "") return; const threads = await bb.sdk.threads.list({ limit: SWEEP_LIMIT }); - const now = Date.now(); - const quietBefore = now - Math.max(1, values.quietSeconds) * 1000; - const backfillAfter = now - BACKFILL_WINDOW_MS; + const quietBefore = Date.now() - Math.max(1, values.quietSeconds) * 1000; for (const thread of threads) { if (thread.visibility === "hidden") continue; @@ -399,13 +397,17 @@ export default async function plugin(bb: BbPluginApi) { const stored = await readBrief(thread.id); if (stored === null) { - // No brief and no backfill: a thread already dormant when the plugin - // arrived stays briefless until someone asks, and the UI reports that - // honestly. Without this bound, every briefless thread would be - // re-enqueued on every sweep forever — an unbounded burst the first - // time a key is configured, and an endless retry for any thread whose - // summary keeps failing. - if (thread.updatedAt >= backfillAfter) enqueue(thread.id); + // Briefs are never backfilled. A thread gets its first brief from + // activity — `thread.idle` while we are running — so the sweep only + // considers a briefless thread whose activity postdates this load, + // which is activity whose event we should have seen and may have + // missed. Anything older stays briefless until it is next worked on. + // + // Without this bound every briefless thread would be re-enqueued on + // every sweep forever: an unbounded burst across the whole thread list + // the first time a key is configured, and an endless ten-minute retry + // for any thread whose summary keeps failing. + if (thread.updatedAt > loadedAt) enqueue(thread.id); continue; } // A stored brief older than the thread's last activity means activity we diff --git a/plugins/thread-briefs/skills/thread-briefs/SKILL.md b/plugins/thread-briefs/skills/thread-briefs/SKILL.md index c1b0472..b94d8e5 100644 --- a/plugins/thread-briefs/skills/thread-briefs/SKILL.md +++ b/plugins/thread-briefs/skills/thread-briefs/SKILL.md @@ -41,13 +41,19 @@ Re-summarize is available in the header popover; it bypasses the debounce. Hidden threads (plugin workers) and deleted threads never get briefs. -**Briefs are not backfilled.** The sweep will give a briefless thread its first -brief only if it was active in the last 24 hours (`BACKFILL_WINDOW_MS`). -Anything dormant longer stays briefless and the header popover says so, with a -**Summarize now** button. This is deliberate: without the bound, every briefless -thread would be re-enqueued on every sweep forever — an unbounded burst of -requests the first time a key is configured, and an endless retry for any thread -whose summary keeps failing. +**Briefs are never backfilled — activity earns a brief.** A thread gets its +first brief from a turn happening while the plugin is running. The sweep will +only give a *briefless* thread a first brief if its last activity postdates the +current plugin load, which is activity whose `thread.idle` should have arrived +and may have been missed. A thread that has been dormant since before the plugin +started stays briefless, however old or recent, and the header popover says so +with a **Summarize now** button. Work on it again and it gets a brief like any +other thread. + +This is deliberate: without the bound, every briefless thread would be +re-enqueued on every sweep forever — an unbounded burst of requests across the +whole thread list the first time a key is configured, and an endless retry for +any thread whose summary keeps failing. So a thread reports `summarizing` only while work is genuinely debounced, queued, or in flight; otherwise it reports `absent`, which the UI renders as an @@ -97,5 +103,5 @@ trip to stay current. `experimental_setThreadRowStatus`, which the content script feature-detects. - Briefs stuck on "Summarizing…": check `apiKey` is set and `bb plugin logs thread-briefs` for HTTP errors from `baseUrl`. -- "No brief for this thread yet" on an old thread is expected, not a fault — - briefs are not backfilled past the 24h window. Use **Summarize now**. +- "No brief for this thread yet" on an older thread is expected, not a fault — + briefs are never backfilled. Work the thread, or use **Summarize now**.