From bedf968b168017b2e39da703d05346ca53750379 Mon Sep 17 00:00:00 2001 From: Patrick Erichsen Date: Tue, 15 Sep 2026 12:45:05 -0700 Subject: [PATCH 1/2] feat: receive weekly plugin and skill recommendation evidence --- docs/clawhub-search-intelligence.md | 40 +- src/clawhubSearchIntelligence/api.ts | 237 +---------- src/clawhubSearchIntelligence/contract.ts | 222 ++++++++++ src/clawhubSearchIntelligence/evidence.ts | 495 ++++++++++++++++++++++ tests/searchIntelligenceApi.test.ts | 305 +++++++++++++ 5 files changed, 1070 insertions(+), 229 deletions(-) create mode 100644 src/clawhubSearchIntelligence/contract.ts create mode 100644 src/clawhubSearchIntelligence/evidence.ts diff --git a/docs/clawhub-search-intelligence.md b/docs/clawhub-search-intelligence.md index d3552b2..99029b9 100644 --- a/docs/clawhub-search-intelligence.md +++ b/docs/clawhub-search-intelligence.md @@ -6,7 +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 -`plugin_search_weekly` digest. It uses the existing `CLAWHUB_HERMIT_TOKEN` +`search_intelligence_weekly_v2` digest and previously frozen `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. @@ -39,6 +40,34 @@ dashboard pointer, never cut links or Markdown. Text is escaped, mentions are neutralized, and `allowed_mentions.parse` is empty. A localhost trusted dashboard origin produces a visible **LOCAL PREVIEW** heading. +## Plugin and skill evidence + +[CLAW-893](https://linear.app/my-openclaw/issue/CLAW-893) adds named `plugins` and +`skills` catalogs. Each carries its own company opportunities, official gaps, +movers and up to five Featured recommendations, plus search coverage and adoption +snapshot freshness. The complete v2 JSON payload is capped at 30,000 UTF-8 bytes. + +ClawHub's shared recommendation owner supplies candidate order and eligibility. +Hermit displays separate search/adoption support, counts, periods and freshness; +it never computes a combined score or changes Featured. Search-only recommendations +need at least three matched searches in the completed week. Adoption-supported +recommendations may show lower aggregate demand, but each displayed query detail +still needs three searches. Details are capped at three queries per candidate, +with an explicit omission count. Missing search evidence stays `null`. + +Current adoption snapshots may describe a different period from the completed +search week. External observations carry their own `sourceObservedAt`; unknown +source periods and unavailable metrics stay null. Lifetime counts are labeled as +lifetime counts. Search metadata failure does not erase independently hydrated +adoption evidence. Human quality, security and category-coverage review remains +required before featuring anything. + +Legacy payload validation and rendering are retained for frozen retries. Both +versions use the same origin/week receipt identity: v2 cannot replace an already +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. + ## Delivery state and failure semantics No migration is needed. The existing D1 `keyValue` primary key stores one receipt @@ -89,10 +118,11 @@ bun run test bun run deploy:dry-run ``` -Local validation: 13 focused receiver tests (105 assertions), typecheck, and -deployment dry-run pass. After installing the existing artwork suite's -ImageMagick prerequisite, the full Hermit suite passes: 300 tests across 35 -files, 184,958 assertions (114.70 seconds). No artwork source changes were needed. +Receiver tests also cover both catalogs, rare query suppression, independent +adoption hydration, frozen legacy replay, cross-version receipt collisions and +v2 response-loss recovery. Local upgrade proof can use persistent Wrangler D1 +and a mock Discord transport; that demonstrates receipt continuity, not actual +Discord delivery. The full artwork suite requires ImageMagick. Executable real-service proof (never deploys or registers commands): diff --git a/src/clawhubSearchIntelligence/api.ts b/src/clawhubSearchIntelligence/api.ts index 89a233b..8a9df09 100644 --- a/src/clawhubSearchIntelligence/api.ts +++ b/src/clawhubSearchIntelligence/api.ts @@ -11,226 +11,10 @@ import { } from "../clawhubPublisherAbuse/api.js" import { deliverWeeklyDigest } from "./delivery.js" +import { parseDigest, type Digest, type Row } from "./contract.js" +import { parseEvidenceDigest, renderEvidenceDigest } from "./evidence.js" + const apiPath = "/api/clawhub-search-intelligence/weekly" -type Row = { - query: string - searches: number - previousSearches: number - officialGaps: number - searchUrl: string -} -type Digest = { - kind: "plugin_search_weekly" - weekStart: number - weekEnd: number - minimumSearches: 3 - dashboardUrl: string - totalSearches: number - sourceCounts: { clawhubWeb: number; openclawControlUi: number } - classificationStatus: "available" | "partial" | "unavailable" - currentMetadataStatus: "available" | "unavailable" - truncated: boolean - coverage: { - dataThrough: number | null - collectionStartedAt: number | null - gapStart: number | null - gapEnd: number | null - } - companyOpportunities: (Row & { - companyProductName?: string - confidence: number - })[] - officialGaps: Row[] - featuredCandidates: (Row & { - package: { name: string; displayName: string; url: string } - })[] - movers: Row[] -} -const record = (value: unknown): value is Record => - !!value && typeof value === "object" && !Array.isArray(value) -const fields = ( - value: unknown, - required: string[], - optional: string[] = [] -): value is Record => - record(value) && - required.every((key) => Object.hasOwn(value, key)) && - Object.keys(value).every( - (key) => required.includes(key) || optional.includes(key) - ) -const count = (value: unknown): value is number => - typeof value === "number" && Number.isSafeInteger(value) && value >= 0 -const timestamp = (value: unknown): value is number => - count(value) && value <= 8_640_000_000_000_000 -const string = (value: unknown, max: number): value is string => - typeof value === "string" && - value.length > 0 && - value.trim() === value && - value.length <= max && - !/[\u0000-\u001f\u007f]/.test(value) -const validUrl = (value: unknown, origins: string[]) => { - if (!string(value, 2048)) return false - try { - const url = new URL(value) - return ( - ["http:", "https:"].includes(url.protocol) && - !url.username && - !url.password && - origins.includes(url.origin) && - url.toString().length <= 2048 - ) - } catch { - return false - } -} -const parseDigest = (value: unknown, origins: string[]): Digest | null => { - if ( - !fields(value, [ - "kind", - "weekStart", - "weekEnd", - "minimumSearches", - "dashboardUrl", - "totalSearches", - "sourceCounts", - "classificationStatus", - "currentMetadataStatus", - "truncated", - "coverage", - "companyOpportunities", - "officialGaps", - "featuredCandidates", - "movers" - ]) - ) - return null - if ( - value.kind !== "plugin_search_weekly" || - value.minimumSearches !== 3 || - !timestamp(value.weekStart) || - !timestamp(value.weekEnd) || - value.weekEnd - value.weekStart !== 604_800_000 || - value.weekEnd % 86_400_000 !== 0 || - new Date(value.weekEnd).getUTCDay() !== 1 || - !validUrl(value.dashboardUrl, origins) || - !count(value.totalSearches) || - typeof value.truncated !== "boolean" - ) - return null - if ( - typeof value.classificationStatus !== "string" || - !["available", "partial", "unavailable"].includes( - value.classificationStatus - ) || - typeof value.currentMetadataStatus !== "string" || - !["available", "unavailable"].includes(value.currentMetadataStatus) - ) - return null - const sources = value.sourceCounts - if ( - !fields(sources, ["clawhubWeb", "openclawControlUi"]) || - !count(sources["clawhubWeb"]) || - !count(sources["openclawControlUi"]) || - sources["clawhubWeb"] + sources["openclawControlUi"] !== value.totalSearches - ) - return null - const coverage = value.coverage - if ( - !fields(coverage, [ - "dataThrough", - "collectionStartedAt", - "gapStart", - "gapEnd" - ]) || - !Object.values(coverage).every( - (time) => time === null || timestamp(time) - ) || - (coverage.gapStart === null) !== (coverage.gapEnd === null) || - (typeof coverage.gapStart === "number" && - typeof coverage.gapEnd === "number" && - coverage.gapStart >= coverage.gapEnd) - ) - return null - const rowFields = [ - "query", - "searches", - "previousSearches", - "officialGaps", - "searchUrl" - ] - const validRows = ( - rows: unknown, - kind: "company" | "gap" | "featured" | "mover" - ) => - Array.isArray(rows) && - rows.length <= 5 && - rows.every((row) => { - if ( - !fields( - row, - [ - ...rowFields, - ...(kind === "company" - ? ["confidence"] - : kind === "featured" - ? ["package"] - : []) - ], - kind === "company" ? ["companyProductName"] : [] - ) || - !string(row.query, 256) || - !count(row.searches) || - row.searches > (value.totalSearches as number) || - !count(row.previousSearches) || - !count(row.officialGaps) || - row.officialGaps > row.searches || - !validUrl(row.searchUrl, origins) - ) - return false - if ( - (kind === "mover" - ? Math.max(row.searches, row.previousSearches) - : row.searches) < 3 - ) - return false - if ((kind === "company" || kind === "gap") && row.officialGaps < 3) - return false - if ( - kind === "company" && - (typeof row.confidence !== "number" || - !Number.isFinite(row.confidence) || - row.confidence < 0.8 || - row.confidence > 1 || - (row.companyProductName !== undefined && - !string(row.companyProductName, 120))) - ) - return false - if ( - kind === "featured" && - (!fields(row.package, ["name", "displayName", "url"]) || - !string(row.package.name, 160) || - !string(row.package.displayName, 120) || - !validUrl(row.package.url, origins)) - ) - return false - return true - }) - if ( - !validRows(value.companyOpportunities, "company") || - !validRows(value.officialGaps, "gap") || - !validRows(value.featuredCandidates, "featured") || - !validRows(value.movers, "mover") - ) - return null - if ( - (value.classificationStatus === "unavailable" && - (value.companyOpportunities as unknown[]).length) || - (value.currentMetadataStatus === "unavailable" && - (value.featuredCandidates as unknown[]).length) - ) - return null - return value as unknown as Digest -} // Bound bytes while consuming the stream, not after allocating an arbitrary body. const readBody = async (request: Request): Promise => { const reader = request.body?.getReader() @@ -401,14 +185,19 @@ export const handleSearchIntelligenceApiRequest = async ( error instanceof RangeError ? 413 : 400 ) } - const digest = parseDigest( - body, - publisherAbuseDigestTrustedOrigins(getRuntimeEnv()) - ) + const origins = publisherAbuseDigestTrustedOrigins(getRuntimeEnv()) + const digest = + parseEvidenceDigest(body, origins) ?? parseDigest(body, origins) if (!digest) return json({ error: "Invalid search intelligence payload" }, 400) try { - return await deliverWeeklyDigest(client, digest, render(digest)) + return await deliverWeeklyDigest( + client, + digest, + digest.kind === "plugin_search_weekly" + ? render(digest) + : renderEvidenceDigest(digest) + ) } catch { return json({ error: "Delivery state unavailable" }, 503) } diff --git a/src/clawhubSearchIntelligence/contract.ts b/src/clawhubSearchIntelligence/contract.ts new file mode 100644 index 0000000..08372bb --- /dev/null +++ b/src/clawhubSearchIntelligence/contract.ts @@ -0,0 +1,222 @@ +export type Row = { + query: string + searches: number + previousSearches: number + officialGaps: number + searchUrl: string +} +export type Digest = { + kind: "plugin_search_weekly" + weekStart: number + weekEnd: number + minimumSearches: 3 + dashboardUrl: string + totalSearches: number + sourceCounts: { clawhubWeb: number; openclawControlUi: number } + classificationStatus: "available" | "partial" | "unavailable" + currentMetadataStatus: "available" | "unavailable" + truncated: boolean + coverage: { + dataThrough: number | null + collectionStartedAt: number | null + gapStart: number | null + gapEnd: number | null + } + companyOpportunities: (Row & { + companyProductName?: string + confidence: number + })[] + officialGaps: Row[] + featuredCandidates: (Row & { + package: { name: string; displayName: string; url: string } + })[] + movers: Row[] +} +export const record = (value: unknown): value is Record => + !!value && typeof value === "object" && !Array.isArray(value) +export const fields = ( + value: unknown, + required: string[], + optional: string[] = [] +): value is Record => + record(value) && + required.every((key) => Object.hasOwn(value, key)) && + Object.keys(value).every( + (key) => required.includes(key) || optional.includes(key) + ) +export const count = (value: unknown): value is number => + typeof value === "number" && Number.isSafeInteger(value) && value >= 0 +export const timestamp = (value: unknown): value is number => + count(value) && value <= 8_640_000_000_000_000 +export const string = (value: unknown, max: number): value is string => + typeof value === "string" && + value.length > 0 && + value.trim() === value && + value.length <= max && + !/[\u0000-\u001f\u007f]/.test(value) +export const validUrl = (value: unknown, origins: string[]) => { + if (!string(value, 2048)) return false + try { + const url = new URL(value) + return ( + ["http:", "https:"].includes(url.protocol) && + !url.username && + !url.password && + origins.includes(url.origin) && + url.toString().length <= 2048 + ) + } catch { + return false + } +} +export const parseDigest = ( + value: unknown, + origins: string[] +): Digest | null => { + if ( + !fields(value, [ + "kind", + "weekStart", + "weekEnd", + "minimumSearches", + "dashboardUrl", + "totalSearches", + "sourceCounts", + "classificationStatus", + "currentMetadataStatus", + "truncated", + "coverage", + "companyOpportunities", + "officialGaps", + "featuredCandidates", + "movers" + ]) + ) + return null + if ( + value.kind !== "plugin_search_weekly" || + value.minimumSearches !== 3 || + !timestamp(value.weekStart) || + !timestamp(value.weekEnd) || + value.weekEnd - value.weekStart !== 604_800_000 || + value.weekEnd % 86_400_000 !== 0 || + new Date(value.weekEnd).getUTCDay() !== 1 || + !validUrl(value.dashboardUrl, origins) || + !count(value.totalSearches) || + typeof value.truncated !== "boolean" + ) + return null + if ( + typeof value.classificationStatus !== "string" || + !["available", "partial", "unavailable"].includes( + value.classificationStatus + ) || + typeof value.currentMetadataStatus !== "string" || + !["available", "unavailable"].includes(value.currentMetadataStatus) + ) + return null + const sources = value.sourceCounts + if ( + !fields(sources, ["clawhubWeb", "openclawControlUi"]) || + !count(sources["clawhubWeb"]) || + !count(sources["openclawControlUi"]) || + sources["clawhubWeb"] + sources["openclawControlUi"] !== value.totalSearches + ) + return null + const coverage = value.coverage + if ( + !fields(coverage, [ + "dataThrough", + "collectionStartedAt", + "gapStart", + "gapEnd" + ]) || + !Object.values(coverage).every( + (time) => time === null || timestamp(time) + ) || + (coverage.gapStart === null) !== (coverage.gapEnd === null) || + (typeof coverage.gapStart === "number" && + typeof coverage.gapEnd === "number" && + coverage.gapStart >= coverage.gapEnd) + ) + return null + const rowFields = [ + "query", + "searches", + "previousSearches", + "officialGaps", + "searchUrl" + ] + const validRows = ( + rows: unknown, + kind: "company" | "gap" | "featured" | "mover" + ) => + Array.isArray(rows) && + rows.length <= 5 && + rows.every((row) => { + if ( + !fields( + row, + [ + ...rowFields, + ...(kind === "company" + ? ["confidence"] + : kind === "featured" + ? ["package"] + : []) + ], + kind === "company" ? ["companyProductName"] : [] + ) || + !string(row.query, 256) || + !count(row.searches) || + row.searches > (value.totalSearches as number) || + !count(row.previousSearches) || + !count(row.officialGaps) || + row.officialGaps > row.searches || + !validUrl(row.searchUrl, origins) + ) + return false + if ( + (kind === "mover" + ? Math.max(row.searches, row.previousSearches) + : row.searches) < 3 + ) + return false + if ((kind === "company" || kind === "gap") && row.officialGaps < 3) + return false + if ( + kind === "company" && + (typeof row.confidence !== "number" || + !Number.isFinite(row.confidence) || + row.confidence < 0.8 || + row.confidence > 1 || + (row.companyProductName !== undefined && + !string(row.companyProductName, 120))) + ) + return false + if ( + kind === "featured" && + (!fields(row.package, ["name", "displayName", "url"]) || + !string(row.package.name, 160) || + !string(row.package.displayName, 120) || + !validUrl(row.package.url, origins)) + ) + return false + return true + }) + if ( + !validRows(value.companyOpportunities, "company") || + !validRows(value.officialGaps, "gap") || + !validRows(value.featuredCandidates, "featured") || + !validRows(value.movers, "mover") + ) + return null + if ( + (value.classificationStatus === "unavailable" && + (value.companyOpportunities as unknown[]).length) || + (value.currentMetadataStatus === "unavailable" && + (value.featuredCandidates as unknown[]).length) + ) + return null + return value as unknown as Digest +} diff --git a/src/clawhubSearchIntelligence/evidence.ts b/src/clawhubSearchIntelligence/evidence.ts new file mode 100644 index 0000000..d706486 --- /dev/null +++ b/src/clawhubSearchIntelligence/evidence.ts @@ -0,0 +1,495 @@ +import { Container, TextDisplay, serializePayload } from "@buape/carbon" +import { + count, + fields, + parseDigest, + record, + string, + timestamp, + validUrl, + type Digest, + type Row +} from "./contract.js" + +type Scope = "catalog" | "shelf" | "legacy" +type ScopedRow = Row & { scope: Scope } +type Recommendation = { + artifactKind: "plugin" | "skill" + id: string + displayName: string + url: string + category: string | null + support: "both" | "search-only" | "adoption-only" + metadataCheckedAt: number + search: null | { + matchedSearches7d: number + previous7d: number + searches30d: number + queries: { + query: string + scope: Scope + searches7d: number + previous7d: number + searches30d: number + }[] + omittedQueries: number + periodStart: number + periodEnd: number + dataThrough: number | null + collectionStartedAt: number | null + } + adoption: null | { + source: + | "package-trending" + | "clawhub-trending" + | "clawhub-rising" + | "skills-sh-trending" + rank: number | null + snapshotId: string | null + rankingVersion: string | null + periodStart: number | null + periodEnd: number | null + generatedAt: number | null + sourceObservedAt: number | null + downloads: number | null + installs: number | null + bookmarks: number | null + lifetimeInstalls: number | null + } +} +type Catalog = Pick< + Digest, + | "totalSearches" + | "sourceCounts" + | "coverage" + | "classificationStatus" + | "currentMetadataStatus" +> & { + adoption: { + status: "available" | "unavailable" + generatedAt: number | null + periodStart: number | null + periodEnd: number | null + snapshotId: string | null + rankingVersion: string | null + totalItems: number + inspectedItems: number + truncated: boolean + } + companyOpportunities: (ScopedRow & { + companyProductName?: string + confidence: number + })[] + officialGaps: ScopedRow[] + movers: ScopedRow[] + recommendations: Recommendation[] +} +export type EvidenceDigest = { + kind: "search_intelligence_weekly_v2" + weekStart: number + weekEnd: number + minimumSearches: 3 + dashboardUrl: string + truncated: boolean + catalogs: { plugins: Catalog; skills: Catalog } +} +const scope = (value: unknown) => + value === "catalog" || value === "shelf" || value === "legacy" +const nullable = (value: unknown, validate: (value: unknown) => boolean) => + value === null || validate(value) +const period = (start: unknown, end: unknown) => + nullable(start, timestamp) && + nullable(end, timestamp) && + (start === null || end === null || (start as number) < (end as number)) + +const validAdoptionSummary = (value: unknown) => + fields(value, [ + "status", + "generatedAt", + "periodStart", + "periodEnd", + "snapshotId", + "rankingVersion", + "totalItems", + "inspectedItems", + "truncated" + ]) && + (value.status === "available" || value.status === "unavailable") && + nullable(value.generatedAt, timestamp) && + period(value.periodStart, value.periodEnd) && + nullable(value.snapshotId, (item) => string(item, 256)) && + nullable(value.rankingVersion, (item) => string(item, 120)) && + count(value.totalItems) && + count(value.inspectedItems) && + value.inspectedItems <= value.totalItems && + typeof value.truncated === "boolean" + +const validRecommendation = ( + value: unknown, + kind: "plugin" | "skill", + origins: string[], + weekStart: number, + weekEnd: number, + totalSearches: number +) => { + if ( + !fields(value, [ + "artifactKind", + "id", + "displayName", + "url", + "category", + "support", + "metadataCheckedAt", + "search", + "adoption" + ]) || + value.artifactKind !== kind || + !string(value.id, 256) || + !string(value.displayName, 120) || + !validUrl(value.url, origins) || + !nullable(value.category, (item) => string(item, 120)) || + !timestamp(value.metadataCheckedAt) + ) + return false + const search = value.search + if (search !== null) { + if ( + !fields(search, [ + "matchedSearches7d", + "previous7d", + "searches30d", + "queries", + "omittedQueries", + "periodStart", + "periodEnd", + "dataThrough", + "collectionStartedAt" + ]) || + !count(search.matchedSearches7d) || + search.matchedSearches7d > totalSearches || + !count(search.previous7d) || + !count(search.searches30d) || + search.searches30d < search.matchedSearches7d + search.previous7d || + !count(search.omittedQueries) || + search.periodStart !== weekStart || + search.periodEnd !== weekEnd || + !nullable(search.dataThrough, timestamp) || + !nullable(search.collectionStartedAt, timestamp) || + !Array.isArray(search.queries) || + search.queries.length > 3 || + !search.queries.every( + (query) => + fields(query, [ + "query", + "scope", + "searches7d", + "previous7d", + "searches30d" + ]) && + string(query.query, 256) && + scope(query.scope) && + count(query.searches7d) && + query.searches7d >= 3 && + query.searches7d <= (search.matchedSearches7d as number) && + count(query.previous7d) && + count(query.searches30d) && + query.searches30d >= query.searches7d + query.previous7d + ) + ) + return false + const queries = search.queries as NonNullable< + Recommendation["search"] + >["queries"] + if ( + new Set(queries.map((query) => `${query.scope}\0${query.query}`)).size !== + queries.length || + queries.reduce((sum, query) => sum + query.searches7d, 0) > + search.matchedSearches7d || + queries.reduce((sum, query) => sum + query.previous7d, 0) > + search.previous7d || + queries.reduce((sum, query) => sum + query.searches30d, 0) > + search.searches30d + ) + return false + } + const adoption = value.adoption + if (adoption !== null) { + if ( + !fields(adoption, [ + "source", + "rank", + "snapshotId", + "rankingVersion", + "periodStart", + "periodEnd", + "generatedAt", + "sourceObservedAt", + "downloads", + "installs", + "bookmarks", + "lifetimeInstalls" + ]) || + typeof adoption.source !== "string" || + !(kind === "plugin" + ? adoption.source === "package-trending" + : ["clawhub-trending", "clawhub-rising", "skills-sh-trending"].includes( + adoption.source + )) || + !nullable(adoption.rank, (item) => count(item) && item > 0) || + !nullable(adoption.snapshotId, (item) => string(item, 256)) || + !nullable(adoption.rankingVersion, (item) => string(item, 120)) || + !period(adoption.periodStart, adoption.periodEnd) || + !nullable(adoption.generatedAt, timestamp) || + !nullable(adoption.sourceObservedAt, timestamp) || + ![ + adoption.downloads, + adoption.installs, + adoption.bookmarks, + adoption.lifetimeInstalls + ].every((item) => nullable(item, count)) + ) + return false + } + if (value.support === "search-only") + return ( + record(search) && + (search.matchedSearches7d as number) >= 3 && + adoption === null + ) + if (value.support === "both") + return ( + record(search) && + (search.matchedSearches7d as number) > 0 && + adoption !== null + ) + return value.support === "adoption-only" && adoption !== null +} + +export const parseEvidenceDigest = ( + value: unknown, + origins: string[] +): EvidenceDigest | null => { + if ( + !fields(value, [ + "kind", + "weekStart", + "weekEnd", + "minimumSearches", + "dashboardUrl", + "truncated", + "catalogs" + ]) || + value.kind !== "search_intelligence_weekly_v2" || + !fields(value.catalogs, ["plugins", "skills"]) || + new TextEncoder().encode(JSON.stringify(value)).byteLength > 30_000 + ) + return null + for (const [name, kind] of [ + ["plugins", "plugin"], + ["skills", "skill"] + ] as const) { + const catalog = value.catalogs[name] + if ( + !fields(catalog, [ + "totalSearches", + "sourceCounts", + "coverage", + "classificationStatus", + "currentMetadataStatus", + "adoption", + "companyOpportunities", + "officialGaps", + "movers", + "recommendations" + ]) || + !validAdoptionSummary(catalog.adoption) + ) + return null + const unscoped: Record = {} + for (const section of [ + "companyOpportunities", + "officialGaps", + "movers" + ] as const) { + const rows = catalog[section] + if ( + !Array.isArray(rows) || + rows.length > 5 || + !rows.every( + (row) => + record(row) && + scope(row.scope) && + (section !== "companyOpportunities" || row.scope === "catalog") + ) + ) + return null + unscoped[section] = rows.map(({ scope: _scope, ...row }) => row) + } + // The unchanged legacy validator owns shared weekly count, coverage, URL and + // section invariants. V2 adds catalog identity and recommendation evidence. + const summary = parseDigest( + { + kind: "plugin_search_weekly", + weekStart: value.weekStart, + weekEnd: value.weekEnd, + minimumSearches: value.minimumSearches, + dashboardUrl: value.dashboardUrl, + truncated: value.truncated, + totalSearches: catalog.totalSearches, + sourceCounts: catalog.sourceCounts, + coverage: catalog.coverage, + classificationStatus: catalog.classificationStatus, + currentMetadataStatus: catalog.currentMetadataStatus, + ...unscoped, + featuredCandidates: [] + }, + origins + ) + if ( + !summary || + !Array.isArray(catalog.recommendations) || + catalog.recommendations.length > 5 || + !catalog.recommendations.every((candidate) => + validRecommendation( + candidate, + kind, + origins, + summary.weekStart, + summary.weekEnd, + summary.totalSearches + ) + ) || + new Set(catalog.recommendations.map((candidate) => candidate.id)).size !== + catalog.recommendations.length || + ((catalog.adoption as Catalog["adoption"]).status === "unavailable" && + catalog.recommendations.some( + (candidate) => candidate.adoption !== null + )) + ) + return null + } + return value as unknown as EvidenceDigest +} + +const safe = (value: string) => + value + .replace(/[\u0000-\u001f\u007f]/g, " ") + .replace(/([\\`*_~|>\[\]()#])/g, "\\$1") + .replace(/@/g, "@\u200b") +const brief = (value: string) => + safe(value.length > 80 ? `${value.slice(0, 79)}…` : value) +const link = (value: string) => + `<${new URL(value).toString().replace(//g, "%3E")}>` +const time = (value: number | null) => + value === null ? "unknown" : new Date(value).toISOString().slice(0, 16) + "Z" +const adoptionMetrics = (adoption: NonNullable) => { + const metrics = [ + ["downloads", adoption.downloads], + ["installs", adoption.installs], + ["bookmarks", adoption.bookmarks], + ["lifetime installs", adoption.lifetimeInstalls] + ] as const + return ( + metrics + .filter(([, value]) => value !== null) + .map(([label, value]) => `${value} ${label}`) + .join(" · ") || "Adoption counts unavailable" + ) +} +const recommendationText = (row: Recommendation) => { + const search = row.search + const adoption = row.adoption + return [ + `[${brief(row.displayName)}](${link(row.url)}) · ${row.support}${row.category ? ` · ${brief(row.category)}` : ""}`, + search + ? `${search.matchedSearches7d} matched-query searches (previous ${search.previous7d}; 30d ${search.searches30d}); ${time(search.periodStart)} – ${time(search.periodEnd)}` + : "Search evidence unavailable.", + ...(search?.queries.map( + (query) => + `${brief(query.query)} (${query.scope}): ${query.searches7d} searches` + ) ?? []), + ...(search?.omittedQueries + ? [`${search.omittedQueries} query details omitted.`] + : []), + adoption + ? `${adoptionMetrics(adoption)}; ${time(adoption.periodStart)} – ${time(adoption.periodEnd)}; snapshot ${time(adoption.generatedAt)}${adoption.sourceObservedAt === null ? "" : `; source observed ${time(adoption.sourceObservedAt)}`} (${adoption.source}${adoption.rank === null ? "" : ` #${adoption.rank}`})` + : "Adoption evidence unavailable.", + `Metadata checked ${time(row.metadataCheckedAt)}.` + ].join("\n") +} + +export const renderEvidenceDigest = (digest: EvidenceDigest) => { + const preview = ["localhost", "127.0.0.1", "[::1]"].includes( + new URL(digest.dashboardUrl).hostname + ) + const header = `### ${preview ? "LOCAL PREVIEW · " : ""}ClawHub weekly intelligence\n${time(digest.weekStart)} – ${time(digest.weekEnd)} (UTC, end exclusive)\n[Open intelligence dashboard](${link(digest.dashboardUrl)})` + const footer = + "Human quality review, security and category-coverage review remain required. Company classification is advisory. Query details require at least 3 searches; matched-query searches are separate from adoption." + const sections: { title: string; rows: string[]; omitted: boolean }[] = [] + const rowText = (row: ScopedRow) => + `[${brief(row.query)}](${link(row.searchUrl)}) (${row.scope}) · ${row.searches} searches · ${row.officialGaps} gaps · previous ${row.previousSearches}` + for (const [name, catalog] of [ + ["Plugins", digest.catalogs.plugins], + ["Skills", digest.catalogs.skills] + ] as const) { + const coverage = catalog.coverage + const incomplete = + coverage.dataThrough === null || + coverage.dataThrough < digest.weekEnd || + coverage.collectionStartedAt === null || + coverage.collectionStartedAt > digest.weekStart || + coverage.gapStart !== null + sections.push({ + title: `**${name}** · ${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." : ""}\nClassification ${catalog.classificationStatus}; adoption ${catalog.adoption.status}; search metadata ${catalog.currentMetadataStatus}.\nAdoption snapshot ${time(catalog.adoption.generatedAt)}; ${time(catalog.adoption.periodStart)} – ${time(catalog.adoption.periodEnd)}; inspected ${catalog.adoption.inspectedItems}/${catalog.adoption.totalItems}${catalog.adoption.truncated ? " (capped)" : ""}.`, + rows: [], + omitted: false + }) + for (const [title, rows] of [ + [ + `${name} Featured recommendations`, + catalog.recommendations.map(recommendationText) + ], + [ + `${name} company opportunities`, + catalog.companyOpportunities.map( + (row) => + `${rowText(row)}${row.companyProductName ? ` · ${brief(row.companyProductName)}` : ""} · ${Math.round(row.confidence * 100)}% classifier confidence` + ) + ], + [`${name} official gaps`, catalog.officialGaps.map(rowText)], + [`${name} movers`, catalog.movers.map(rowText)] + ] as const) + sections.push({ title: `**${title}**`, rows: [...rows], omitted: false }) + } + const text = (section: (typeof sections)[number]) => + [ + section.title, + ...section.rows, + ...(section.omitted ? ["More evidence on the dashboard."] : []) + ].join("\n") + const renderedLength = () => + header.length + footer.length + sections.map(text).join("\n").length + 80 + // Drop whole rows from the longest remaining section; never cut a URL or + // re-rank the canonical recommendations just to fit Discord's text budget. + while (renderedLength() > 3900) { + const longest = sections + .filter((section) => section.rows.length) + .sort((a, b) => b.rows.join("\n").length - a.rows.join("\n").length)[0] + if (!longest) break + longest.rows.pop() + longest.omitted = true + } + return serializePayload({ + components: [ + new Container([ + new TextDisplay(header), + ...sections.map((section) => new TextDisplay(text(section))), + new TextDisplay( + `${footer}${digest.truncated ? " Input capped; more evidence on the dashboard." : ""}` + ) + ]) + ], + allowedMentions: { parse: [] } + }) +} diff --git a/tests/searchIntelligenceApi.test.ts b/tests/searchIntelligenceApi.test.ts index 2a581ec..73b0095 100644 --- a/tests/searchIntelligenceApi.test.ts +++ b/tests/searchIntelligenceApi.test.ts @@ -67,6 +67,116 @@ const validPayload = { } ] } +const evidencePayload = () => { + const search = { + matchedSearches7d: 4, + previous7d: 2, + searches30d: 8, + queries: [ + { + query: "memory", + scope: "catalog", + searches7d: 4, + previous7d: 2, + searches30d: 8 + } + ], + omittedQueries: 0, + periodStart: validPayload.weekStart, + periodEnd: validPayload.weekEnd, + dataThrough: validPayload.weekEnd, + collectionStartedAt: validPayload.coverage.collectionStartedAt + } + const adoption = { + source: "package-trending", + rank: 2, + snapshotId: "package-week-1", + rankingVersion: "package-trending", + periodStart: validPayload.weekStart, + periodEnd: validPayload.weekEnd, + generatedAt: validPayload.weekEnd, + sourceObservedAt: null, + downloads: 341, + installs: 1, + bookmarks: null, + lifetimeInstalls: null + } + const recommendation = { + artifactKind: "plugin", + id: "plugin:memory-kit", + displayName: "Memory Kit", + url: "https://clawhub.ai/plugins/memory-kit", + category: "memory", + support: "both", + search, + adoption, + metadataCheckedAt: validPayload.weekEnd + } + const catalog = { + totalSearches: validPayload.totalSearches, + sourceCounts: validPayload.sourceCounts, + coverage: validPayload.coverage, + classificationStatus: "available", + currentMetadataStatus: "available", + adoption: { + status: "available", + generatedAt: validPayload.weekEnd, + periodStart: validPayload.weekStart, + periodEnd: validPayload.weekEnd, + snapshotId: "synthetic", + rankingVersion: "v1", + totalItems: 1, + inspectedItems: 1, + truncated: false + }, + companyOpportunities: validPayload.companyOpportunities.map((row) => ({ + ...row, + scope: "catalog" + })), + officialGaps: validPayload.officialGaps.map((row) => ({ + ...row, + scope: "catalog" + })), + movers: validPayload.movers.map((row) => ({ ...row, scope: "catalog" })), + recommendations: [recommendation] + } + return { + kind: "search_intelligence_weekly_v2", + weekStart: validPayload.weekStart, + weekEnd: validPayload.weekEnd, + minimumSearches: 3, + dashboardUrl: validPayload.dashboardUrl, + truncated: false, + catalogs: { + plugins: catalog, + skills: { + ...catalog, + totalSearches: 0, + sourceCounts: { clawhubWeb: 0, openclawControlUi: 0 }, + classificationStatus: "unavailable", + companyOpportunities: [], + officialGaps: [], + movers: [], + recommendations: [ + { + ...recommendation, + artifactKind: "skill", + id: "clawhub:homeassistant", + displayName: "Homeassistant Skill", + url: "https://clawhub.ai/example/skills/homeassistant", + support: "adoption-only", + search: null, + adoption: { + ...adoption, + source: "clawhub-trending", + periodStart: validPayload.weekEnd - 86_400_000 + } + } + ] + } + } + } +} let mockClock: ReturnType> | undefined const owners: SqliteD1Database[] = [] const setup = () => { @@ -128,6 +238,201 @@ afterEach(() => { }) describe("ClawHub weekly search intelligence receiver", () => { + it("renders separate catalog evidence, including adoption-supported skills with no searches", async () => { + const { client, posts } = setup() + expect( + ( + await handleSearchIntelligenceApiRequest( + request(evidencePayload()), + client + ) + )?.status + ).toBe(200) + const text = (posts[0].body.components as unknown[]) + .flatMap(texts) + .join("\n") + expect(text).toContain("Plugins") + expect(text).toContain("Skills") + expect(text).toContain("Memory Kit") + expect(text).toContain("Homeassistant Skill") + expect(text).toContain("341 downloads") + expect(text).toContain("adoption-only") + expect(text).toContain("2026-09-06") + expect(text).toContain("quality review") + expect(posts[0].body.allowed_mentions).toEqual({ parse: [] }) + }) + it("keeps independently hydrated adoption evidence when search metadata is unavailable", async () => { + const { client, posts } = setup() + const payload = evidencePayload() + payload.catalogs.skills.currentMetadataStatus = "unavailable" + payload.catalogs.skills.recommendations[0].adoption = { + ...payload.catalogs.skills.recommendations[0].adoption, + source: "skills-sh-trending", + periodStart: null, + periodEnd: null, + sourceObservedAt: payload.weekEnd - 86_400_000, + downloads: null, + installs: null, + lifetimeInstalls: 1200 + } as never + expect( + (await handleSearchIntelligenceApiRequest(request(payload), client)) + ?.status + ).toBe(200) + const rendered = JSON.stringify(posts[0].body) + expect(rendered).toContain("1200 lifetime installs") + expect(rendered).toContain("source observed 2026-09-06T00:00Z") + expect(rendered).toContain("search metadata unavailable") + }) + it("preserves a frozen legacy week and rejects replacing its receipt with a v2 report", async () => { + const { client, posts } = setup() + expect( + (await handleSearchIntelligenceApiRequest(request(), client))?.status + ).toBe(200) + expect( + ( + await handleSearchIntelligenceApiRequest( + request(evidencePayload()), + client + ) + )?.status + ).toBe(409) + expect( + (await handleSearchIntelligenceApiRequest(request(), client))?.status + ).toBe(200) + expect(posts).toHaveLength(1) + }) + it("rejects private fields, low-volume query text and inconsistent catalog evidence before receipt access", async () => { + const { client, posts, owner } = setup() + const change = ( + mutate: (payload: ReturnType) => void + ) => { + const payload = evidencePayload() + mutate(payload) + return payload + } + const invalid = [ + { ...evidencePayload(), userId: "private" }, + change((payload) => { + Object.assign(payload.catalogs.skills, { identity: "private" }) + }), + change((payload) => { + payload.catalogs.plugins.recommendations[0].artifactKind = "skill" + }), + change((payload) => { + payload.catalogs.plugins.recommendations[0].url = + "https://evil.example/plugin" + }), + change((payload) => { + payload.catalogs.plugins.recommendations[0].search.queries[0].searches7d = 2 + }), + change((payload) => { + payload.catalogs.plugins.recommendations[0].search.queries.push( + payload.catalogs.plugins.recommendations[0].search.queries[0] + ) + }), + change((payload) => { + payload.catalogs.plugins.recommendations[0].search.periodEnd++ + }), + change((payload) => { + payload.catalogs.plugins.recommendations[0].search.matchedSearches7d = 13 + }), + change((payload) => { + Object.assign(payload.catalogs.skills.recommendations[0].adoption, { + source: ["clawhub-trending"] + }) + }), + change((payload) => { + payload.catalogs.skills.recommendations[0].adoption.periodStart = + payload.weekEnd + }), + change((payload) => { + payload.catalogs.skills.recommendations[0].adoption.installs = -1 + }), + change((payload) => { + payload.catalogs.skills.recommendations[0].support = "search-only" + }), + change((payload) => { + payload.catalogs.skills.adoption.status = "unavailable" + }), + change((payload) => { + payload.catalogs.plugins.companyOpportunities[0].scope = "shelf" + }), + change((payload) => { + payload.catalogs.skills.recommendations = Array(6).fill( + payload.catalogs.skills.recommendations[0] + ) + }) + ] + for (const payload of invalid) + 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("reconciles v2 response loss and preserves the receipt on replay", async () => { + const { client, posts } = setup() + const original = client.rest.post.bind(client.rest) + client.rest.post = (async (...args: Parameters) => { + await original(...args) + throw new Error("Response lost") + }) as typeof client.rest.post + const payload = evidencePayload() + expect( + (await handleSearchIntelligenceApiRequest(request(payload), client)) + ?.status + ).toBe(503) + client.rest.get = async () => [ + { + id: "message-1", + author: { id: "bot-user", bot: true }, + timestamp: new Date().toISOString(), + components: posts[0].body.components + } + ] + expect( + (await handleSearchIntelligenceApiRequest(request(payload), client)) + ?.status + ).toBe(200) + expect( + (await handleSearchIntelligenceApiRequest(request(payload), client)) + ?.status + ).toBe(200) + expect(posts).toHaveLength(1) + }) + it("bounds v2 text with both catalogs and explicit omissions while leaving source facts intact", async () => { + const { client, posts } = setup() + const payload = evidencePayload() + for (const catalog of [payload.catalogs.plugins, payload.catalogs.skills]) { + const candidate = catalog.recommendations[0] + catalog.recommendations = Array.from({ length: 5 }, (_, index) => ({ + ...candidate, + id: candidate.id + index, + displayName: "@everyone [link](https://evil.example) ".repeat(3).trim(), + url: candidate.url + "?proof=" + "x".repeat(800) + })) as typeof catalog.recommendations + } + payload.truncated = true + expect( + (await handleSearchIntelligenceApiRequest(request(payload), client)) + ?.status + ).toBe(200) + const text = (posts[0].body.components as unknown[]) + .flatMap(texts) + .join("\n") + expect(text.length).toBeLessThanOrEqual(4000) + expect(text).toContain("Plugins Featured recommendations") + expect(text).toContain("Skills Featured recommendations") + expect(text).toContain("More evidence on the dashboard") + expect(text).toContain("Input capped") + expect(text).not.toContain("@everyone") + expect(text).not.toContain("[link](https://evil.example)") + expect(payload.catalogs.plugins.recommendations).toHaveLength(5) + }) it("delivers bounded aggregate facts with Carbon V2 and no mentions", async () => { const { client, posts } = setup() const response = await handleSearchIntelligenceApiRequest(request(), client) From 49ad595d86d2d97894060b7debed4ad575d13817 Mon Sep 17 00:00:00 2001 From: Patrick Erichsen Date: Tue, 15 Sep 2026 12:57:38 -0700 Subject: [PATCH 2/2] fix: show explicit empty weekly intelligence sections --- src/clawhubSearchIntelligence/evidence.ts | 33 ++++++++++++++++++----- tests/searchIntelligenceApi.test.ts | 22 +++++++++++++++ 2 files changed, 48 insertions(+), 7 deletions(-) diff --git a/src/clawhubSearchIntelligence/evidence.ts b/src/clawhubSearchIntelligence/evidence.ts index d706486..358053d 100644 --- a/src/clawhubSearchIntelligence/evidence.ts +++ b/src/clawhubSearchIntelligence/evidence.ts @@ -426,7 +426,12 @@ export const renderEvidenceDigest = (digest: EvidenceDigest) => { const header = `### ${preview ? "LOCAL PREVIEW · " : ""}ClawHub weekly intelligence\n${time(digest.weekStart)} – ${time(digest.weekEnd)} (UTC, end exclusive)\n[Open intelligence dashboard](${link(digest.dashboardUrl)})` const footer = "Human quality review, security and category-coverage review remain required. Company classification is advisory. Query details require at least 3 searches; matched-query searches are separate from adoption." - const sections: { title: string; rows: string[]; omitted: boolean }[] = [] + const sections: { + title: string + rows: string[] + empty?: string + omitted: boolean + }[] = [] const rowText = (row: ScopedRow) => `[${brief(row.query)}](${link(row.searchUrl)}) (${row.scope}) · ${row.searches} searches · ${row.officialGaps} gaps · previous ${row.previousSearches}` for (const [name, catalog] of [ @@ -445,27 +450,41 @@ export const renderEvidenceDigest = (digest: EvidenceDigest) => { rows: [], omitted: false }) - for (const [title, rows] of [ + for (const [title, rows, empty] of [ [ `${name} Featured recommendations`, - catalog.recommendations.map(recommendationText) + catalog.recommendations.map(recommendationText), + "No qualifying recommendations." ], [ `${name} company opportunities`, catalog.companyOpportunities.map( (row) => `${rowText(row)}${row.companyProductName ? ` · ${brief(row.companyProductName)}` : ""} · ${Math.round(row.confidence * 100)}% classifier confidence` - ) + ), + "No qualifying company opportunities." + ], + [ + `${name} official gaps`, + catalog.officialGaps.map(rowText), + "No qualifying official gaps." ], - [`${name} official gaps`, catalog.officialGaps.map(rowText)], - [`${name} movers`, catalog.movers.map(rowText)] + [`${name} movers`, catalog.movers.map(rowText), "No qualifying movers."] ] as const) - sections.push({ title: `**${title}**`, rows: [...rows], omitted: false }) + sections.push({ + title: `**${title}**`, + rows: [...rows], + empty, + omitted: false + }) } const text = (section: (typeof sections)[number]) => [ section.title, ...section.rows, + ...(!section.rows.length && !section.omitted && section.empty + ? [section.empty] + : []), ...(section.omitted ? ["More evidence on the dashboard."] : []) ].join("\n") const renderedLength = () => diff --git a/tests/searchIntelligenceApi.test.ts b/tests/searchIntelligenceApi.test.ts index 73b0095..9a84664 100644 --- a/tests/searchIntelligenceApi.test.ts +++ b/tests/searchIntelligenceApi.test.ts @@ -284,6 +284,28 @@ describe("ClawHub weekly search intelligence receiver", () => { expect(rendered).toContain("source observed 2026-09-06T00:00Z") expect(rendered).toContain("search metadata unavailable") }) + it("reports an explicit empty outcome for every catalog section", async () => { + const { client, posts } = setup() + const payload = evidencePayload() + for (const catalog of Object.values(payload.catalogs)) { + catalog.recommendations = [] + catalog.companyOpportunities = [] + catalog.officialGaps = [] + catalog.movers = [] + } + expect( + (await handleSearchIntelligenceApiRequest(request(payload), client)) + ?.status + ).toBe(200) + const rendered = JSON.stringify(posts[0].body) + for (const section of [ + "recommendations", + "company opportunities", + "official gaps", + "movers" + ]) + expect(rendered.split(`No qualifying ${section}.`)).toHaveLength(3) + }) it("preserves a frozen legacy week and rejects replacing its receipt with a v2 report", async () => { const { client, posts } = setup() expect(