Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
40 changes: 35 additions & 5 deletions docs/clawhub-search-intelligence.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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):

Expand Down
237 changes: 13 additions & 224 deletions src/clawhubSearchIntelligence/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, unknown> =>
!!value && typeof value === "object" && !Array.isArray(value)
const fields = (
value: unknown,
required: string[],
optional: string[] = []
): value is Record<string, unknown> =>
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<unknown> => {
const reader = request.body?.getReader()
Expand Down Expand Up @@ -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)
}
Expand Down
Loading
Loading