From 13bc03c2bdcf1f09ec801f2caf2f380598cd9453 Mon Sep 17 00:00:00 2001 From: Patrick Erichsen Date: Wed, 16 Sep 2026 13:33:20 -0700 Subject: [PATCH 1/2] feat: deliver complete monthly Featured digests Preserve weekly search intelligence, all 16 slots per catalog and pending editorial choices. Freeze complete reports and rendered parts while reusing the existing uncertain-send delivery owner. Refs CLAW-724. --- docs/clawhub-search-intelligence.md | 47 ++- scripts/proof-search-intelligence.ts | 89 +++-- src/clawhubSearchIntelligence/delivery.ts | 119 +++++-- src/clawhubSearchIntelligence/evidence.ts | 95 +++-- src/clawhubSearchIntelligence/monthly.ts | 289 +++++++++++++++ tests/searchIntelligenceApi.test.ts | 409 +++++++++++++++++++++- 6 files changed, 968 insertions(+), 80 deletions(-) create mode 100644 src/clawhubSearchIntelligence/monthly.ts diff --git a/docs/clawhub-search-intelligence.md b/docs/clawhub-search-intelligence.md index 99029b9..a019cf3 100644 --- a/docs/clawhub-search-intelligence.md +++ b/docs/clawhub-search-intelligence.md @@ -6,8 +6,8 @@ Companion to [CLAW-768](https://linear.app/my-openclaw/issue/CLAW-768) under ## Boundary and ownership `POST /api/clawhub-search-intelligence/weekly` accepts ClawHub's frozen -`search_intelligence_weekly_v2` digest and previously frozen `plugin_search_weekly` -digests. It uses the existing `CLAWHUB_HERMIT_TOKEN` +`search_intelligence_weekly_v4` digest and previously frozen v3, v2 and +`plugin_search_weekly` digests. It uses the existing `CLAWHUB_HERMIT_TOKEN` (fallback `CLAWHUB_BAN_APPEALS_TOKEN`) and `CLAWHUB_SITE_URL` trusted-origin configuration. Destination is `formSettings.clawhubAppealReviewChannelId`, the `maintainer-clawhub` channel. No role or user is mentioned. @@ -68,12 +68,49 @@ claimed legacy week or cause a second message for it. Deploy receiver support before enabling the v2 sender. A read-only report/dry run does not call this POST endpoint; invoking it requests delivery. +## Monthly Featured lineups (v4) + +V4 carries 16 slots per catalog. Plugins reserve eight editorial slots and use +eight deduplicated telemetry slots; skills use 16 telemetry slots. ClawHub owns +eligibility and order: installs across 30 completed UTC days, then installs in the +final seven days, then stable artifact ID. Search counts, downloads, official +status and current Featured membership do not improve that order. Editorial +reservations retain their rationale; pending entries stay pending instead of +being replaced with telemetry choices. A changed editorial revision is visibly +stale and requires a regenerated report before approval. + +Each catalog carries the exact monthly window, aggregate scan start/end, scanned +and imported row counts, and import dataset provenance. The scan is not an atomic +snapshot. Search evidence still has its separate completed-week window and query +privacy threshold. Missing adoption stays unavailable, not zero. Hermit neither +re-ranks the report nor publishes Featured selections. + +The compact Carbon layout retains every proposed ID, slot, selection basis, +30d/7d install count and rationale, metadata freshness, plus all pending reservations. +Weekly search totals, source counts, coverage and bounded company-opportunity, +official-gap and mover rows remain separate sections. The complete +wire payload remains capped at 30,000 bytes. Reports that fit use one message; +longer reports are packed deterministically into bounded parts, without dropping +selection rows or reasons. Each part uses at most 4000 text characters and 40 +components, with a dashboard link for full search links and removal details. + +V4 freezes the complete digest hash and ordered rendered-part hashes at the same +origin/week key. Each part reuses the existing message receipt, nonce and +uncertain-send reconciliation owner at a derived part key. Only all confirmed +parts yield overall success. A rejected part may retry; confirmed earlier parts +are skipped. An uncertain part must reconcile before later parts can proceed. +A changed report or rendering manifest conflicts instead of silently replacing a +partially delivered week. Frozen v1/v2/v3 payloads retain their original validation, +rendering, hashes and single-message receipts. Deploy receiver support before +sending v4. Read-only dry runs must not call the delivery endpoint. + ## Delivery state and failure semantics No migration is needed. The existing D1 `keyValue` primary key stores one receipt -per trusted origin and UTC week. The record contains a version, canonical digest -hash, delivery status, start timestamp, and confirmed Discord message ID; it -contains no query text. Reads use a `first-primary` D1 session. Atomic +per trusted origin and UTC week, plus v4 part receipts when applicable. Legacy +message records contain a version, canonical digest +hash, delivery status, start timestamp, and confirmed Discord message ID; receipts +contain no query text. Reads use a `first-primary` D1 session. Atomic `INSERT ... ON CONFLICT DO NOTHING RETURNING` and compare-and-swap updates fence concurrent requests and freeze the weekly payload. diff --git a/scripts/proof-search-intelligence.ts b/scripts/proof-search-intelligence.ts index 81711eb..47f4130 100644 --- a/scripts/proof-search-intelligence.ts +++ b/scripts/proof-search-intelligence.ts @@ -122,36 +122,57 @@ try { .bind(key) .first<{ value: string }>() const receipt = row ? JSON.parse(row.value) : null - proofStep = "read-confirmed-discord-message" - const message = receipt?.messageId - ? ((await client.rest.get( - Routes.channelMessage(channel.id, receipt.messageId) - )) as { - id: string - author: { id: string } - flags: number - components: unknown[] - mentions: unknown[] - mention_roles: unknown[] - mention_everyone: boolean - }) - : null + proofStep = "read-confirmed-discord-messages" + // V4 freezes the whole report at the weekly key; message IDs live on + // its deterministic part receipts. Older weeks keep their single receipt. + const partReceipts = + receipt?.version === 2 + ? await Promise.all( + receipt.partHashes.map(async (_hash: string, index: number) => { + const part = await proxy.env.DB.withSession("first-primary") + .prepare("SELECT value FROM keyValue WHERE key = ?") + .bind(`${key}:part:${index + 1}`) + .first<{ value: string }>() + return part ? JSON.parse(part.value) : null + }) + ) + : [receipt] + const confirmed = partReceipts.filter( + (part) => part?.status === "sent" && part.messageId + ) + const messages = await Promise.all( + confirmed.map( + async (part) => + (await client.rest.get( + Routes.channelMessage(channel.id, part.messageId) + )) as { + id: string + author: { id: string } + flags: number + components: unknown[] + mentions: unknown[] + mention_roles: unknown[] + mention_everyone: boolean + } + ) + ) + const discordParts = messages.map((message) => ({ + url: `https://discord.com/channels/${channel.guild_id}/${channel.id}/${message.id}`, + botId: message.author.id, + flags: message.flags, + components: message.components, + mentionCount: message.mentions.length, + roleMentionCount: message.mention_roles.length, + mentionEveryone: message.mention_everyone + })) + const evidence = { mode: "local Hermit production handler + local persistent D1 + real Discord test bot; NOT deployed production Hermit", first: { status: first?.status, body: await first?.json() }, duplicate: { status: replay?.status, body: await replay?.json() }, receipt, - discord: message - ? { - url: `https://discord.com/channels/${channel.guild_id}/${channel.id}/${message.id}`, - botId: message.author.id, - flags: message.flags, - components: message.components, - mentionCount: message.mentions.length, - roleMentionCount: message.mention_roles.length, - mentionEveryone: message.mention_everyone - } - : null + discord: discordParts[0] ?? null, + discordParts } await writeFile( resolve(directory, "evidence.json"), @@ -163,18 +184,24 @@ try { duplicateStatus: replay?.status, delivered: receipt?.status === "sent", discordUrl: evidence.discord?.url, + discordUrls: discordParts.map((part) => part.url), evidencePath: resolve(directory, "evidence.json") }) ) if ( first?.status !== 200 || replay?.status !== 200 || - !message || - message.flags !== 32768 || - message.author.id !== botId || - message.mentions.length || - message.mention_roles.length || - message.mention_everyone + receipt?.status !== "sent" || + messages.length !== partReceipts.length || + !messages.length || + messages.some( + (message) => + message.flags !== 32768 || + message.author.id !== botId || + message.mentions.length || + message.mention_roles.length || + message.mention_everyone + ) ) process.exitCode = 1 } diff --git a/src/clawhubSearchIntelligence/delivery.ts b/src/clawhubSearchIntelligence/delivery.ts index 7ffcefd..b2c8b11 100644 --- a/src/clawhubSearchIntelligence/delivery.ts +++ b/src/clawhubSearchIntelligence/delivery.ts @@ -107,32 +107,111 @@ const findDeliveredMessage = async ( return null } +type DigestIdentity = { + kind?: string + weekStart: number + weekEnd: number + dashboardUrl: string +} +type MessageBody = ReturnType +const fingerprint = (body: MessageBody, value: string): MessageBody => ({ + ...body, + components: [ + ...(body.components ?? []), + ...(serializePayload({ + components: [new TextDisplay(`-# Report ${value}`)] + }).components ?? []) + ] +}) export const deliverWeeklyDigest = async ( client: Client, - digest: { - kind?: string - weekStart: number - weekEnd: number - dashboardUrl: string - }, - body: ReturnType + digest: DigestIdentity, + rendered: MessageBody | MessageBody[] ): Promise => { - const db = getRuntimeEnv().DB.withSession("first-primary") const key = `clawhub-search-weekly:${new URL(digest.dashboardUrl).origin}:${digest.weekStart}` const payloadHash = await hash(canonical(digest)) - if (digest.kind === "search_intelligence_weekly_v3") { - // Long candidate URLs may share a dashboard button. The full payload's - // fingerprint preserves exact report identity during uncertain-send recovery. - body = { - ...body, - components: [ - ...(body.components ?? []), - ...(serializePayload({ - components: [new TextDisplay(`-# Report ${payloadHash}`)] - }).components ?? []) - ] - } + if (!Array.isArray(rendered)) + return deliverMessage( + client, + digest, + digest.kind === "search_intelligence_weekly_v3" + ? fingerprint(rendered, payloadHash) + : rendered, + key, + payloadHash + ) + // The existing weekly key freezes the whole report. Each deterministic part + // uses the same claim/reconciliation owner; a lost part never resends prior parts. + const db = getRuntimeEnv().DB.withSession("first-primary") + const startedAt = Date.now() + const partHashes = await Promise.all( + rendered.map((body) => hash(canonical(body))) + ) + const claim = { + version: 2, + hash: payloadHash, + status: "sending", + startedAt, + partHashes } + const serialized = JSON.stringify(claim) + await db + .prepare( + "INSERT INTO keyValue (key, value, createdAt, updatedAt) VALUES (?, ?, ?, ?) ON CONFLICT(key) DO NOTHING RETURNING key" + ) + .bind(key, serialized, startedAt, startedAt) + .first() + const existing = await db + .prepare("SELECT value FROM keyValue WHERE key = ?") + .bind(key) + .first<{ value: string }>() + if (!existing) return response({ error: "Delivery state unavailable" }, 503) + const state = JSON.parse(existing.value) as typeof claim + if ( + state.hash !== payloadHash || + state.version !== 2 || + canonical(state.partHashes) !== canonical(partHashes) + ) + return response({ error: "Weekly payload conflict" }, 409) + const success = () => + response({ ok: true, delivered: true, weekEnd: digest.weekEnd }) + if (state.status === "sent") return success() + for (let index = 0; index < rendered.length; index++) { + const result = await deliverMessage( + client, + digest, + fingerprint( + rendered[index], + `${payloadHash} · ${index + 1}/${rendered.length}` + ), + `${key}:part:${index + 1}`, + payloadHash + ) + if (!result.ok) return result + } + const saved = await db + .prepare( + "UPDATE keyValue SET value = ?, updatedAt = ? WHERE key = ? AND value = ? RETURNING key" + ) + .bind( + JSON.stringify({ ...state, status: "sent" }), + Date.now(), + key, + existing.value + ) + .first() + return saved + ? success() + : response({ error: "Delivery receipt pending" }, 503) +} +const deliverMessage = async ( + client: Client, + digest: DigestIdentity, + body: MessageBody, + key: string, + payloadHash: string +): Promise => { + const db = getRuntimeEnv().DB.withSession("first-primary") const claim: Delivery = { version: 1, hash: payloadHash, diff --git a/src/clawhubSearchIntelligence/evidence.ts b/src/clawhubSearchIntelligence/evidence.ts index 62dfba2..03b9848 100644 --- a/src/clawhubSearchIntelligence/evidence.ts +++ b/src/clawhubSearchIntelligence/evidence.ts @@ -1,3 +1,10 @@ +import { + validMonthlyAdoption, + validMonthlySummary, + validMonthlyLineup, + renderMonthlyDigest, + type MonthlyDigest +} from "./monthly.js" import { Container, LinkButton, @@ -63,11 +70,11 @@ type Recommendation = { lifetimeInstalls: number | null } } -type LineupRecommendation = Omit & { +export type LineupRecommendation = Omit & { version: string | null support: Recommendation["support"] | "current-only" } -type FeaturedLineup = { +export type FeaturedLineup = { targetSize: 8 baseline: { id: string; version: string | null; featuredAt: number }[] changes: { id: string; change: "retain" | "add"; emerging: boolean }[] @@ -79,7 +86,7 @@ type FeaturedLineup = { }[] shortfall: number } -type Catalog = Pick< +export type Catalog = Pick< Digest, | "totalSearches" | "sourceCounts" @@ -119,7 +126,7 @@ type LineupCatalog = Omit & { recommendations: LineupRecommendation[] lineup: FeaturedLineup } -type LineupDigest = Omit & { +export type LineupDigest = Omit & { kind: "search_intelligence_weekly_v3" catalogs: { plugins: LineupCatalog; skills: LineupCatalog } } @@ -132,7 +139,7 @@ const period = (start: unknown, end: unknown) => nullable(end, timestamp) && (start === null || end === null || (start as number) < (end as number)) -const validAdoptionSummary = (value: unknown) => +const validAdoptionSummary = (value: unknown, monthly = false) => fields(value, [ "status", "generatedAt", @@ -142,7 +149,16 @@ const validAdoptionSummary = (value: unknown) => "rankingVersion", "totalItems", "inspectedItems", - "truncated" + "truncated", + ...(monthly + ? [ + "collectionStartedAt", + "periodStart7d", + "scannedRows", + "importedRows", + "importDatasetVersions" + ] + : []) ]) && (value.status === "available" || value.status === "unavailable") && nullable(value.generatedAt, timestamp) && @@ -161,7 +177,8 @@ const validRecommendation = ( weekStart: number, weekEnd: number, totalSearches: number, - fullLineup = false + fullLineup = false, + monthly = false ) => { if ( !fields(value, [ @@ -174,7 +191,8 @@ const validRecommendation = ( "metadataCheckedAt", "search", "adoption", - ...(fullLineup ? ["version"] : []) + ...(fullLineup ? ["version"] : []), + ...(monthly ? ["slot", "selectionBasis", "reason"] : []) ]) || value.artifactKind !== kind || !string(value.id, 256) || @@ -247,7 +265,9 @@ const validRecommendation = ( return false } const adoption = value.adoption - if (adoption !== null) { + if (adoption !== null && monthly) { + if (!validMonthlyAdoption(adoption, kind)) return false + } else if (adoption !== null) { if ( !fields(adoption, [ "source", @@ -304,7 +324,8 @@ const validRecommendation = ( const validLineup = ( value: unknown, recommendations: LineupRecommendation[], - origins: string[] + origins: string[], + monthly = false ) => { if ( !fields(value, [ @@ -312,10 +333,22 @@ const validLineup = ( "baseline", "changes", "removals", - "shortfall" + "shortfall", + ...(monthly + ? [ + "reservedSlots", + "telemetryTarget", + "pendingCount", + "telemetryShortfall", + "editorialRevision", + "currentEditorialRevision", + "staleEditorial", + "reservations" + ] + : []) ]) || - value.targetSize !== 8 || - value.shortfall !== 8 - recommendations.length || + value.targetSize !== (monthly ? 16 : 8) || + value.shortfall !== (monthly ? 16 : 8) - recommendations.length || !Array.isArray(value.baseline) || value.baseline.length > 100 || !value.baseline.every( @@ -368,7 +401,8 @@ const validLineup = ( (entry) => selected.has(entry.id) || removed.has(entry.id) ) && recommendations.every( - (entry) => entry.support !== "current-only" || baseline.has(entry.id) + (entry) => + monthly || entry.support !== "current-only" || baseline.has(entry.id) ) ) } @@ -376,7 +410,7 @@ const validLineup = ( export const parseEvidenceDigest = ( value: unknown, origins: string[] -): EvidenceDigest | LineupDigest | null => { +): EvidenceDigest | LineupDigest | MonthlyDigest | null => { if ( !fields(value, [ "kind", @@ -388,12 +422,14 @@ export const parseEvidenceDigest = ( "catalogs" ]) || (value.kind !== "search_intelligence_weekly_v2" && - value.kind !== "search_intelligence_weekly_v3") || + value.kind !== "search_intelligence_weekly_v3" && + value.kind !== "search_intelligence_weekly_v4") || !fields(value.catalogs, ["plugins", "skills"]) || new TextEncoder().encode(JSON.stringify(value)).byteLength > 30_000 ) return null - const fullLineup = value.kind === "search_intelligence_weekly_v3" + const monthly = value.kind === "search_intelligence_weekly_v4" + const fullLineup = monthly || value.kind === "search_intelligence_weekly_v3" for (const [name, kind] of [ ["plugins", "plugin"], ["skills", "skill"] @@ -413,7 +449,9 @@ export const parseEvidenceDigest = ( "recommendations", ...(fullLineup ? ["lineup"] : []) ]) || - !validAdoptionSummary(catalog.adoption) + !validAdoptionSummary(catalog.adoption, monthly) || + (monthly && + !validMonthlySummary(catalog.adoption, value.weekEnd as number)) ) return null const unscoped: Record = {} @@ -459,7 +497,7 @@ export const parseEvidenceDigest = ( if ( !summary || !Array.isArray(catalog.recommendations) || - catalog.recommendations.length > (fullLineup ? 8 : 5) || + catalog.recommendations.length > (monthly ? 16 : fullLineup ? 8 : 5) || !catalog.recommendations.every((candidate) => validRecommendation( candidate, @@ -468,7 +506,8 @@ export const parseEvidenceDigest = ( summary.weekStart, summary.weekEnd, summary.totalSearches, - fullLineup + fullLineup, + monthly ) ) || new Set(catalog.recommendations.map((candidate) => candidate.id)).size !== @@ -484,12 +523,18 @@ export const parseEvidenceDigest = ( !validLineup( catalog.lineup, catalog.recommendations as LineupRecommendation[], - origins + origins, + monthly ) ) return null + if ( + monthly && + !validMonthlyLineup(catalog.lineup, catalog.recommendations, kind) + ) + return null } - return value as unknown as EvidenceDigest | LineupDigest + return value as unknown as EvidenceDigest | LineupDigest | MonthlyDigest } const safe = (value: string) => @@ -683,7 +728,11 @@ const renderLineupDigest = (digest: LineupDigest) => { return serializePayload({ components, allowedMentions: { parse: [] } }) } -export const renderEvidenceDigest = (digest: EvidenceDigest | LineupDigest) => { +export const renderEvidenceDigest = ( + digest: EvidenceDigest | LineupDigest | MonthlyDigest +) => { + if (digest.kind === "search_intelligence_weekly_v4") + return renderMonthlyDigest(digest) if (digest.kind === "search_intelligence_weekly_v3") return renderLineupDigest(digest) const preview = ["localhost", "127.0.0.1", "[::1]"].includes( diff --git a/src/clawhubSearchIntelligence/monthly.ts b/src/clawhubSearchIntelligence/monthly.ts new file mode 100644 index 0000000..41e941f --- /dev/null +++ b/src/clawhubSearchIntelligence/monthly.ts @@ -0,0 +1,289 @@ +import { + Container, + LinkButton, + Row, + TextDisplay, + serializePayload +} from "@buape/carbon" +import { count, fields, record, string, timestamp } from "./contract.js" +import type { + Catalog, + FeaturedLineup, + LineupDigest, + LineupRecommendation +} from "./evidence.js" + +type Adoption = { + source: "package-daily-installs" | "skill-daily-installs" + rank: number + installs30d: number + installs7d: number + importedRows: number + importDatasetVersions: string[] +} +type Recommendation = Omit & { + slot: number + selectionBasis: "editorial" | "telemetry" + reason: string + adoption: Adoption | null +} +type Reservation = { + slot: number + id: string | null + name: string | null + displayName: string | null + reason: string | null + status: "ready" | "pending" + pendingReasons: string[] +} +type Lineup = Omit & { + targetSize: 16 + reservedSlots: number + telemetryTarget: number + pendingCount: number + telemetryShortfall: number + editorialRevision: number + currentEditorialRevision: number + staleEditorial: boolean + reservations: Reservation[] +} +type MonthlyCatalog = Omit & { + recommendations: Recommendation[] + lineup: Lineup + adoption: Catalog["adoption"] & { + collectionStartedAt: number + periodStart7d: number + scannedRows: number + importedRows: number + importDatasetVersions: string[] + } +} +export type MonthlyDigest = Omit & { + kind: "search_intelligence_weekly_v4" + catalogs: { plugins: MonthlyCatalog; skills: MonthlyCatalog } +} +const versions = (value: unknown) => + Array.isArray(value) && + value.length <= 100 && + value.every((entry) => string(entry, 256)) && + new Set(value).size === value.length +export const validMonthlyAdoption = ( + value: unknown, + kind: "plugin" | "skill" +) => + fields(value, [ + "source", + "rank", + "installs30d", + "installs7d", + "importedRows", + "importDatasetVersions" + ]) && + value.source === + (kind === "plugin" ? "package-daily-installs" : "skill-daily-installs") && + count(value.rank) && + value.rank > 0 && + count(value.installs30d) && + count(value.installs7d) && + value.installs30d >= value.installs7d && + count(value.importedRows) && + versions(value.importDatasetVersions) + +export const validMonthlySummary = (value: unknown, end: number) => + record(value) && + value.periodEnd === end && + value.periodStart === end - 30 * 86400000 && + value.periodStart7d === end - 7 * 86400000 && + timestamp(value.collectionStartedAt) && + (value.generatedAt === null || + (timestamp(value.generatedAt) && + value.generatedAt >= value.collectionStartedAt)) && + count(value.scannedRows) && + count(value.importedRows) && + value.importedRows <= value.scannedRows && + versions(value.importDatasetVersions) + +export const validMonthlyLineup = ( + value: unknown, + candidates: unknown[], + kind: "plugin" | "skill" +) => { + if ( + !record(value) || + value.reservedSlots !== (kind === "plugin" ? 8 : 0) || + value.telemetryTarget !== (kind === "plugin" ? 8 : 16) || + !count(value.pendingCount) || + !count(value.telemetryShortfall) || + !count(value.editorialRevision) || + !count(value.currentEditorialRevision) || + value.staleEditorial !== + (value.editorialRevision !== value.currentEditorialRevision) || + !Array.isArray(value.reservations) || + value.reservations.length !== value.reservedSlots || + !value.reservations.every( + (entry, slot) => + fields(entry, [ + "slot", + "id", + "name", + "displayName", + "reason", + "status", + "pendingReasons" + ]) && + entry.slot === slot && + (entry.id === null || string(entry.id, 256)) && + (entry.name === null || string(entry.name, 256)) && + (entry.displayName === null || string(entry.displayName, 120)) && + (entry.reason === null || string(entry.reason, 500)) && + (entry.status === "ready" || entry.status === "pending") && + Array.isArray(entry.pendingReasons) && + entry.pendingReasons.length <= 12 && + entry.pendingReasons.every((reason) => string(reason, 256)) + ) || + !candidates.every( + (entry) => + record(entry) && + count(entry.slot) && + entry.slot < 16 && + string(entry.reason, 500) && + (entry.selectionBasis === "editorial" || + entry.selectionBasis === "telemetry") + ) + ) + return false + const lineup = value as unknown as Lineup + const rows = candidates as Recommendation[] + const assigned = lineup.reservations.filter((entry) => entry.id !== null) + const telemetry = rows.filter((entry) => entry.selectionBasis === "telemetry") + return ( + new Set(assigned.map((entry) => entry.id)).size === assigned.length && + lineup.pendingCount === + lineup.reservations.filter((entry) => entry.status === "pending") + .length && + lineup.telemetryShortfall === lineup.telemetryTarget - telemetry.length && + rows.every( + (entry, index) => + (index === 0 || rows[index - 1].slot < entry.slot) && + (entry.selectionBasis === "editorial" + ? entry.slot < lineup.reservedSlots + : entry.slot >= lineup.reservedSlots && + entry.adoption !== null && + entry.adoption.installs30d > 0) + ) && + lineup.reservations.every((entry) => { + const candidate = rows.find((row) => row.slot === entry.slot) + return entry.status === "ready" + ? entry.id !== null && + entry.name !== null && + entry.reason !== null && + entry.pendingReasons.length === 0 && + candidate?.selectionBasis === "editorial" && + candidate.id === entry.id && + candidate.reason === entry.reason + : candidate === undefined && entry.pendingReasons.length > 0 + }) + ) +} + +class DashboardLink extends LinkButton { + label = "Review complete report" + constructor(public url: string) { + super() + } +} +const safe = (value: string) => + value.replace(/([\\`*_~|>\[\]()#])/g, "\\$1").replace(/@/g, "@\u200b") +const time = (value: number | null) => + value === null ? "unknown" : new Date(value).toISOString() +const day = (value: number | null) => + value === null ? "unknown" : new Date(value).toISOString().slice(0, 10) + +export const renderMonthlyDigest = (digest: MonthlyDigest) => { + const preview = ["localhost", "127.0.0.1", "[::1]"].includes( + new URL(digest.dashboardUrl).hostname + ) + const blocks: string[] = [] + for (const [name, catalog] of [ + ["Plugins", digest.catalogs.plugins], + ["Skills", digest.catalogs.skills] + ] as const) { + const { lineup, adoption } = catalog + blocks.push( + `### ${name}: ${catalog.recommendations.length}/16 ready\nInstalls: 30 completed UTC days ${day(adoption.periodStart)} – ${day(adoption.periodEnd)} (end exclusive); final 7 days from ${day(adoption.periodStart7d)}. Counts below: 30d / 7d.\n${lineup.pendingCount} pending editorial; ${lineup.telemetryShortfall} telemetry shortfall. ${lineup.removals.length} proposed removals. Editorial revision ${lineup.editorialRevision}${lineup.staleEditorial ? ` is stale (current ${lineup.currentEditorialRevision}); regenerate before approval.` : "."}\nAggregate scan ${new Date(adoption.collectionStartedAt).toISOString()} – ${adoption.generatedAt === null ? "unknown" : new Date(adoption.generatedAt).toISOString()}; ${adoption.importedRows} imported rows. ${adoption.status === "unavailable" ? "Adoption unavailable." : ""}` + ) + for (let slot = 0; slot < 16; slot++) { + const candidate = catalog.recommendations.find( + (entry) => entry.slot === slot + ) + const reservation = lineup.reservations[slot] + if (candidate) + blocks.push( + `${name} ${slot + 1}. **${safe(candidate.displayName)}** · ${safe(candidate.id)}\n${candidate.selectionBasis} · ${candidate.adoption ? `${candidate.adoption.installs30d} / ${candidate.adoption.installs7d}` : "counts unavailable"} · Metadata checked ${time(candidate.metadataCheckedAt)}\n${safe(candidate.reason)}` + ) + else if (reservation) + blocks.push( + `${name} ${slot + 1}. ${reservation.id ? safe(reservation.id) : "Unassigned"} · editorial PENDING\n${reservation.reason ? safe(reservation.reason) + "\n" : ""}${reservation.pendingReasons.map(safe).join("\n")}` + ) + } + const { coverage } = catalog + const incomplete = + coverage.dataThrough === null || + coverage.dataThrough < digest.weekEnd || + coverage.collectionStartedAt === null || + coverage.collectionStartedAt > digest.weekStart || + coverage.gapStart !== null + blocks.push( + `### ${name} weekly search context\n${catalog.totalSearches} searches · Web ${catalog.sourceCounts.clawhubWeb} · Control UI ${catalog.sourceCounts.openclawControlUi}\nData through ${time(coverage.dataThrough)}; collection started ${time(coverage.collectionStartedAt)}.${incomplete ? " Incomplete collection history." : ""}${coverage.gapStart !== null ? ` Gap ${time(coverage.gapStart)} – ${time(coverage.gapEnd)}.` : ""}\nClassification ${catalog.classificationStatus}; search metadata ${catalog.currentMetadataStatus}. Search counts do not affect monthly install rank.` + ) + for (const [title, rows] of [ + ["company opportunities", catalog.companyOpportunities], + ["official gaps", catalog.officialGaps], + ["movers", catalog.movers] + ] as const) { + blocks.push( + `**${name} ${title}**${rows.length ? "" : "\nNone qualified."}` + ) + for (const row of rows) + blocks.push( + `${safe(row.query)} (${row.scope}): ${row.searches} searches · previous ${row.previousSearches} · ${row.officialGaps} official gaps${"companyProductName" in row && row.companyProductName ? ` · ${safe(row.companyProductName)}` : ""}${"confidence" in row ? ` · ${Math.round(row.confidence * 100)}% classifier confidence` : ""}` + ) + } + } + // Retain every slot and rationale. A valid 30KB report can exceed one + // message; pack deterministically before claiming any delivery receipts. + const pages: string[] = [] + for (const block of blocks) { + const chunks = block.length <= 3400 ? [block] : block.split("\n") + for (const chunk of chunks) { + if ( + !pages.length || + pages[pages.length - 1].length + chunk.length + 1 > 3400 + ) + pages.push(chunk) + else pages[pages.length - 1] += "\n" + chunk + } + } + + const dashboardUrl = + digest.dashboardUrl.length <= 512 + ? digest.dashboardUrl + : new URL( + `/management?view=search-insights&endDay=${digest.weekEnd}`, + digest.dashboardUrl + ).href + return pages.map((page, index) => + serializePayload({ + components: [ + new Container([ + new TextDisplay( + `### ${preview ? "LOCAL PREVIEW · " : ""}ClawHub monthly Featured review · ${index + 1}/${pages.length}\nAdvisory; approval required. Search context ${day(digest.weekStart)} – ${day(digest.weekEnd)} is separate from install ranking. Full search links and removal reasons remain on the dashboard.` + ), + new TextDisplay(page), + new Row([new DashboardLink(dashboardUrl)]) + ]) + ], + allowedMentions: { parse: [] } + }) + ) +} diff --git a/tests/searchIntelligenceApi.test.ts b/tests/searchIntelligenceApi.test.ts index 4da1194..85590f1 100644 --- a/tests/searchIntelligenceApi.test.ts +++ b/tests/searchIntelligenceApi.test.ts @@ -232,6 +232,96 @@ const lineupPayload = () => { } } } +const monthlyPayload = (longReasons = false) => { + const base = evidencePayload() + const catalog = ( + source: typeof base.catalogs.plugins | typeof base.catalogs.skills + ) => { + const row = source.recommendations[0] + const plugin = row.artifactKind === "plugin" + const recommendations = Array.from({ length: 16 }, (_, slot) => ({ + ...row, + id: `${row.id}-${slot}`, + displayName: `Discovery ${row.artifactKind} ${slot}`, + url: `${row.url}-${slot}`, + version: "1.0.0", + slot, + selectionBasis: plugin && slot < 8 ? "editorial" : "telemetry", + reason: + plugin && slot < 8 + ? longReasons + ? "_".repeat(480) + : "Useful workflow." + : "Monthly install priority.", + support: "adoption-only", + search: null, + adoption: { + source: plugin ? "package-daily-installs" : "skill-daily-installs", + rank: slot + 1, + installs30d: 100 - slot, + installs7d: 30 - slot, + importedRows: 0, + importDatasetVersions: [] as string[] + } + })) + return { + ...source, + recommendations, + adoption: { + ...source.adoption, + totalItems: 16, + inspectedItems: 16, + periodStart: base.weekEnd - 30 * 86400000, + periodStart7d: base.weekStart, + collectionStartedAt: base.weekEnd, + scannedRows: 480, + importedRows: 0, + importDatasetVersions: [] as string[] + }, + lineup: { + targetSize: 16, + baseline: [] as { + id: string + version: string | null + featuredAt: number + }[], + changes: recommendations.map(({ id }) => ({ + id, + change: "add", + emerging: false + })), + removals: [], + shortfall: 0, + reservedSlots: plugin ? 8 : 0, + telemetryTarget: plugin ? 8 : 16, + pendingCount: 0, + telemetryShortfall: 0, + editorialRevision: plugin ? 1 : 0, + currentEditorialRevision: plugin ? 1 : 0, + staleEditorial: false, + reservations: plugin + ? recommendations.slice(0, 8).map((row) => ({ + slot: row.slot, + id: row.id, + name: row.id.slice(7), + displayName: row.displayName, + reason: row.reason, + status: "ready", + pendingReasons: [] as string[] + })) + : [] + } + } + } + return { + ...base, + kind: "search_intelligence_weekly_v4", + catalogs: { + plugins: catalog(base.catalogs.plugins), + skills: catalog(base.catalogs.skills) + } + } +} const links = (value: unknown): string[] => { if (!value || typeof value !== "object") return [] const row = value as { url?: string; components?: unknown[] } @@ -311,6 +401,322 @@ afterEach(() => { }) describe("ClawHub weekly search intelligence receiver", () => { + it("delivers every monthly slot and replays the immutable complete report", async () => { + const { client, posts } = setup() + const payload = monthlyPayload() + expect( + (await handleSearchIntelligenceApiRequest(request(payload), client)) + ?.status + ).toBe(200) + const rendered = posts.flatMap(({ body }) => texts(body)).join("\n") + for (const catalog of Object.values(payload.catalogs)) + for (const row of catalog.recommendations) { + expect(rendered).toContain(row.id) + expect(rendered).toContain( + `${row.adoption.installs30d} / ${row.adoption.installs7d}` + ) + } + for (const { body } of posts) { + expect(texts(body).join("\n").length).toBeLessThanOrEqual(4000) + expect(componentCount(body) - 1).toBeLessThanOrEqual(40) + expect(body.allowed_mentions).toEqual({ parse: [] }) + } + const count = posts.length + expect( + (await handleSearchIntelligenceApiRequest(request(payload), client)) + ?.status + ).toBe(200) + expect(posts).toHaveLength(count) + }) + + it("preserves weekly search totals, coverage and every existing nonempty section in v4", async () => { + const { client, posts } = setup() + const payload = monthlyPayload() + expect( + (await handleSearchIntelligenceApiRequest(request(payload), client)) + ?.status + ).toBe(200) + const rendered = posts.flatMap(({ body }) => texts(body)).join("\n") + for (const title of ["company opportunities", "official gaps", "movers"]) { + expect(rendered).toContain(`Plugins ${title}`) + expect(rendered).toContain(`Skills ${title}`) + } + expect(rendered).toContain("12 searches · Web 8 · Control UI 4") + expect(rendered).toContain( + "notion (catalog): 5 searches · previous 3 · 5 official gaps" + ) + expect(rendered).toContain("collection started 2026-08-01") + expect(rendered).toContain("Metadata checked 2026-09-07") + expect(rendered).toContain( + payload.catalogs.skills.recommendations[0].displayName + ) + expect(rendered).toContain("None qualified.") + for (const { body } of posts) + expect(texts(body).join("\n").length).toBeLessThanOrEqual(4000) + }) + + it("keeps pending editorial slots, full rationales and unavailable counts distinct", async () => { + const { client, posts } = setup() + const payload = monthlyPayload() + const plugins = payload.catalogs.plugins + const pending = plugins.lineup.reservations[3] + pending.status = "pending" + pending.reason = "_".repeat(500) + pending.pendingReasons = Array.from( + { length: 12 }, + (_, i) => `Reason ${i} ` + "_".repeat(230) + ) + plugins.recommendations = plugins.recommendations.filter( + ({ slot }) => slot !== 3 + ) + plugins.lineup.changes = plugins.lineup.changes.filter( + ({ id }) => id !== pending.id + ) + plugins.lineup.pendingCount = 1 + plugins.lineup.shortfall = 1 + plugins.lineup.currentEditorialRevision++ + plugins.lineup.staleEditorial = true + expect( + (await handleSearchIntelligenceApiRequest(request(payload), client)) + ?.status + ).toBe(200) + const rendered = posts.flatMap(({ body }) => texts(body)).join("\n") + expect(rendered).toContain(`${pending.id} · editorial PENDING`) + expect(rendered).toContain("1 pending editorial; 0 telemetry shortfall") + expect(rendered).toContain("regenerate before approval") + for (let i = 0; i < 12; i++) expect(rendered).toContain(`Reason ${i}`) + for (const { body } of posts) + expect(texts(body).join("\n").length).toBeLessThanOrEqual(4000) + }) + it("rejects monthly privacy, source, slot and period violations before durable claims", async () => { + const { client, owner, posts } = setup() + const mutations: ((p: ReturnType) => void)[] = [ + (p) => { + Object.assign(p.catalogs.plugins.recommendations[0].adoption, { + userId: "private" + }) + }, + (p) => { + p.catalogs.skills.recommendations[0].adoption.source = + "package-daily-installs" + }, + (p) => { + p.catalogs.plugins.adoption.periodStart++ + }, + (p) => { + p.catalogs.skills.adoption.periodStart7d++ + }, + (p) => { + p.catalogs.plugins.adoption.importedRows = 481 + }, + (p) => { + p.catalogs.skills.recommendations[0].adoption.installs7d = 101 + }, + (p) => { + p.catalogs.skills.recommendations[0].slot = 1 + }, + (p) => { + p.catalogs.plugins.lineup.reservations[0].id = "plugin:different" + }, + (p) => { + p.catalogs.plugins.lineup.reservations[0].status = "pending" + }, + (p) => { + p.catalogs.plugins.lineup.reservations[0].reason = "Different reason" + }, + (p) => { + p.catalogs.skills.recommendations[0].selectionBasis = "editorial" + }, + (p) => { + p.catalogs.plugins.lineup.staleEditorial = true + }, + (p) => { + p.catalogs.plugins.lineup.telemetryShortfall = 1 + }, + (p) => { + p.catalogs.skills.recommendations[0].adoption.installs30d = 0 + }, + (p) => { + Object.assign(p.catalogs.plugins.recommendations[0], { + support: "both", + search: { + ...evidencePayload().catalogs.plugins.recommendations[0].search, + queries: [ + { + query: "rare", + scope: "catalog", + searches7d: 2, + previous7d: 0, + searches30d: 2 + } + ] + } + }) + } + ] + for (const mutate of mutations) { + const payload = monthlyPayload() + mutate(payload) + expect( + (await handleSearchIntelligenceApiRequest(request(payload), client)) + ?.status + ).toBe(400) + } + expect(posts).toHaveLength(0) + expect( + owner.database.query("SELECT count(*) AS count FROM keyValue").get() + ).toEqual({ count: 0 }) + }) + it("serializes concurrent monthly deliveries and freezes the complete report before its first part", async () => { + const { client, posts } = setup() + const original = client.rest.post.bind(client.rest) + let release!: () => void + let entered!: () => void + const ready = new Promise((resolve) => { + entered = resolve + }) + const gate = new Promise((resolve) => { + release = resolve + }) + client.rest.post = (async (...args: Parameters) => { + entered() + await gate + return original(...args) + }) as typeof client.rest.post + const payload = monthlyPayload(true) + const first = handleSearchIntelligenceApiRequest(request(payload), client) + await ready + expect( + (await handleSearchIntelligenceApiRequest(request(payload), client)) + ?.status + ).toBe(409) + const changed = monthlyPayload(true) + changed.catalogs.skills.recommendations[15].reason = "Changed final item" + expect( + (await handleSearchIntelligenceApiRequest(request(changed), client)) + ?.status + ).toBe(409) + release() + expect((await first)?.status).toBe(200) + const nonces = posts.map(({ body }) => body.nonce) + expect(new Set(nonces).size).toBe(nonces.length) + expect(nonces.length).toBeGreaterThan(1) + }) + it("retries only a rejected monthly part and never reposts confirmed earlier parts", async () => { + const { client, posts } = setup() + const original = client.rest.post.bind(client.rest) + let calls = 0 + client.rest.post = (async (...args: Parameters) => { + if (++calls === 2) + throw Object.assign(new Error("Rejected"), { status: 429 }) + return original(...args) + }) as typeof client.rest.post + const payload = monthlyPayload(true) + expect( + (await handleSearchIntelligenceApiRequest(request(payload), client)) + ?.status + ).toBe(502) + expect(posts).toHaveLength(1) + const firstNonce = posts[0].body.nonce + expect( + (await handleSearchIntelligenceApiRequest(request(payload), client)) + ?.status + ).toBe(200) + expect(posts.filter(({ body }) => body.nonce === firstNonce)).toHaveLength( + 1 + ) + expect(posts.flatMap(({ body }) => texts(body)).join("\n")).toContain( + payload.catalogs.skills.recommendations[15].id + ) + }) + it("reconciles an uncertain middle monthly part before continuing the same report", async () => { + const { client, posts } = setup() + const original = client.rest.post.bind(client.rest) + let calls = 0 + client.rest.post = (async (...args: Parameters) => { + const result = await original(...args) + if (++calls === 2) throw new Error("Response lost") + return result + }) as typeof client.rest.post + const payload = monthlyPayload(true) + expect( + (await handleSearchIntelligenceApiRequest(request(payload), client)) + ?.status + ).toBe(503) + expect(posts).toHaveLength(2) + expect( + (await handleSearchIntelligenceApiRequest(request(payload), client)) + ?.status + ).toBe(503) + expect(posts).toHaveLength(2) + client.rest.get = async () => + [ + { + id: "confirmed-part-2", + author: { id: "bot-user", bot: true }, + timestamp: new Date().toISOString(), + components: posts[1].body.components + } + ] as never + expect( + (await handleSearchIntelligenceApiRequest(request(payload), client)) + ?.status + ).toBe(200) + expect(posts.length).toBeGreaterThan(2) + expect(new Set(posts.map(({ body }) => body.nonce)).size).toBe(posts.length) + }) + it("recovers a final monthly receipt write failure without repeating its confirmed parts", async () => { + const { client, posts, owner } = setup() + owner.database.exec(`CREATE TRIGGER fail_monthly_completion BEFORE UPDATE ON keyValue + WHEN json_extract(NEW.value, '$.version') = 2 AND json_extract(NEW.value, '$.status') = 'sent' + BEGIN SELECT RAISE(ABORT, 'fixture receipt unavailable'); END`) + const payload = monthlyPayload(true) + expect( + (await handleSearchIntelligenceApiRequest(request(payload), client)) + ?.status + ).toBe(503) + const count = posts.length + expect(count).toBeGreaterThan(1) + owner.database.exec("DROP TRIGGER fail_monthly_completion") + expect( + (await handleSearchIntelligenceApiRequest(request(payload), client)) + ?.status + ).toBe(200) + expect(posts).toHaveLength(count) + const receipts = owner.database + .query("SELECT value FROM keyValue") + .all() as { value: string }[] + expect( + receipts.every(({ value }) => JSON.parse(value).status === "sent") + ).toBe(true) + expect( + receipts.some(({ value }) => + value.includes(payload.catalogs.skills.recommendations[0].id) + ) + ).toBe(false) + }) + + it("preserves a sent v3 week when a monthly replacement arrives", async () => { + const { client, posts } = setup() + expect( + ( + await handleSearchIntelligenceApiRequest( + request(lineupPayload()), + client + ) + )?.status + ).toBe(200) + expect( + ( + await handleSearchIntelligenceApiRequest( + request(monthlyPayload()), + client + ) + )?.status + ).toBe(409) + expect(posts).toHaveLength(1) + }) + it("delivers and replays the complete eight-per-catalog lineup without dropping long links", async () => { const { client, posts } = setup() const payload = lineupPayload() @@ -449,7 +855,8 @@ describe("ClawHub weekly search intelligence receiver", () => { Object.assign(payload.catalogs.skills.lineup, { userId: "private" }) }, (payload) => { - payload.catalogs.plugins.recommendations[1].search!.queries[0].searches7d = 2 + payload.catalogs.plugins.recommendations[1] + .search!.queries[0].searches7d = 2 }, (payload) => { payload.catalogs.skills.recommendations[2].support = "current-only" From 99d7a44c7da8a48f55584439fb026d437a9e1227 Mon Sep 17 00:00:00 2001 From: Patrick Erichsen Date: Wed, 16 Sep 2026 13:45:05 -0700 Subject: [PATCH 2/2] fix: show monthly digest evidence limitations --- src/clawhubSearchIntelligence/monthly.ts | 6 +++- tests/searchIntelligenceApi.test.ts | 40 ++++++++++++++++++++++++ 2 files changed, 45 insertions(+), 1 deletion(-) diff --git a/src/clawhubSearchIntelligence/monthly.ts b/src/clawhubSearchIntelligence/monthly.ts index 41e941f..1334662 100644 --- a/src/clawhubSearchIntelligence/monthly.ts +++ b/src/clawhubSearchIntelligence/monthly.ts @@ -212,6 +212,10 @@ export const renderMonthlyDigest = (digest: MonthlyDigest) => { blocks.push( `### ${name}: ${catalog.recommendations.length}/16 ready\nInstalls: 30 completed UTC days ${day(adoption.periodStart)} – ${day(adoption.periodEnd)} (end exclusive); final 7 days from ${day(adoption.periodStart7d)}. Counts below: 30d / 7d.\n${lineup.pendingCount} pending editorial; ${lineup.telemetryShortfall} telemetry shortfall. ${lineup.removals.length} proposed removals. Editorial revision ${lineup.editorialRevision}${lineup.staleEditorial ? ` is stale (current ${lineup.currentEditorialRevision}); regenerate before approval.` : "."}\nAggregate scan ${new Date(adoption.collectionStartedAt).toISOString()} – ${adoption.generatedAt === null ? "unknown" : new Date(adoption.generatedAt).toISOString()}; ${adoption.importedRows} imported rows. ${adoption.status === "unavailable" ? "Adoption unavailable." : ""}` ) + if (adoption.truncated) + blocks.push( + `${name} adoption metadata limited; inspected ${adoption.inspectedItems} of ${adoption.totalItems} candidates.` + ) for (let slot = 0; slot < 16; slot++) { const candidate = catalog.recommendations.find( (entry) => entry.slot === slot @@ -277,7 +281,7 @@ export const renderMonthlyDigest = (digest: MonthlyDigest) => { components: [ new Container([ new TextDisplay( - `### ${preview ? "LOCAL PREVIEW · " : ""}ClawHub monthly Featured review · ${index + 1}/${pages.length}\nAdvisory; approval required. Search context ${day(digest.weekStart)} – ${day(digest.weekEnd)} is separate from install ranking. Full search links and removal reasons remain on the dashboard.` + `### ${preview ? "LOCAL PREVIEW · " : ""}ClawHub monthly Featured review · ${index + 1}/${pages.length}\nAdvisory; approval required. Search context ${day(digest.weekStart)} – ${day(digest.weekEnd)} is separate from install ranking. Full search links and removal reasons remain on the dashboard.${digest.truncated ? "\nSome evidence details omitted; all proposed slots retained." : ""}` ), new TextDisplay(page), new Row([new DashboardLink(dashboardUrl)]) diff --git a/tests/searchIntelligenceApi.test.ts b/tests/searchIntelligenceApi.test.ts index 85590f1..73fb6ee 100644 --- a/tests/searchIntelligenceApi.test.ts +++ b/tests/searchIntelligenceApi.test.ts @@ -455,6 +455,46 @@ describe("ClawHub weekly search intelligence receiver", () => { expect(texts(body).join("\n").length).toBeLessThanOrEqual(4000) }) + it.each([ + [false, false], + [true, false], + [false, true], + [true, true] + ])( + "shows monthly evidence limitations independently (digest %s, adoption %s)", + async (digestLimited, adoptionLimited) => { + const { client, posts } = setup() + const payload = monthlyPayload() + payload.truncated = digestLimited + Object.assign(payload.catalogs.plugins.adoption, { + totalItems: 101, + inspectedItems: 100, + truncated: adoptionLimited + }) + expect( + (await handleSearchIntelligenceApiRequest(request(payload), client)) + ?.status + ).toBe(200) + const rendered = posts.flatMap(({ body }) => texts(body)).join("\n") + expect( + rendered.includes( + "Some evidence details omitted; all proposed slots retained." + ) + ).toBe(digestLimited) + expect( + rendered.includes( + "Plugins adoption metadata limited; inspected 100 of 101 candidates." + ) + ).toBe(adoptionLimited) + expect(rendered).not.toContain("Skills adoption metadata limited") + for (const catalog of Object.values(payload.catalogs)) + for (const row of catalog.recommendations) + expect(rendered).toContain(row.id) + for (const { body } of posts) + expect(texts(body).join("\n").length).toBeLessThanOrEqual(4000) + } + ) + it("keeps pending editorial slots, full rationales and unavailable counts distinct", async () => { const { client, posts } = setup() const payload = monthlyPayload()