From 0c3c346b0c74c85f55b02b680192bf0fb95ca254 Mon Sep 17 00:00:00 2001 From: lizhixuan Date: Mon, 12 Oct 2026 22:20:29 +0800 Subject: [PATCH 01/12] feat(mcp): give agents description, tags, author, relevance and rehostPolicy search_references projected only id/title/license/links, so an agent could not judge a match or tell whether it may store a result's media. Add the source description (capped at 280), the first 8 tags, the author, the cross-source relevance and rights.rehostPolicy, with each policy value explained in the output schema and tool description. sourceScore stays out: it is per-source and not comparable. Core exports REHOST_POLICIES and uses it in its own schema. --- .changeset/mcp-agent-ref-fields.md | 6 +++ README.md | 2 +- packages/core/README.md | 2 +- packages/core/src/index.ts | 2 +- packages/core/src/rights.ts | 9 +++- packages/mcp/README.md | 2 +- packages/mcp/src/__tests__/mcp.test.ts | 62 ++++++++++++++++++++++++++ packages/mcp/src/index.ts | 31 ++++++++++++- 8 files changed, 108 insertions(+), 8 deletions(-) create mode 100644 .changeset/mcp-agent-ref-fields.md diff --git a/.changeset/mcp-agent-ref-fields.md b/.changeset/mcp-agent-ref-fields.md new file mode 100644 index 0000000..3e9b248 --- /dev/null +++ b/.changeset/mcp-agent-ref-fields.md @@ -0,0 +1,6 @@ +--- +'@refkit/core': minor +'@refkit/mcp': minor +--- + +`search_references` results now carry what an agent needs to judge a match and handle the file: the source's `description` (capped at 280 characters), its first 8 `tags`, the `author`, the 0..1 cross-source `relevance`, and `rights.rehostPolicy`. The output schema and the tool description explain each `rehostPolicy` value, so agents know whether they may download, cache or only hotlink a result's media. The per-source `sourceScore` stays out because it is not comparable across sources. Core exports the policy values as `REHOST_POLICIES`, which its own schema now uses as well. diff --git a/README.md b/README.md index be5c13a..fc4a7a0 100644 --- a/README.md +++ b/README.md @@ -357,7 +357,7 @@ A provider's `search` returns `EmittedReference[]` — only what the **source** - **Zero network in `core`** — no `fetch` call, no hard-coded endpoint. Hosts inject `ProviderContext.fetch`. - **Core stamps its own fields** — `id`, `source`, `canonicalUrl`, `verifiedAt`, `relevance` and the `limit` come from core, never from a provider (`EmittedReference` → `Reference`). - **Facts, not labels** — the use-gate, the merge's conflict resolution, attribution and the license boost all read license **facts** via `factsOf(rights)`: `LicenseId` is an open string, and a source whose terms are narrower than the label it declares ships its own `rights.facts` row. An id with no known facts resolves to the `unknown` row, which grants nothing. -- **No re-hosting** — keep `canonicalUrl` + thumbnails only; never store originals. `rights.rehostPolicy` records what the source permits; it is host-facing metadata that core validates and passes through but never acts on — enforcing it is the host binding's job. +- **No re-hosting** — keep `canonicalUrl` + thumbnails only; never store originals. `rights.rehostPolicy` records what the source permits (`REHOST_POLICIES`); core validates and passes it through but never acts on it. Enforcing it is the host binding's job, and `@refkit/mcp` hands it to agents on every result with what each value allows. - **strict-deny** — when rights can't be determined, deny / needs-review (never fail-open). Unknown, NonCommercial and "no known copyright restrictions" never map to a commercially usable license; NoDerivatives allows verbatim commercial reuse (with attribution) but never derivative/AI use. ## Agent usage diff --git a/packages/core/README.md b/packages/core/README.md index 901bc43..cca8738 100644 --- a/packages/core/README.md +++ b/packages/core/README.md @@ -203,7 +203,7 @@ The hook compares `Reference` objects only. Core still never fetches, decodes, o - **Zero network** — no `fetch` call, no hard-coded endpoint in this package. Hosts inject `ProviderContext.fetch`. - **Substrate-agnostic** — no import of any host or orchestration framework. - **Only `zod`** as a non-relative dependency. -- **No re-hosting** — keep `canonicalUrl` + thumbnails only; never store originals. `rights.rehostPolicy` records what the source permits and is validated on every record, but core never acts on it: it is metadata for the host binding to honour. +- **No re-hosting** — keep `canonicalUrl` + thumbnails only; never store originals. `rights.rehostPolicy` records what the source permits (`REHOST_POLICIES`) and is validated on every record, but core never acts on it: it is metadata for the host binding to honour, and `@refkit/mcp` passes it to agents on every result. - **Core stamps its own fields** — `id`, `source`, `canonicalUrl`, `verifiedAt`, `relevance` and the per-provider `limit` come from core, never from a provider. - **strict-deny** — when rights can't be determined, deny / `needs-review` (never fail-open). Unknown, NonCommercial, NoDerivatives, and "no known copyright restrictions" never map to a usable license. Indeterminate facts (all three axes `'unknown'`) can never be allowed, and a cross-source conflict whose claims are incomparable resolves to `unknown`. diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 2926d57..59c53ac 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -7,7 +7,7 @@ export { } from './license' export type { LicenseId, KnownLicenseId, LicenseFacts, Tri } from './license' export type { RehostPolicy, RightsRecord } from './rights' -export { rightsRecordSchema, licenseFactsSchema, factsOf } from './rights' +export { REHOST_POLICIES, rightsRecordSchema, licenseFactsSchema, factsOf } from './rights' export { buildAttribution } from './attribution' export type { Attribution, AttributionInput } from './attribution' export type { diff --git a/packages/core/src/rights.ts b/packages/core/src/rights.ts index 97e1d90..2a3c23c 100644 --- a/packages/core/src/rights.ts +++ b/packages/core/src/rights.ts @@ -1,7 +1,12 @@ import { z } from 'zod' import { CC_VERSIONED_FAMILIES, factsFor, type LicenseFacts, type LicenseId, type Tri } from './license' -export type RehostPolicy = 'hotlink-required' | 'cache-allowed' | 'thumbnail-only' | 'no-store' +/** What the source lets a host do with the media file. Hosts (and, through @refkit/mcp, + * agents) read it before storing anything: hotlink-required = link to the source, never + * copy the file; cache-allowed = may download and cache it; thumbnail-only = may store the + * thumbnail but not the full file; no-store = store nothing, not even the thumbnail. */ +export const REHOST_POLICIES = ['hotlink-required', 'cache-allowed', 'thumbnail-only', 'no-store'] as const +export type RehostPolicy = typeof REHOST_POLICIES[number] // What a satellite emits per result. Permissions are derived from `license` via // factsFor() unless the record ships its own `facts` (required for ids outside @@ -43,7 +48,7 @@ export const rightsRecordSchema: z.ZodType = z.object({ facts: licenseFactsSchema.optional(), licenseVersion: z.string().optional(), author: z.string().optional(), - rehostPolicy: z.enum(['hotlink-required', 'cache-allowed', 'thumbnail-only', 'no-store']), + rehostPolicy: z.enum(REHOST_POLICIES), jurisdiction: z.string().optional(), editorialOnly: z.boolean().optional(), raw: z.object({ sourceTerms: z.string(), sourceUrl: z.string() }), diff --git a/packages/mcp/README.md b/packages/mcp/README.md index bcfab71..db60ec2 100644 --- a/packages/mcp/README.md +++ b/packages/mcp/README.md @@ -72,7 +72,7 @@ Input: `{ query, modalities?, sources?, controls?, providerOptions?, limit?, cur } ``` -Output: `{ references: [{ id, title?, modality, kind?, provider, canonicalUrl, license, thumbnail?, excerpt?, useVerdict?, useExplanation?, attribution? }], nextCursor?, meta? }`. When `intent` (or `gateFor`) is set, each result carries `useVerdict { decision, reason, confidence }`, a plain `useExplanation`, and — if the license requires it — a ready-to-use `attribution` credit line. When `explain: true`, `meta` includes per-provider `fulfilled` / `failed` / `skipped` status, applied/ignored control details, warnings, and gate/threshold-drop counts. The `meta` shape comes from core's own `searchMetaSchema` — this server keeps no copy of the vocabulary. +Output: `{ references: [{ id, title?, description?, tags?, modality, kind?, provider, canonicalUrl, license, author?, rehostPolicy, relevance, thumbnail?, excerpt?, useVerdict?, useExplanation?, attribution? }], nextCursor?, meta? }`. `description` is capped at 280 characters and `tags` at the first 8, to keep agent context small. `relevance` is the 0..1 cross-source score after fusion and rerank; the per-source `sourceScore` is left out because it is not comparable across sources. `rehostPolicy` tells the agent what it may do with the media before storing anything: `hotlink-required` (link only), `cache-allowed`, `thumbnail-only` or `no-store`. When `intent` (or `gateFor`) is set, each result carries `useVerdict { decision, reason, confidence }`, a plain `useExplanation`, and — if the license requires it — a ready-to-use `attribution` credit line. When `explain: true`, `meta` includes per-provider `fulfilled` / `failed` / `skipped` status, applied/ignored control details, warnings, and gate/threshold-drop counts. The `meta` shape comes from core's own `searchMetaSchema` — this server keeps no copy of the vocabulary. > Results are references with a license id + source link — **not rights clearance, not legal advice**. `unknown` / `needs-review` results require the caller to verify the source's terms. diff --git a/packages/mcp/src/__tests__/mcp.test.ts b/packages/mcp/src/__tests__/mcp.test.ts index d0ae7c4..d60b4b6 100644 --- a/packages/mcp/src/__tests__/mcp.test.ts +++ b/packages/mcp/src/__tests__/mcp.test.ts @@ -249,6 +249,68 @@ describe('@refkit/mcp', () => { await client.close() }) + it('projects description, tags, author, relevance and rehostPolicy for agents (not the per-source sourceScore)', async () => { + const long = `Lighthouse on a rocky cape at dusk, ${'with long exposure waves '.repeat(20)}`.trim() + const tags = ['lighthouse', 'coast', 'dusk', 'sea', 'rocks', 'waves', 'beacon', 'cape', 'night', 'tower'] + const provider = defineProvider({ + id: 'p', + modalities: ['image'], + search: async () => [ + { + modality: 'image' as const, + title: 'Cape light', + description: long, + tags, + sourceScore: 42, + sourceUrl: 'https://p/1', + rights: { license: 'CC-BY-4.0', author: 'Ann', rehostPolicy: 'hotlink-required' as const, raw: { sourceTerms: 'terms', sourceUrl: 'https://p/1' } }, + }, + { + modality: 'image' as const, + title: 'Harbour light', + description: 'Short caption.', + sourceUrl: 'https://p/2', + rights: { license: 'CC0-1.0', rehostPolicy: 'cache-allowed' as const, raw: { sourceTerms: 'terms', sourceUrl: 'https://p/2' } }, + }, + ], + }) + const server = createRefkitMcpServer(createRefkit({ providers: [provider], resilience: false })) + const [clientT, serverT] = InMemoryTransport.createLinkedPair() + const client = new Client({ name: 'test', version: '1.0.0' }) + await Promise.all([client.connect(clientT), server.connect(serverT)]) + const res = await client.callTool({ name: 'search_references', arguments: { query: 'lighthouse', modalities: ['image'] } }) + const refs = (res.structuredContent as { references: Array> }).references + const cape = refs.find(r => r.title === 'Cape light')! + const harbour = refs.find(r => r.title === 'Harbour light')! + expect(typeof cape.description).toBe('string') + expect((cape.description as string).length).toBeLessThanOrEqual(280) + expect(cape.description as string).toMatch(/^Lighthouse on a rocky cape at dusk, with long exposure waves/) + expect(cape.description as string).toMatch(/…$/) + expect(harbour.description).toBe('Short caption.') + expect(cape.tags).toEqual(tags.slice(0, 8)) + expect(harbour.tags).toBeUndefined() + expect(cape.author).toBe('Ann') + expect(cape.rehostPolicy).toBe('hotlink-required') + expect(harbour.rehostPolicy).toBe('cache-allowed') + expect(typeof cape.relevance).toBe('number') + expect(cape.relevance as number).toBeGreaterThan(0) + expect(cape.relevance as number).toBeLessThanOrEqual(1) + expect(cape).not.toHaveProperty('sourceScore') + await client.close() + }) + + it('tells agents what rehostPolicy means, in the tool description and the output schema', async () => { + const client = await connectedClient() + const { tools } = await client.listTools() + const search = tools.find(t => t.name === 'search_references')! + expect(search.description).toMatch(/rehostPolicy/) + const items = (search.outputSchema as unknown as { properties: { references: { items: { properties: Record } } } }) + .properties.references.items.properties + expect(items.rehostPolicy.enum).toEqual(['hotlink-required', 'cache-allowed', 'thumbnail-only', 'no-store']) + for (const value of ['hotlink-required', 'cache-allowed', 'thumbnail-only', 'no-store']) expect(items.rehostPolicy.description).toContain(value) + await client.close() + }) + it('reranks by default and passes rerank:false through as raw fusion order', async () => { const titles = ['Something else', 'A lion'] const provider = defineProvider({ diff --git a/packages/mcp/src/index.ts b/packages/mcp/src/index.ts index 09f628f..5b5518e 100644 --- a/packages/mcp/src/index.ts +++ b/packages/mcp/src/index.ts @@ -2,7 +2,7 @@ import { readFileSync } from 'node:fs' import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js' import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js' import { z } from 'zod' -import { LICENSE_IDS, INTENTS, evaluateUse, buildAttribution, ccVersionFor, buildSearchControlsSchema, searchMetaSchema } from '@refkit/core' +import { LICENSE_IDS, INTENTS, REHOST_POLICIES, evaluateUse, buildAttribution, ccVersionFor, buildSearchControlsSchema, searchMetaSchema } from '@refkit/core' import type { RefkitClient, Reference, Verdict, Attribution, SearchControls, SearchInput, ProviderOptionsById, RightsRecord, Modality } from '@refkit/core' // Legacy media.kind control values — kept in the dynamic enum because they stay @@ -23,18 +23,35 @@ const VERSION: string = (() => { } })() +// Agent context is the scarce resource: descriptions are capped and tags trimmed to the +// leading few (sources list the most specific subjects first). +const AGENT_DESCRIPTION_MAX = 280 +const AGENT_TAGS_MAX = 8 + +function capText(text: string | undefined, max: number): string | undefined { + if (text === undefined || text.length <= max) return text + return `${text.slice(0, max - 1).trimEnd()}…` +} + // Concise, agent-facing projection of a Reference (no raw provider dump). When an // intent is supplied the use-gate verdict + attribution ride along, so the agent // sees *whether it may use* each result — not just a bare license id it ignores. +// `sourceScore` stays out on purpose: it is in each source's own scale and only orders +// results within one source; `relevance` is the cross-source score an agent can compare. function toAgentRef(r: Reference, assessment?: { verdict: Verdict; attribution: Attribution }) { const base = { id: r.id, title: r.title, + description: capText(r.description, AGENT_DESCRIPTION_MAX), + tags: r.tags && r.tags.length > 0 ? r.tags.slice(0, AGENT_TAGS_MAX) : undefined, modality: r.modality, kind: r.kind, provider: r.source.providerId, canonicalUrl: r.canonicalUrl, license: r.rights.license, + author: r.rights.author, + rehostPolicy: r.rights.rehostPolicy, + relevance: Math.round(r.relevance * 1000) / 1000, thumbnail: r.thumbnail?.url, excerpt: r.text?.excerpt, } @@ -53,11 +70,20 @@ function toAgentRef(r: Reference, assessment?: { verdict: Verdict; attribution: const agentRefSchema = z.object({ id: z.string(), title: z.string().optional(), + description: z.string().optional().describe(`the source's own caption or description, capped at ${AGENT_DESCRIPTION_MAX} characters`), + tags: z.array(z.string()).optional().describe(`the source's subjects or tags, first ${AGENT_TAGS_MAX}`), modality: z.string(), kind: z.string().optional().describe('fine-grained resource kind, e.g. photo / texture / ebook'), provider: z.string(), canonicalUrl: z.string(), license: z.string(), + author: z.string().optional().describe('creator as declared by the source'), + rehostPolicy: z.enum(REHOST_POLICIES).describe( + 'what the source lets you do with the media file — respect it before downloading or storing anything: ' + + 'hotlink-required = link to canonicalUrl, never copy the file; cache-allowed = you may download and cache it; ' + + 'thumbnail-only = you may store the thumbnail but not the full file; no-store = store nothing, not even the thumbnail', + ), + relevance: z.number().describe('0..1 cross-source relevance after fusion/rerank; compare results by this'), thumbnail: z.string().optional(), excerpt: z.string().optional(), useVerdict: z @@ -94,7 +120,8 @@ export function createRefkitMcpServer(refkit: RefkitClient): McpServer { 'Search license-normalized reference material (image / video / audio / text) across the configured sources. ' + 'Every result carries a license id + canonical source link. Pass `intent` to annotate each result with a ' + 'use-verdict (may I use this, is attribution required) WITHOUT filtering; pass `gateFor` to instead return ' + - 'only results whose license allows that intent. Results are references, not rights clearance — not legal advice.' + + 'only results whose license allows that intent. Results are references, not rights clearance — not legal advice. ' + + 'Before downloading or storing a result\'s media, follow its `rehostPolicy` (some sources only allow hotlinking or thumbnails).' + '\n\nConfigured sources:\n' + sourceList, inputSchema: { query: z.string().describe('what to search for, e.g. "cyberpunk alley at night"'), From 6713f234d145b28e8e81c63babce642224101db0 Mon Sep 17 00:00:00 2001 From: lizhixuan Date: Mon, 12 Oct 2026 23:04:05 +0800 Subject: [PATCH 02/12] docs: design spec and plan for Reference by modality and source naming --- ...-10-12-modality-union-and-source-naming.md | 111 ++++++++++++ ...modality-union-and-source-naming-design.md | 169 ++++++++++++++++++ 2 files changed, 280 insertions(+) create mode 100644 docs/superpowers/plans/2026-10-12-modality-union-and-source-naming.md create mode 100644 docs/superpowers/specs/2026-10-12-modality-union-and-source-naming-design.md diff --git a/docs/superpowers/plans/2026-10-12-modality-union-and-source-naming.md b/docs/superpowers/plans/2026-10-12-modality-union-and-source-naming.md new file mode 100644 index 0000000..7b7be3e --- /dev/null +++ b/docs/superpowers/plans/2026-10-12-modality-union-and-source-naming.md @@ -0,0 +1,111 @@ +# Reference by Modality and Source Naming Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task by task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Make `Reference` a union keyed by modality, with audio and video metadata. Give the catalog a search queries one name: "source". Fix the defects found in the same files. + +**Spec:** `docs/superpowers/specs/2026-10-12-modality-union-and-source-naming-design.md`. Sections D1–D7 are binding. D8, the dsh plugin, is a separate follow-up after the refkit packages are on npm. + +**Tech stack:** TypeScript strict, a pnpm workspace, vitest (`pnpm test:run`), zod 4, tsup. + +## Global Constraints + +- `@refkit/core` depends only on `zod`. No `fetch(` call and no `http(s)://` literal in `packages/core/src`; `no-network.test.ts` enforces this. +- Every `@refkit/provider-*` package depends only on `@refkit/core`. +- No compatibility shims and no deprecated aliases. Renamed surfaces are renamed everywhere in this repo. +- Each task ends green: `pnpm typecheck && pnpm lint && pnpm build && pnpm test:run && pnpm smoke:artifact`. +- Write tests first where behaviour changes, and show RED before GREEN in the report. +- Commits use conventional prefixes, with no attribution or Co-authored-by trailers. Never `git stash`. Never push. Work only in /Users/xuan/Desktop/testSpace/refkit/.claude/worktrees/jolly-heyrovsky-787cc1 on branch `feat/modality-union-source-naming`. +- Implementers do not dispatch subagents. +- Changesets are written in Task 4 only. + +--- + +### Task 1: core union, schemas, modality boundary, testkit (spec D1, D2, D3, D6.2 core and testkit, D6.7) + +**Files:** `packages/core/src/reference.ts`, `packages/core/src/index.ts`, `packages/core/src/provider-run.ts` (cache key, modality rejection), `packages/core/src/rerank.ts` / `confidence.ts` / `dedup.ts` (only if needed to compile), `packages/provider-testkit/src/index.ts`, plus tests in `packages/core/src/__tests__/` and the testkit's tests. + +- [ ] **Step 1.** Type tests first, in a `reference-types.test.ts` using `expectTypeOf` and `@ts-expect-error`: + - each `Emitted…` variant rejects another variant's field (`visual` on text, `text` on image, `audio` on video, and so on); + - `completeReference` returns a `Reference` whose `modality` still narrows to the right variant. +- [ ] **Step 2.** Implement D1/D2 in `reference.ts`: + - the variant interfaces, with the other variants' fields typed `?: never`; + - `VideoMeta` and `AudioMeta`; + - `EmittedReference` and `Reference` as union aliases; + - `completeReference` keeps the discriminant. + + Export the new types from `index.ts`, and document `thumbnail` as "an image a UI can show". Retype the fixture builders that spread `Partial`/`Partial` over an image default (`client.test.ts:1059`, `confidence.test.ts:7`, `dedup.test.ts:5`, `rerank.test.ts:6`, testkit `testkit.test.ts:7`) to `Partial`/`Partial`. +- [ ] **Step 3.** Schema tests first, then implement D3: + - `z.discriminatedUnion('modality', …)` built from one `satisfies Record` table through a generic `variant(m, tail)` helper called once per modality in an explicit tuple, plus a type test that `EmittedReference['modality']` is exactly `Modality`; + - a per-variant `preview.mediaType` prefix check; + - cross-modality keys stripped. + + Keep `emittedReferenceSchema: z.ZodType` and `referenceSchema: z.ZodType` assignable. +- [ ] **Step 4.** Cache key `refkit:v3` → `refkit:v4` in `provider-run.ts`, with a test (spec D3 explains why it is required). +- [ ] **Step 5.** D6.2, core side, exactly as the spec words it. In `runProviderSearch`'s live path, an emitted item whose `modality` is not in `query.modalities` (the per-source normalized list, `SearchInput.modalities ∩ provider.modalities`) is counted as **rejected** on the existing malformed-item path: counted in `rejected` and firing the error callback. Update the `rejected` doc comment. Test a provider declaring `['image']` that emits `video` while the caller asks for `['image','video']`. +- [ ] **Step 6.** Testkit (spec D6.7, D3): + - drop the testkit's own preview `mediaType` check, which the schema now enforces; `testkit.test.ts:63-67` expects the `failed emittedReferenceSchema` message; + - run the thumbnail landing-page rule per item for every modality; + - assert `ref.modality ∈ provider.modalities`; + - assert `parseEmitted(item)` lost no keys: top-level keys and the keys of nested known objects, skipping `raw`. + + Run every provider package's tests. A failure that is a real provider defect is reported, not hidden; fix it only if it belongs to this task, otherwise note it for Task 3. +- [ ] **Step 7.** Gate, then commit `feat(core)!: Reference is a union keyed by modality, with audio and video metadata`. + +### Task 2: one name for "source" (spec D4, D6.3 core, D6.4, D6.6; MCP renames from D7) + +**Files:** +- core: `client.ts`, `select.ts`, `schemas.ts`, `pipeline.ts`, `provider-run.ts`, `provider.ts`, `reference.ts`, `dedup-key.ts`, `index.ts`, `rerank.ts`; +- `packages/core/README.md`, adding the two-line glossary from D4; +- `packages/mcp/src/index.ts` and `cli.ts` where needed; +- every test that reads a renamed surface; +- provider tests only where they read renamed core surfaces. + +- [ ] **Step 1.** Rename exactly per the D4 table: old name → new name, including `PassDeps.providerOptions` and `normalizeQuery`'s `providerOptions` input, and every message string. Keep everything D4 lists as "unchanged" (`NormalizedQuery.providerOptions`, `PassDeps.providers`, `ProviderSelection`, `providerCacheKey`, `ProviderRun`, `ProviderRunDeps`, and the rest). Grep the repo afterwards, including READMEs, docstrings and `scripts/` (e.g. `scripts/build-hero.mts`), for each old identifier and paste the result in the report. Hits are allowed only in CHANGELOG.md files and docs/superpowers. +- [ ] **Step 2.** D6.4, tests first. Export `SourceSelectionError extends Error` with `code: 'no-matching-source' | 'no-source-for-query'` and the fields `requested?`, `unknown`, `modalities` and `kind?`, exactly as spec D6.4 defines them. Throw it from both selection throws, and test each case the spec lists: unknown id, known id without the modality, kind miss, `sources: []`, and no source for the query. MCP's tool-error path detects it with `instanceof` and `code === 'no-matching-source'`, not by inspecting the message; other errors raised while `sources` is set now throw. +- [ ] **Step 3.** D6.3, core side, tests first. `createRefkit` throws on duplicate provider ids, and the message names the id. Check the MCP CLI's default provider list and every test fixture for duplicates. +- [ ] **Step 4.** D6.6, tests first. Unknown ids in `sourceOptions` produce a warning in `meta.warnings`, worded like the existing unknown-`sources` warning. +- [ ] **Step 5.** MCP renames from D7: input `sourceOptions`, agent ref `source`. Update the MCP tests and the MCP README lines that name these. +- [ ] **Step 6.** Gate, then commit `feat(core)!: one name for the catalog a search queries — source`. + +### Task 3: providers — metadata, defects (spec D5, D6.1, D6.2 IA, D6.3 polyhaven, D6.5, D6.8) + +**Files:** `packages/provider-{openverse,freesound,jamendo,pexels,pixabay,internet-archive,polyhaven,flickr}/src/index.ts` and their tests and fixtures. + +- [ ] **Step 1.** D5, with a fixture test first for each field. + - openverse-audio: `audio.durationSec` (ms/1000), `bitrateKbps` (bps/1000, null for Jamendo-sourced items), `sampleRateHz` (often null), and `format` only when `filetype` is a key of `AUDIO_MIME`, with `mp31`/`mp32` normalised to `mp3`. + - freesound: `audio.durationSec`. + - jamendo: `audio.durationSec`. Confirm the field from the fixture or the vendor docs, and add it to the typed interface. + - pexels-video and pixabay-video: `video.durationSec`. + - Null or non-finite values leave the field unset. +- [ ] **Step 2.** D6.1. openverse-audio's `thumbnail` comes from the upstream `thumbnail` (album art), unset when null; the waveform URL is dropped. Update the fixture test that pins `waveform`. +- [ ] **Step 3.** D6.5. + - pixabay-video: `kind` from the upstream `type` (`film` / `animation`), unset otherwise. + - openverse-audio: `kind` from `category`. First verify live (keyless): `curl 'https://api.openverse.org/v1/audio/?q=rain&page_size=5'` and `?q=piano`. Map only values that clearly mean music or sound effect, and paste the observed values. Expect only `music` for Jamendo-sourced items and `null` for Freesound-sourced ones. If Openverse is unreachable from this network (DNS has returned wrong addresses before), say so, and rely on the fixture and the spec reviewer's live observation. +- [ ] **Step 4.** D6.2 for Internet Archive. Derive the `mediatype:` filter from the requested modalities (video → movies, text → texts), and return `[]` without a request when neither is requested. The query-escaping test's expected clause (`internet-archive.test.ts:194-198`) changes to `mediatype:(movies)` for a video-only query. Verify live (keyless) that a text-only query returns no movies. +- [ ] **Step 5.** D6.3. `polyhaven({ assetType: 'hdris' })` uses the id `polyhaven-hdri`. Update its tests and README, and grep the repo for the old assumption. +- [ ] **Step 6.** D6.8. Flickr and pixabay-video stop setting `visual` from a rendition; the rendition size stays on `preview`. Update the pins `flickr.test.ts:59` and `pixabay.test.ts:134`. pixabay-video skips a rendition whose `url` is empty or whose size is 0 when choosing the preview, and gets a test. +- [ ] **Step 7.** `REFKIT_LIVE=1 pnpm test:live` once, then the gate. Paste the live summary: a 403 WAF skip is fine, and keyed sources skip without keys. Commit `feat(providers): audio and video durations, honest thumbnails and kinds, no modality leaks`. + +### Task 4: MCP fields, docs, changesets (spec D7 additive part, release notes) + +- [ ] **Step 1.** Test first. The MCP agent ref gains optional `durationSec` (from `audio`/`video`) and `width`/`height` (from `visual`, else from `preview.width/height`), and the output schema describes them. +- [ ] **Step 2.** Docs: + - root README and core README: the variants table and the glossary; + - the MCP README output line; + - the provider READMEs whose output changed (openverse, polyhaven). + + Remove stale names. +- [ ] **Step 3.** One changeset (spec, Release line) listing `@refkit/core`, `@refkit/mcp` and **all 19** `@refkit/provider-*` packages as **minor**; `@refkit/provider-testkit` is private and needs none. It says in plain words: + - what changed: the union and its type-alias consequences, every D4 rename, the D5 fields and the D6 fixes; + - how to migrate; + - that `polyhaven-hdri` is breaking for HDRI users; + - that `visual` now means the original's size. +- [ ] **Step 4.** Gate, then commit `feat(mcp): duration and dimensions for agents; docs and changesets for the source/modality release`. + +--- + +## After the plan + +1. Final whole-branch review. Rebase onto main once #33 is merged, then open the PR. The user merges it, and later merges the Version Packages PR. +2. Follow-up D8 in the dsh-plugin repo, with its own brief, after the new packages are on npm. diff --git a/docs/superpowers/specs/2026-10-12-modality-union-and-source-naming-design.md b/docs/superpowers/specs/2026-10-12-modality-union-and-source-naming-design.md new file mode 100644 index 0000000..16f792b --- /dev/null +++ b/docs/superpowers/specs/2026-10-12-modality-union-and-source-naming-design.md @@ -0,0 +1,169 @@ +# Reference by modality, one word for "source" — design spec + +Status: decided by the controller under the user's delegation ("按你的优先级来做吧", 2026-10-12). This covers items 10 and 11 of the 2026-09 architecture review. Research with file:line evidence is in the session scratchpad (`major-research.md`); the facts this spec relies on are restated here. + +Release: one changeset lists `@refkit/core`, `@refkit/mcp` and all 19 `@refkit/provider-*` packages as `minor` (0.x breaking, as `4dfd41a` did); `@refkit/provider-testkit` is private and needs none. Providers pin core exactly when published, so hosts upgrade core and every provider together; a provider released only by a forced patch would leak its behaviour change into dsh 0.2.0's caret ranges, which is why every provider gets a minor. Preferably this ships in the same Version Packages PR as #33's changeset (core 0.9.0 → 0.10.0 once); if #33 has already shipped alone, this release is 0.11.0 and nothing else changes. Then a lockstep `@refkit/dsh-plugin` 0.3.0 (separate repo, D8). This builds on #33 (`REHOST_POLICIES`, agent-ref fields). + +## Why + +- **Item 10.** `EmittedReference`/`Reference` is one flat shape: any modality may carry `visual`, `text` or `preview`, and nothing says which belong together. Audio and video have no metadata at all, although every audio and video API returns a duration. Freesound even requests `duration` and drops it. +- **Item 11.** One concept, "the catalog a search queries" (`provider.id`, e.g. `openverse-audio`), has three names: callers pass `sources`, results report `meta.providers[].providerId`, options are `providerOptions`, errors go to `onProviderError`, and each reference names it `source.providerId`. Every user-facing surface (MCP params, dsh config and copy, READMEs) already says "source". + +## Goals + +1. A `Reference` type whose variants say which metadata fits which modality, with the type system catching a provider that writes the wrong field. +2. Duration and audio/video metadata filled wherever the existing responses already carry them (no extra requests). +3. One vocabulary: a **provider** is adapter code; each provider instance serves one **source**, whose id is what callers pick and what results report. +4. Fix the defects the research found in the same files (listed under D6). + +## Non-goals + +- No change to the MCP `sources` param or the dsh `sources` config field (stored user configs and learned agent calls keep working). +- No new upstream requests (no Freesound `FIELDS` expansion, no Internet Archive per-item metadata calls, no Flickr `o_dims`). +- No change to ranking weights, the use-gate, license facts or attribution. +- The other review leftovers (Wikimedia housekeeping categories, deadline/`withDefaultUserAgent`/controls-boundary test gaps, the AggregateError → deadline error, the three one-line nits) stay out of this release. + +## Decisions + +### D1 — Variants (core `reference.ts`) + +Common to every variant: `modality` (discriminant), `kind?`, `title?`, `description?`, `tags?`, `sourceUrl` / `canonicalUrl?` (emitted) or `canonicalUrl` (complete), `rights`, `thumbnail?`, `sourceScore?`, `raw?`, plus the core-stamped `id`, `source`, `verifiedAt`, `relevance`. + +- `thumbnail` stays common and is documented as **an image a UI can show**: the picture itself, a video poster, audio cover art or a book cover. It must never be a non-image URL. + +| Variant | Own fields | +|---|---| +| `image` | `preview?: MediaPreview` (mediaType `image/*`), `visual?: VisualMeta`, `perceptualHash?` | +| `video` | `preview?: MediaPreview` (mediaType `video/*`), `visual?: VisualMeta` (source dimensions), `video?: VideoMeta` | +| `audio` | `preview?: MediaPreview` (mediaType `audio/*`), `audio?: AudioMeta` | +| `text` | `text?: TextMeta` | + +New types: + +```ts +export interface VideoMeta { durationSec?: number; fps?: number } +export interface AudioMeta { durationSec?: number; bitrateKbps?: number; sampleRateHz?: number; channels?: number; format?: string } +``` + +- **Units.** Durations are seconds as a float (Freesound reports `2.5`); Openverse's milliseconds are divided by 1000. Bitrate is kbps; Openverse's bps are divided by 1000. +- **`visual` means the original's dimensions** for image and video. An emitter that only knows a rendition's size leaves `visual` unset (D6.8). + +### D2 — TypeScript shape: a soft union + +- Each variant is an interface; the fields of the other variants are declared `?: never` on it. Writers get the wrong-field error that is the point of item 10. Readers such as `refText`, `lexicalHit`, `qualityScores`, MCP `toAgentRef` and the dsh `toTile` keep compiling: `r.visual` is `VisualMeta | undefined` on the union. Fixture builders that spread a `Partial` / `Partial` over an image default do not compile (core `client.test.ts:1059`, `confidence.test.ts:7`, `dedup.test.ts:5`, `rerank.test.ts:6`, testkit `testkit.test.ts:7`, dsh `tests/search.test.ts:9`): retype their override parameter to `Partial` / `Partial`. +- Exported types: `ImageReference`, `VideoReference`, `AudioReference`, `TextReference` and their `Emitted…` counterparts; `EmittedReference` and `Reference` become union type aliases. `VideoMeta` and `AudioMeta` are new exports. `ReferenceMedia`, `MediaPreview`, `VisualMeta` and `TextMeta` stay. +- `Reference` can no longer use `interface … extends Omit<…>`. It is built per variant: the emitted variant minus `sourceUrl`/`canonicalUrl`, plus the core-stamped fields. `completeReference` must keep the discriminant through its rest-spread; verify by type tests. +- A strict union (fields absent on other variants) is rejected: 8 more code edits and about 16 more test reads, on top of the 6 fixture-builder retypes both options need, for no extra safety on the emit side. +- Consequences for hosts, stated in the changeset: `Reference` is a type alias, so it can no longer be augmented by declaration merging, and `Omit` in host code collapses to the common keys (use a distributive omit). + +### D3 — Runtime schemas + +- `emittedReferenceSchema` and `referenceSchema` become `z.discriminatedUnion('modality', …)` over the four variants, built from one table (`satisfies Record`) by a generic `variant(m, tail)` helper called once per modality in an explicit tuple (`MODALITIES.map` does not type-check as a non-empty tuple). A type test asserts `EmittedReference['modality']` is exactly `Modality`, so a new modality cannot be half-added. +- `preview.mediaType` must start with the variant's prefix (`image/`, `video/`, `audio/`). A mismatch rejects the item at the boundary, like any other malformed item. +- Unknown keys are **stripped** (default `z.object`). Rejecting the whole item would be too harsh at a boundary built to drop single bad items. The provider testkit instead asserts that `parseEmitted(item)` lost no keys (top-level keys and the keys of nested known objects, skipping `raw`), so a provider writing a cross-modality field fails its own conformance tests. +- The cache key prefix goes `refkit:v3` → `refkit:v4`, so entries written in the old shape are missed rather than parsed: with `source.providerId` renamed to `source.id` (D4), every old item would otherwise fail `parseReference`, be counted as rejected and empty the search for the TTL. The bump is required. +- The zod output types must stay assignable to the TS types (`z.ZodType` / `z.ZodType`), as today. + +### D4 — Naming: scheme B + +Rule (goes into the core README as a two-line glossary): *A provider is adapter code (a `ReferenceProvider` from a `@refkit/provider-*` package). Each provider instance serves one source, whose id (`provider.id`) is what `sources`, `sourceOptions` and `meta.sources` speak.* + +| Package | Old | New | +|---|---|---| +| core meta | `SearchMeta.providers` | `sources` | +| core meta | `ProviderSearchStatus` (`providerId`), `providerSearchStatusSchema` | `SourceStatus` (`sourceId`), `sourceStatusSchema` | +| core meta | `controls.appliedByProvider` / `ignoredByProvider` | `appliedBySource` / `ignoredBySource` | +| core meta | `SearchMeta.providerOptions` | `sourceOptions` | +| core skip vocab | `PROVIDER_SKIP_REASONS`, `ProviderSkipReason` | `SOURCE_SKIP_REASONS`, `SourceSkipReason` (values unchanged) | +| core input | `SearchInput.providerOptions`, `ProviderOptionsById` | `sourceOptions`, `SourceOptionsById` | +| core input | `onProviderError`, `ProviderError { providerId }` | `onSourceError`, `SourceError { sourceId }` | +| core reference | `Reference.source { providerId, sourceUrl }` | `source { id, url }` | +| core pipeline (exported) | `PassDeps.onProviderError`, `PassDeps.providerOptions`, `PassOutcome.statusByProvider`, `normalizeQuery` input `providerOptions` | `onSourceError`, `sourceOptions`, `statusBySource`, `sourceOptions` | +| core messages | "no configured provider matches source id(s)", "no registered provider supports modalities", "all providers failed", "n provider(s) failed" | "no configured source matches id(s)", "no configured source supports modalities", "all sources failed", "n source(s) failed" | +| mcp input | `providerOptions` | `sourceOptions` | +| mcp output | agent ref `provider` | `source` | + +Unchanged on purpose: `RefkitOptions.providers`, `RefkitClient.providers`, `PassDeps.providers`, `ProviderSelection`, `providerCacheKey`, `ProviderRun`, `ProviderRunDeps`, `ReferenceProvider`, `defineProvider`, `ProviderContext`, `ProviderCapabilities`, `ProviderOptions`, `ProviderOptionValue`, **`NormalizedQuery.providerOptions`** (adapter side: "this provider's own options"; the 19 packages keep reading it), `selectProviders`, `runProviderSearch`, `SearchInput.sources`, `sourceConfidence`, `sourceDiversity`, `sourceScore`, package and factory names, `XxxSearchOptions`, the MCP `sources` param, the MCP CLI env names. + +Positional parameters named `providerId` (e.g. `completeReference`, `referenceId`) are renamed `sourceId` (not breaking). + +### D5 — Metadata filled in this release (no extra requests) + +| Source | Field | From | +|---|---|---| +| openverse-audio | `audio.durationSec` | `duration` ms / 1000 | +| openverse-audio | `audio.bitrateKbps`, `audio.sampleRateHz`, `audio.format` | `bit_rate` / 1000 (null for Jamendo-sourced items), `sample_rate` (often null), `filetype` only when it is a key of `AUDIO_MIME`, with Jamendo's `mp31`/`mp32` codes normalised to `mp3` | +| freesound | `audio.durationSec` | `duration` (already requested) | +| jamendo | `audio.durationSec` | `duration` (verify it is in the response; add to the typed interface) | +| pexels-video | `video.durationSec` | `duration` | +| pixabay-video | `video.durationSec` | `duration` | + +Null or non-finite upstream values leave the field unset. Internet Archive's `runtime` is skipped (sparse, ambiguous forms). + +### D6 — Defects fixed in the same release + +1. **openverse-audio thumbnail is JSON.** It points at the `waveform` endpoint (`application/json` peak data). Use the upstream `thumbnail` (album art) instead, unset when null; drop the waveform. The fixture that pins the waveform URL changes. +2. **Modality leaks.** + - Internet Archive ignores `q.modalities` and always asks for `movies OR texts`; derive the mediatype filter from the requested modalities (`movies` for video, `texts` for text; when `q.modalities` contains neither, return `[]` without a request). + - Core (`runProviderSearch`, live path) counts an emitted item whose `modality` is not in `query.modalities` (the per-source normalized query, i.e. `SearchInput.modalities ∩ provider.modalities`) as **rejected**, the same path as a malformed item: it is counted in `rejected` and fires `onSourceError`. Selection already throws on an empty `modalities`, so the list is never empty through `createRefkit`. Cached entries were checked when written. Update the `rejected` doc on `SourceStatus`. + - The testkit asserts `ref.modality ∈ provider.modalities`. +3. **Duplicate source ids pass silently.** + - `createRefkit` throws when two providers share an id. + - `polyhaven({ assetType: 'hdris' })` gets the id `polyhaven-hdri`, so textures and HDRIs can coexist. This is breaking for HDRI users (reference ids, cache keys, `sources`/`sourceOptions` keys change) and the changeset says so. +4. **Typed selection error.** Export `SourceSelectionError` for both selection throws: `code: 'no-matching-source'` when `sources` was given and nothing was chosen (unknown id, known id lacking the modality or kind, or `sources: []`), `'no-source-for-query'` otherwise; fields `requested?: string[]` (the `sources` as passed), `unknown: string[]` (ids that match no configured source), `modalities: Modality[]`, `kind?: string`. The dsh plugin stops string-matching core's message, which this release changes anyway. +5. **Declared kinds that are never emitted.** + - pixabay-video: `kind` from the upstream `type` (`film` / `animation`). + - openverse-audio: `kind` from the upstream `category` when it maps to `music` or `sound-effect`. The implementer verifies the field and its values live (keyless). Expect only `music` (Jamendo-sourced items); Freesound-sourced items carry `category: null`. +6. **Unknown ids in `sourceOptions`** produce a warning, as unknown ids in `sources` already do. +7. **Testkit per-item rules.** The preview `mediaType` prefix is enforced by D3's schema, so the testkit drops its own check (`testkit.test.ts:63-67` now expects the `failed emittedReferenceSchema` message). The thumbnail landing-page rule runs per item for every modality, not only when `provider.modalities` includes `image`. +8. **`visual` semantics.** Flickr (≤1024px rendition) and pixabay-video (first available rendition) stop setting `visual`; the rendition size stays on `preview`. Pins `flickr.test.ts:59` and `pixabay.test.ts:134` change. pixabay-video also skips a rendition whose `url` is empty or whose size is 0 when choosing the preview (vendor docs say an absent `large` comes back that way). + +### D7 — `@refkit/mcp` + +- Renames per D4: `sourceOptions` input, agent ref `source`. No alias for `providerOptions` (no shims; nobody depends on it yet) — see Risks. +- The agent ref gains optional `durationSec` (from `audio`/`video`) and `width`/`height` (from `visual`, else from `preview.width/height`: aspect ratio is rendition-independent), so agents can filter by length or resolution. It stays a flat projection; the output schema does not become a union. +- The "unknown sources" tool error is detected through `SourceSelectionError` with `code === 'no-matching-source'`, not a message heuristic. Other errors raised while `sources` is set (e.g. an invalid cursor) now throw instead of becoming an `isError` result. + +### D8 — `@refkit/dsh-plugin` 0.3.0 (separate repo, after the refkit packages are on npm) + +- Dependencies move to the new core and provider versions (the `^0.9` range does not admit the new minor): core `^0.10.0` (or `^0.11.0` if #33 shipped alone) and every provider at its new minor. +- Add a `polyhaven-hdri` source (the tool description already advertises HDRIs): append it to `SHIPPED_IDS`/`EXPECTED_IDS`, mirror it in `SOURCE_IDS`, and update the "23 sources" copy to 24. +- dsh already exports its own `SourceStatus` (a different shape); alias the core import. Renaming the public `PROVIDER_IDS` export is breaking for the plugin's API and goes in its changelog. +- Adapt to D4: + - read `meta.sources`, `sourceId` and `onSourceError`; + - detect the selection miss with `SourceSelectionError`. +- Naming inside the plugin: + - the host's `PROVIDER_IDS` becomes `SOURCE_IDS` and `RETIRED_IDS` becomes `RETIRED_SOURCE_IDS`, matching the client mirror; + - copy that says "provider ids" says "source ids" (dsh uses "provider" for LLM providers); + - the `sources` config field, tool param and output `sources[]` stay. +- The tile's `provider` key becomes `source`. `narrowTile` accepts either key for one release, so cards persisted in old conversations still render. +- The tile gains `durationSec`, and its `width`/`height` fall back to `preview.width/height` when `visual` is unset. Audio and video tiles show the duration as a small `m:ss` badge. +- Video tiles render their poster (`thumbnail`) with a ▶ overlay instead of a bare glyph. +- The `refkit_search` tool output schema follows (additive `durationSec`; `provider` → `source`). + +## Testing + +- **Core.** + - Type tests: each variant rejects another variant's field (`@ts-expect-error`); `completeReference` keeps the discriminant. + - Schema tests: each variant parses; a wrong preview prefix rejects; a cross-modality field is stripped. + - A modality outside the query is counted as rejected. + - Duplicate ids throw. + - `SourceSelectionError` carries its code and ids. + - Unknown `sourceOptions` ids warn. + - The cache key is `v4`. +- **Providers.** Fixture tests for every D5 field and every D6 fix. Keyless live checks: openverse-audio duration, bitrate, kind and album art; Internet Archive text-only. The full live smoke runs with `REFKIT_LIVE=1`. +- **MCP.** Renamed input and output; `durationSec`/`width`/`height` projected; selection error path. +- **Providers, pinned tests that change.** IA's query-escaping test (`internet-archive.test.ts:194-198`, `mediatype:(movies)` for a video-only query), openverse's waveform pin, flickr and pixabay-video `visual` pins, the testkit preview test. +- **Sweep.** Grep READMEs, docstrings and `scripts/` (e.g. `scripts/build-hero.mts`) for every D4 old name. +- **Gate.** `pnpm typecheck && pnpm lint && pnpm build && pnpm test:run && pnpm smoke:artifact` clean. +- **dsh 0.3.0.** Its gate, plus live acceptance in the local dsh web profile: + - an audio search shows durations and album art; + - a video search shows posters; + - a persisted 0.2.0 card still renders. + +## Risks + +- Downstream code that reads `meta.providers`, `ref.source.providerId` or `onProviderError` breaks at compile time. That is intended for a breaking minor; the changesets spell out each rename. +- MCP callers that still send `providerOptions` have it silently stripped by the MCP SDK (it strips unknown input keys). Accepted: no known users, and the tool schema advertises `sourceOptions`. +- Stripping unknown keys could hide a provider bug in production. The testkit's lost-keys assertion catches it in each provider's own tests. +- Some D5 and D6 fields come from keyed APIs that cannot be checked live here (freesound, jamendo, pexels, pixabay). They are taken from fixtures and vendor docs and are optional, so a wrong guess leaves the field unset rather than failing. From 0c9465923f62008d58d61491a97da9bf7468585e Mon Sep 17 00:00:00 2001 From: lizhixuan Date: Mon, 12 Oct 2026 23:18:04 +0800 Subject: [PATCH 03/12] feat(core)!: Reference is a union keyed by modality, with audio and video metadata --- packages/core/src/__tests__/client.test.ts | 31 ++- .../core/src/__tests__/confidence.test.ts | 6 +- packages/core/src/__tests__/dedup.test.ts | 4 +- .../core/src/__tests__/provider-run.test.ts | 50 ++++- .../src/__tests__/reference-types.test.ts | 187 ++++++++++++++++++ packages/core/src/__tests__/reference.test.ts | 53 +++++ packages/core/src/__tests__/rerank.test.ts | 21 +- packages/core/src/client.ts | 6 +- packages/core/src/index.ts | 10 + packages/core/src/provider-run.ts | 16 +- packages/core/src/reference.ts | 166 +++++++++++++--- .../src/__tests__/testkit.test.ts | 70 ++++++- packages/provider-testkit/src/index.ts | 82 +++++--- 13 files changed, 618 insertions(+), 84 deletions(-) create mode 100644 packages/core/src/__tests__/reference-types.test.ts diff --git a/packages/core/src/__tests__/client.test.ts b/packages/core/src/__tests__/client.test.ts index d907fd4..66b7b80 100644 --- a/packages/core/src/__tests__/client.test.ts +++ b/packages/core/src/__tests__/client.test.ts @@ -4,10 +4,10 @@ import { searchMetaSchema } from '../schemas' import { cursorSeenKey, decodeCursor } from '../cursor' import { defineProvider, type ReferenceProvider } from '../provider' import { lexicalReranker } from '../rerank' -import { completeReference, type EmittedReference } from '../reference' +import { completeReference, type EmittedImageReference, type EmittedReference } from '../reference' import type { LicenseId } from '../license' -const ref = (url: string, license: LicenseId = 'CC0-1.0'): EmittedReference => ({ +const ref = (url: string, license: LicenseId = 'CC0-1.0'): EmittedImageReference => ({ modality: 'image', sourceUrl: url, rights: { license, rehostPolicy: 'cache-allowed', raw: { sourceTerms: 't', sourceUrl: url } }, @@ -407,6 +407,31 @@ describe('createRefkit', () => { ]) }) + it('rejects an item whose modality the source was not asked for: counted, reported, dropped', async () => { + // The caller asks for image and video, but this source declares image only, + // so its share of the query is ['image'] and the video item is a leak. + const leaky = defineProvider({ + id: 'leaky', + modalities: ['image'], + search: async () => [ + ref('https://leaky/1'), + { modality: 'video', sourceUrl: 'https://leaky/2', rights: ref('https://leaky/2').rights }, + ], + }) + const onProviderError = vi.fn() + const rk = createRefkit({ providers: [leaky] }) + const out = await rk.searchWithMeta({ query: 'x', modalities: ['image', 'video'], onProviderError }) + expect(out.references.map(r => r.canonicalUrl)).toEqual(['https://leaky/1']) + expect(out.meta.providers).toEqual([ + { providerId: 'leaky', status: 'fulfilled', returned: 2, accepted: 1, rejected: 1, latencyMs: expect.any(Number), confidence: 1 }, + ]) + expect(onProviderError).toHaveBeenCalledTimes(1) + expect(onProviderError).toHaveBeenCalledWith(expect.objectContaining({ + providerId: 'leaky', + error: expect.objectContaining({ message: expect.stringMatching(/modality "video"/) }), + })) + }) + it('uses merge.isDuplicate to dedupe host-supplied fingerprints during search', async () => { // b is emitted first, so RRF ranks it above a; the survivor must be b. const a = { ...ref('https://a/1'), raw: { fingerprint: 'same' } } @@ -1056,7 +1081,7 @@ describe('multi-pass fidelity, deadline and gate context', () => { }) describe('accuracy defaults', () => { - const titled = (url: string, title: string, extra: Partial = {}) => ({ ...ref(url), title, ...extra }) + const titled = (url: string, title: string, extra: Partial = {}) => ({ ...ref(url), title, ...extra }) it('a source whose batch never mentions the query sinks below a source that does', async () => { const noise = defineProvider({ diff --git a/packages/core/src/__tests__/confidence.test.ts b/packages/core/src/__tests__/confidence.test.ts index c16e740..196d02b 100644 --- a/packages/core/src/__tests__/confidence.test.ts +++ b/packages/core/src/__tests__/confidence.test.ts @@ -1,10 +1,10 @@ import { describe, expect, it } from 'vitest' import { lexicalHit, sourceConfidence } from '../confidence' import { tokenize } from '../rerank' -import type { Reference } from '../reference' +import type { ImageReference, Reference } from '../reference' import * as refkit from '../index' -const ref = (id: string, title: string, opts: Partial = {}): Reference => ({ +const ref = (id: string, title: string, opts: Partial = {}): Reference => ({ id, modality: 'image', title, @@ -43,7 +43,7 @@ describe('lexicalHit', () => { const q = tokenize('lion') expect(lexicalHit(q, ref('a', 'Untitled', { tags: ['lion', 'cub'] }))).toBe(true) expect(lexicalHit(q, ref('b', 'Untitled', { description: 'a sleeping lion' }))).toBe(true) - expect(lexicalHit(q, ref('c', 'Untitled', { text: { excerpt: 'the lion sleeps', excerptKind: 'passage' } }))).toBe(true) + expect(lexicalHit(q, { title: 'Untitled', text: { excerpt: 'the lion sleeps', excerptKind: 'passage' } })).toBe(true) expect(lexicalHit(q, ref('d', 'Untitled'))).toBe(false) }) diff --git a/packages/core/src/__tests__/dedup.test.ts b/packages/core/src/__tests__/dedup.test.ts index 38c9f1d..a379d09 100644 --- a/packages/core/src/__tests__/dedup.test.ts +++ b/packages/core/src/__tests__/dedup.test.ts @@ -1,8 +1,8 @@ import { describe, expect, it } from 'vitest' import { hammingDistance, dedupeReferences } from '../dedup' -import type { Reference } from '../reference' +import type { ImageReference, Reference } from '../reference' -const make = (over: Partial): Reference => ({ +const make = (over: Partial): Reference => ({ id: over.id ?? 'x', modality: 'image', source: over.source ?? { providerId: 'p', sourceUrl: 'https://x/1' }, diff --git a/packages/core/src/__tests__/provider-run.test.ts b/packages/core/src/__tests__/provider-run.test.ts index f7f2b76..d9e91dc 100644 --- a/packages/core/src/__tests__/provider-run.test.ts +++ b/packages/core/src/__tests__/provider-run.test.ts @@ -1,5 +1,5 @@ -import { describe, expect, it } from 'vitest' -import { providerCacheKey, runProviderSearch } from '../provider-run' +import { describe, expect, it, vi } from 'vitest' +import { providerCacheKey, runProviderSearch, stableStringify } from '../provider-run' import { defineProvider, type KeyValueCache, type ReferenceProvider } from '../provider' import type { EmittedReference } from '../reference' @@ -30,7 +30,7 @@ describe('providerCacheKey', () => { const a = providerCacheKey('p', { text: 'lion cub "quoted" \n spaced', modalities: ['image'] }) const b = providerCacheKey('p', { text: 'tiger', modalities: ['image'] }) expect(a).not.toBe(b) - expect(a).toMatch(/^refkit:v3:p:[a-z0-9]+$/) // no spaces/quotes → safe for strict KV backends + expect(a).toMatch(/^refkit:v4:p:[a-z0-9]+$/) // no spaces/quotes → safe for strict KV backends expect(a.length).toBeLessThan(64) }) @@ -39,6 +39,18 @@ describe('providerCacheKey', () => { const b = providerCacheKey('p', { modalities: ['image'], providerOptions: { b: 2, a: 1 }, text: 'x' }) expect(a).toBe(b) }) + + it('is version 4: an entry cached in the pre-union shape (v3) is missed, not parsed', async () => { + const q = { text: 'q', modalities: ['image' as const] } + const key = providerCacheKey('p', q) + expect(key.startsWith('refkit:v4:p:')).toBe(true) + const cache = memoryCache() + const old = { id: 'p:old', modality: 'image', source: { providerId: 'p', sourceUrl: 'https://x.test/old' }, canonicalUrl: 'https://x.test/old', rights: ref('https://x.test/old').rights, verifiedAt: '2026-01-01T00:00:00.000Z', relevance: 0 } + cache.store.set(key.replace(/^refkit:v4:/, 'refkit:v3:'), JSON.stringify({ q: stableStringify(q), refs: [old] })) + const run = await runProviderSearch(provider([ref('https://x.test/live')]), q, { fetch: (async () => new Response('')) as typeof fetch, cache, cacheTtlMs: 60_000, cacheRaw: true }) + expect(run.ok && run.cached).toBeUndefined() + expect(run.ok && run.valid.map(r => r.canonicalUrl)).toEqual(['https://x.test/live']) + }) }) describe('runProviderSearch cacheRaw', () => { @@ -78,3 +90,35 @@ describe('runProviderSearch completion', () => { expect(run.ok && run.valid[0].id.startsWith('p:')).toBe(true) }) }) + +describe('runProviderSearch modality boundary', () => { + // Declares image only, so whatever the caller asked for, its share of the + // query (query.modalities) is ['image']: a video item is a modality leak. + const leaky = defineProvider({ + id: 'p', modalities: ['image'], + search: async () => [ + ref('https://x.test/1'), + { modality: 'video', sourceUrl: 'https://x.test/2', rights: ref('https://x.test/2').rights }, + ], + }) + const fetchStub = (async () => new Response('')) as typeof fetch + + it('counts an item outside query.modalities as rejected and reports it, like a malformed item', async () => { + const onError = vi.fn() + const run = await runProviderSearch(leaky, { text: 'q', modalities: ['image'] }, { fetch: fetchStub, cacheTtlMs: 0, cacheRaw: true, onError }) + expect(run.ok && run.valid.map(r => r.canonicalUrl)).toEqual(['https://x.test/1']) + expect(run.ok && run.returned).toBe(2) + expect(run.ok && run.rejected).toBe(1) + expect(onError).toHaveBeenCalledTimes(1) + expect(onError.mock.calls[0][0]).toBeInstanceOf(Error) + expect((onError.mock.calls[0][0] as Error).message).toMatch(/modality "video".*\[image\]/) + }) + + it('never caches the rejected item', async () => { + const cache = memoryCache() + await runProviderSearch(leaky, { text: 'q', modalities: ['image'] }, { fetch: fetchStub, cache, cacheTtlMs: 60_000, cacheRaw: true }) + await new Promise(r => setTimeout(r)) // cache write is fire-and-forget + const [payload] = [...cache.store.values()] + expect(JSON.parse(payload).refs.map((r: { modality: string }) => r.modality)).toEqual(['image']) + }) +}) diff --git a/packages/core/src/__tests__/reference-types.test.ts b/packages/core/src/__tests__/reference-types.test.ts new file mode 100644 index 0000000..1ba631f --- /dev/null +++ b/packages/core/src/__tests__/reference-types.test.ts @@ -0,0 +1,187 @@ +import { describe, expect, expectTypeOf, it } from 'vitest' +import { + completeReference, parseEmitted, + type AudioMeta, type AudioReference, type EmittedAudioReference, type EmittedImageReference, + type EmittedReference, type EmittedTextReference, type EmittedVideoReference, type ImageReference, + type MediaPreview, type Modality, type Reference, type RightsRecord, type TextMeta, type TextReference, + type VideoMeta, type VideoReference, type VisualMeta, +} from '../index' + +// Type tests. tsc (`pnpm typecheck`) is the real runner: a wrong expectTypeOf +// is a compile error, and so is an @ts-expect-error line that stops erroring. +// vitest only executes the bodies, which also check the runtime values. + +const rights: RightsRecord = { + license: 'CC0-1.0', rehostPolicy: 'cache-allowed', raw: { sourceTerms: 't', sourceUrl: 'https://x/1' }, +} +const base = { sourceUrl: 'https://x/1', rights } +const visualMeta: VisualMeta = { width: 640, height: 480 } +const imagePreview: MediaPreview = { url: 'https://x/1.jpg', mediaType: 'image/jpeg' } +const videoPreview: MediaPreview = { url: 'https://x/1.mp4', mediaType: 'video/mp4' } +const audioPreview: MediaPreview = { url: 'https://x/1.mp3', mediaType: 'audio/mpeg' } +const textMeta: TextMeta = { excerpt: 'e', excerptKind: 'passage' } +const videoMeta: VideoMeta = { durationSec: 12, fps: 25 } +const audioMeta: AudioMeta = { durationSec: 2.5, bitrateKbps: 128, sampleRateHz: 44100, channels: 2, format: 'mp3' } + +// Sinks: a call checks a literal against the target type the way a provider's +// `return [{ … }]` is checked against EmittedReference[]. +const image = (r: EmittedImageReference) => r +const video = (r: EmittedVideoReference) => r +const audio = (r: EmittedAudioReference) => r +const text = (r: EmittedTextReference) => r +const emitted = (r: EmittedReference) => r +const emittedAll = (r: EmittedReference[]) => r +const complete = (r: Reference) => r + +describe('Reference variants (types)', () => { + it('the unions are exactly the four named variants', () => { + expectTypeOf().toEqualTypeOf< + EmittedImageReference | EmittedVideoReference | EmittedAudioReference | EmittedTextReference + >() + expectTypeOf().toEqualTypeOf() + }) + + it('the discriminant covers every modality, so a new one cannot be half-added', () => { + expectTypeOf().toEqualTypeOf() + expectTypeOf().toEqualTypeOf() + }) + + it('each variant accepts its own fields', () => { + const all = [ + image({ modality: 'image', ...base, preview: imagePreview, visual: visualMeta, perceptualHash: 'ff00' }), + video({ modality: 'video', ...base, preview: videoPreview, visual: visualMeta, video: videoMeta }), + audio({ modality: 'audio', ...base, preview: audioPreview, audio: audioMeta }), + text({ modality: 'text', ...base, text: textMeta }), + ] + // thumbnail is common: a picture, a poster, cover art, a book cover. + for (const r of all) emitted({ ...r, thumbnail: { url: 'https://x/t.jpg' } }) + expect(all.map(r => r.modality)).toEqual(['image', 'video', 'audio', 'text']) + }) + + it('an image rejects video, audio and text fields', () => { + // @ts-expect-error video metadata does not fit an image + image({ modality: 'image', ...base, video: videoMeta }) + // @ts-expect-error audio metadata does not fit an image + image({ modality: 'image', ...base, audio: audioMeta }) + // @ts-expect-error a text excerpt does not fit an image + image({ modality: 'image', ...base, text: textMeta }) + }) + + it('a video rejects perceptualHash, audio and text fields', () => { + // @ts-expect-error perceptualHash is image-only + video({ modality: 'video', ...base, perceptualHash: 'ff00' }) + // @ts-expect-error audio metadata does not fit a video + video({ modality: 'video', ...base, audio: audioMeta }) + // @ts-expect-error a text excerpt does not fit a video + video({ modality: 'video', ...base, text: textMeta }) + }) + + it('an audio ref rejects visual, video, perceptualHash and text fields', () => { + // @ts-expect-error audio has no pixel dimensions + audio({ modality: 'audio', ...base, visual: visualMeta }) + // @ts-expect-error video metadata does not fit audio + audio({ modality: 'audio', ...base, video: videoMeta }) + // @ts-expect-error perceptualHash is image-only + audio({ modality: 'audio', ...base, perceptualHash: 'ff00' }) + // @ts-expect-error a text excerpt does not fit audio + audio({ modality: 'audio', ...base, text: textMeta }) + }) + + it('a text ref rejects preview, visual, video, audio and perceptualHash fields', () => { + // @ts-expect-error text has no media preview + text({ modality: 'text', ...base, preview: imagePreview }) + // @ts-expect-error text has no pixel dimensions + text({ modality: 'text', ...base, visual: visualMeta }) + // @ts-expect-error video metadata does not fit text + text({ modality: 'text', ...base, video: videoMeta }) + // @ts-expect-error audio metadata does not fit text + text({ modality: 'text', ...base, audio: audioMeta }) + // @ts-expect-error perceptualHash is image-only + text({ modality: 'text', ...base, perceptualHash: 'ff00' }) + }) + + it('the union rejects a wrong field once the literal modality picks the variant', () => { + // @ts-expect-error visual on audio, written the way a provider writes it + emitted({ modality: 'audio', ...base, visual: visualMeta }) + // @ts-expect-error audio metadata on a video + emitted({ modality: 'video', ...base, audio: audioMeta }) + // @ts-expect-error the complete Reference rejects it too + complete({ modality: 'text', id: 'p:1', source: { providerId: 'p', sourceUrl: 'https://x/1' }, canonicalUrl: 'https://x/1', rights, verifiedAt: '2026-01-01T00:00:00.000Z', relevance: 0, visual: visualMeta }) + }) + + it('the `?: never` fields catch what excess-property checks miss (non-fresh objects, map callbacks)', () => { + const rows = [1] + // @ts-expect-error visual on audio, built in a map callback as providers do + emittedAll(rows.map(() => ({ modality: 'audio' as const, ...base, visual: visualMeta }))) + // @ts-expect-error a text excerpt on an image, built in a map callback + emittedAll(rows.map(() => ({ modality: 'image' as const, ...base, text: textMeta }))) + // @ts-expect-error audio metadata on a video, built in a map callback + emittedAll(rows.map(() => ({ modality: 'video' as const, ...base, audio: audioMeta }))) + // @ts-expect-error a preview on text, built in a map callback + emittedAll(rows.map(() => ({ modality: 'text' as const, ...base, preview: imagePreview }))) + const built = { modality: 'audio' as const, ...base, video: videoMeta } + // @ts-expect-error a non-fresh audio object carrying video metadata + audio(built) + }) + + it('a Modality-typed variable with common fields only still compiles (internet-archive)', () => { + const m = 'video' as Modality + expect(emitted({ modality: m, ...base, kind: 'film' }).modality).toBe('video') + }) + + it('readers of the union see each field as optional, of its own type', () => { + expectTypeOf().toEqualTypeOf() + expectTypeOf().toEqualTypeOf() + expectTypeOf().toEqualTypeOf() + expectTypeOf().toEqualTypeOf() + expectTypeOf().toEqualTypeOf() + expectTypeOf().toEqualTypeOf() + expectTypeOf().toEqualTypeOf() + expectTypeOf().toEqualTypeOf() + expectTypeOf().toEqualTypeOf() + expectTypeOf().toEqualTypeOf() + expectTypeOf().toEqualTypeOf() + expectTypeOf().toEqualTypeOf() + expectTypeOf>().toEqualTypeOf<{ title?: string; text?: TextMeta }>() + }) + + it('a complete variant drops sourceUrl and requires the core-stamped fields', () => { + expectTypeOf().not.toHaveProperty('sourceUrl') + expectTypeOf().toEqualTypeOf() + expectTypeOf().toEqualTypeOf() + expectTypeOf().toEqualTypeOf() + }) +}) + +describe('completeReference keeps the discriminant (types)', () => { + const now = '2026-01-01T00:00:00.000Z' + + it('returns a Reference', () => { + expectTypeOf(completeReference).returns.toEqualTypeOf() + }) + + it('narrows to the audio variant on modality, and the audio metadata survives', () => { + const r = completeReference('p', parseEmitted({ modality: 'audio', ...base, audio: audioMeta }), now) + if (r.modality !== 'audio') throw new Error(`modality lost: ${r.modality}`) + expectTypeOf(r).toEqualTypeOf() + expectTypeOf(r.audio).toEqualTypeOf() + expect(r.audio).toEqual(audioMeta) + }) + + it('narrows to the video variant from a typed emitted variant', () => { + const e: EmittedVideoReference = { modality: 'video', ...base, visual: visualMeta, video: videoMeta } + const r = completeReference('p', e, now) + if (r.modality !== 'video') throw new Error(`modality lost: ${r.modality}`) + expectTypeOf(r).toEqualTypeOf() + expectTypeOf(r.video).toEqualTypeOf() + expect(r.video).toEqual(videoMeta) + expect(r.visual).toEqual(visualMeta) + }) + + it('narrows to the text variant', () => { + const r = completeReference('p', { modality: 'text', ...base, text: textMeta }, now) + if (r.modality !== 'text') throw new Error(`modality lost: ${r.modality}`) + expectTypeOf(r).toEqualTypeOf() + expect(r.text).toEqual(textMeta) + }) +}) diff --git a/packages/core/src/__tests__/reference.test.ts b/packages/core/src/__tests__/reference.test.ts index 595c9cf..0456ecc 100644 --- a/packages/core/src/__tests__/reference.test.ts +++ b/packages/core/src/__tests__/reference.test.ts @@ -1,5 +1,6 @@ import { describe, expect, it } from 'vitest' import { completeReference, parseEmitted, parseReference, referenceSchema, type Reference } from '../reference' +import { MODALITIES } from '../modality' const ref: Reference = { id: 'p:abc', @@ -82,3 +83,55 @@ describe('EmittedReference → Reference', () => { expect(() => parseEmitted({ ...emitted, sourceUrl: undefined })).toThrow() }) }) + +describe('schemas: one variant per modality', () => { + const rights = { license: 'CC0-1.0', rehostPolicy: 'cache-allowed', raw: { sourceTerms: 't', sourceUrl: 'https://x/1' } } + const item = (modality: string, extra: Record = {}) => ({ modality, sourceUrl: 'https://x/1', rights, ...extra }) + const now = '2026-01-01T00:00:00.000Z' + + it.each(MODALITIES)('a minimal %s item parses, emitted and complete', (m) => { + const e = parseEmitted(item(m)) + expect(e.modality).toBe(m) + expect(parseReference(completeReference('p', e, now)).modality).toBe(m) + }) + + it('rejects an unknown modality', () => { + expect(() => parseEmitted(item('3d'))).toThrow() + }) + + it('keeps each variant\'s own fields', () => { + const image = { preview: { url: 'https://x/1.jpg', mediaType: 'image/jpeg' }, visual: { width: 4, height: 3 }, perceptualHash: 'ff00' } + const video = { preview: { url: 'https://x/1.mp4', mediaType: 'video/mp4', width: 640, height: 360 }, visual: { width: 1920, height: 1080 }, video: { durationSec: 12, fps: 25 } } + const audio = { preview: { url: 'https://x/1.mp3', mediaType: 'audio/mpeg' }, audio: { durationSec: 2.5, bitrateKbps: 128, sampleRateHz: 44100, channels: 2, format: 'mp3' } } + const text = { text: { excerpt: 'e', excerptKind: 'passage', locator: 'l. 1' } } + expect(parseEmitted(item('image', image))).toMatchObject(image) + expect(parseEmitted(item('video', video))).toMatchObject(video) + expect(parseEmitted(item('audio', audio))).toMatchObject(audio) + expect(parseEmitted(item('text', text))).toMatchObject(text) + expect(parseReference(completeReference('p', parseEmitted(item('audio', audio)), now))).toMatchObject(audio) + }) + + it('rejects a preview whose mediaType does not start with the variant\'s prefix', () => { + const preview = (mediaType: string) => ({ preview: { url: 'https://x/p', mediaType } }) + expect(() => parseEmitted(item('image', preview('text/html')))).toThrow() + expect(() => parseEmitted(item('image', preview('video/mp4')))).toThrow() + expect(() => parseEmitted(item('video', preview('image/jpeg')))).toThrow() + expect(() => parseEmitted(item('audio', preview('application/json')))).toThrow() + expect(() => parseReference({ ...ref, preview: { url: 'https://x/p', mediaType: 'audio/mpeg' } })).toThrow() + expect(parseEmitted(item('image', preview('image/svg+xml'))).preview?.mediaType).toBe('image/svg+xml') + expect(parseEmitted(item('video', preview('video/webm'))).preview?.mediaType).toBe('video/webm') + expect(parseEmitted(item('audio', preview('audio/ogg'))).preview?.mediaType).toBe('audio/ogg') + }) + + it('strips a field that belongs to another modality instead of rejecting the item', () => { + const audio = parseEmitted(item('audio', { visual: { width: 4, height: 3 }, text: { excerpt: 'e', excerptKind: 'quote' } })) + expect(audio).not.toHaveProperty('visual') + expect(audio).not.toHaveProperty('text') + const text = parseEmitted(item('text', { preview: { url: 'https://x/p', mediaType: 'text/plain' }, audio: { durationSec: 1 } })) + expect(text).not.toHaveProperty('preview') + expect(text).not.toHaveProperty('audio') + const image = parseEmitted(item('image', { video: { durationSec: 1 } })) + expect(image).not.toHaveProperty('video') + expect(parseReference({ ...ref, audio: { durationSec: 1 } })).not.toHaveProperty('audio') + }) +}) diff --git a/packages/core/src/__tests__/rerank.test.ts b/packages/core/src/__tests__/rerank.test.ts index 76800eb..5d161af 100644 --- a/packages/core/src/__tests__/rerank.test.ts +++ b/packages/core/src/__tests__/rerank.test.ts @@ -1,9 +1,9 @@ import { describe, expect, it } from 'vitest' import { tokenize, lexicalReranker } from '../rerank' -import type { Reference } from '../reference' +import type { ImageReference, Reference, TextMeta } from '../reference' import * as refkit from '../index' -const ref = (id: string, title: string, opts: Partial = {}): Reference => ({ +const ref = (id: string, title: string, opts: Partial = {}): Reference => ({ id, modality: 'image', title, @@ -15,6 +15,19 @@ const ref = (id: string, title: string, opts: Partial = {}): Referenc ...opts, }) +// A text excerpt belongs on a text reference; the image builder above rejects it. +const textRef = (id: string, title: string, text: TextMeta): Reference => ({ + id, + modality: 'text', + title, + text, + source: { providerId: 'p', sourceUrl: `https://x/${id}` }, + canonicalUrl: `https://x/${id}`, + rights: { license: 'CC0-1.0', rehostPolicy: 'cache-allowed', raw: { sourceTerms: 't', sourceUrl: 'u' } }, + verifiedAt: '2026-06-24T00:00:00.000Z', + relevance: 0, +}) + describe('lexicalReranker', () => { it('ranks a title that matches the query above one that does not', async () => { const refs = [ @@ -126,7 +139,7 @@ describe('lexicalReranker', () => { it('matches query tokens in the text excerpt, not just the title', async () => { const refs = [ ref('title-only', 'untitled'), - ref('excerpt', 'untitled', { text: { excerpt: 'a quiet cyberpunk alley at dawn', excerptKind: 'passage' } }), + textRef('excerpt', 'untitled', { excerpt: 'a quiet cyberpunk alley at dawn', excerptKind: 'passage' }), ] const out = await lexicalReranker()({ query: 'cyberpunk alley', refs }) expect(out[0].id).toBe('excerpt') @@ -171,7 +184,7 @@ describe('lexicalReranker', () => { // every untitled ref after the first from a source ate the penalty and lost // its place to a weaker-matching sibling. `u1`'s title tokenizes to nothing // (all stopwords), which is the same empty set by another route. - const wiki = (id: string, opts: Partial) => + const wiki = (id: string, opts: Partial) => ref(id, '', { source: { providerId: 'wiki', sourceUrl: `https://x/${id}` }, ...opts }) const described = 'a forest path near the river bridge' const refs = [ diff --git a/packages/core/src/client.ts b/packages/core/src/client.ts index 6a69a5d..200f433 100644 --- a/packages/core/src/client.ts +++ b/packages/core/src/client.ts @@ -82,8 +82,10 @@ export interface ProviderSearchStatus { status: 'fulfilled' | 'failed' | 'skipped' returned?: number accepted?: number - /** Items the provider returned that failed schema validation; items dropped - * by the `limit` truncation are neither accepted nor rejected. */ + /** Items the provider returned that failed schema validation, or whose + * modality is outside this source's share of the requested modalities (the + * ones it declares); each also fires onProviderError. Items dropped by the + * `limit` truncation are neither accepted nor rejected. */ rejected?: number reason?: ProviderSkipReason error?: string diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 59c53ac..4ab84f4 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -12,11 +12,21 @@ export { buildAttribution } from './attribution' export type { Attribution, AttributionInput } from './attribution' export type { EmittedReference, + EmittedImageReference, + EmittedVideoReference, + EmittedAudioReference, + EmittedTextReference, Reference, + ImageReference, + VideoReference, + AudioReference, + TextReference, ReferenceMedia, MediaPreview, VisualMeta, TextMeta, + VideoMeta, + AudioMeta, } from './reference' export { emittedReferenceSchema, referenceSchema, diff --git a/packages/core/src/provider-run.ts b/packages/core/src/provider-run.ts index f9d53e1..6a6f995 100644 --- a/packages/core/src/provider-run.ts +++ b/packages/core/src/provider-run.ts @@ -32,8 +32,10 @@ export function providerCacheKey(providerId: string, query: NormalizedQuery): st return keyForFingerprint(providerId, stableStringify(query)) } +// v4: Reference became a union keyed by modality. Entries cached in an older +// shape are missed rather than parsed (and rejected item by item). function keyForFingerprint(providerId: string, fingerprint: string): string { - return `refkit:v3:${providerId}:${fnv1a(fingerprint)}${fnv1a(`${fingerprint.length}:${fingerprint}`)}` + return `refkit:v4:${providerId}:${fnv1a(fingerprint)}${fnv1a(`${fingerprint.length}:${fingerprint}`)}` } /** Shape of a cached entry: the query fingerprint (verified on read so a key @@ -51,7 +53,8 @@ export interface ProviderRunDeps { /** Per-provider soft deadline; undefined → no deadline (resilience disabled). */ timeoutMs?: number signal?: AbortSignal - /** Reports BOTH per-item parse failures and a failed search. */ + /** Reports per-item rejections (a malformed item, or a modality outside + * query.modalities) and a failed search. */ onError?: (error: unknown) => void } @@ -85,6 +88,9 @@ export async function runProviderSearch( // `rejected` is counted right here, where a parse failure actually happens — // NOT derived later from returned/valid counts, since `valid` is post-`truncate` // and a limit-truncated (but well-formed) item must never read as a reject. + // A live item whose modality is outside query.modalities (this source's share + // of the search's modalities) is a modality leak, rejected the same way. Cached + // entries were checked when written, so the cache path only parses. const parseCached = (raw: unknown[]): { valid: Reference[]; rejected: number } => { const valid: Reference[] = [] let rejected = 0 @@ -104,7 +110,11 @@ export async function runProviderSearch( let rejected = 0 for (const item of raw) { try { - valid.push(completeReference(provider.id, parseEmitted(item), now)) + const emitted = parseEmitted(item) + if (!query.modalities.includes(emitted.modality)) { + throw new Error(`modality "${emitted.modality}" is outside the modalities this source was asked for [${query.modalities.join(', ')}]`) + } + valid.push(completeReference(provider.id, emitted, now)) } catch (error) { rejected++ deps.onError?.(error) diff --git a/packages/core/src/reference.ts b/packages/core/src/reference.ts index 797a5b0..5edf5f9 100644 --- a/packages/core/src/reference.ts +++ b/packages/core/src/reference.ts @@ -1,22 +1,35 @@ import { z } from 'zod' -import { MODALITIES, type Modality } from './modality' +import type { Modality } from './modality' import { rightsRecordSchema, type RightsRecord } from './rights' import { referenceId } from './dedup-key' export interface ReferenceMedia { url: string; width?: number; height?: number } export interface MediaPreview { url: string; mediaType: string; width?: number; height?: number } +/** The original's pixel dimensions (image and video). An emitter that only knows + * a rendition's size leaves `visual` unset and puts that size on `preview`. */ export interface VisualMeta { width: number; height: number; dominantColors?: string[] } export interface TextMeta { excerpt: string excerptKind: 'passage' | 'structure' | 'quote' locator?: string } +export interface VideoMeta { + /** Seconds, as a float. */ + durationSec?: number + fps?: number +} +export interface AudioMeta { + /** Seconds, as a float. */ + durationSec?: number + bitrateKbps?: number + sampleRateHz?: number + channels?: number + /** Container/codec short name, e.g. 'mp3', 'flac'. */ + format?: string +} -/** What a provider emits for one result: everything the SOURCE knows. Core stamps - * id, source, verifiedAt and relevance (see completeReference) — providers never - * write those, never post-truncate, and never compute ids. */ -export interface EmittedReference { - modality: Modality +/** Fields every variant carries, emitted or complete. */ +interface CommonFields { /** Fine-grained kind (open vocabulary, see ResourceKind), e.g. 'photo', 'texture'. */ kind?: string title?: string @@ -24,23 +37,55 @@ export interface EmittedReference { description?: string /** Source tags / subjects / categories. Feeds ranking. */ tags?: string[] - /** Landing page at the source. Also the canonical URL unless canonicalUrl is set. */ - sourceUrl: string - canonicalUrl?: string rights: RightsRecord + /** An image a UI can show: the picture itself, a video poster, audio cover art + * or a book cover. Never a non-image URL (a landing page, JSON, an audio file). */ thumbnail?: ReferenceMedia + /** Upstream relevance score in the source's own scale; only the order within one + * source is meaningful. */ + sourceScore?: number + raw?: unknown +} + +interface ImageFields { + /** The rehostable asset; mediaType is image/*. */ preview?: MediaPreview + visual?: VisualMeta /** Computed by the satellite (pHash/blockhash); core only compares it. */ perceptualHash?: string +} +interface VideoFields { + /** The playable asset; mediaType is video/*. */ + preview?: MediaPreview + /** The source video's dimensions. */ visual?: VisualMeta + video?: VideoMeta +} +interface AudioFields { + /** The playable asset; mediaType is audio/*. */ + preview?: MediaPreview + audio?: AudioMeta +} +interface TextFields { text?: TextMeta - /** Upstream relevance score in the source's own scale; only the order within one - * source is meaningful. */ - sourceScore?: number - raw?: unknown } -export interface Reference extends Omit { +// A soft union: each variant declares the other variants' fields as `?: never`, +// so a provider writing a field that does not fit its modality gets a compile +// error, while a reader of the union still sees e.g. `r.visual` as +// `VisualMeta | undefined` without narrowing first. +type VariantKey = keyof ImageFields | keyof VideoFields | keyof AudioFields | keyof TextFields +type Absent = { [K in Exclude]?: never } + +/** What a provider emits next to the common fields: the landing page, and an + * optional canonical URL. */ +interface EmittedTail { + /** Landing page at the source. Also the canonical URL unless canonicalUrl is set. */ + sourceUrl: string + canonicalUrl?: string +} +/** What core stamps onto an emitted item (see completeReference). */ +interface StampedTail { /** Content-addressed: `${providerId}:${hash(sourceUrl)}`; stable within a result set. */ id: string source: { providerId: string; sourceUrl: string } @@ -51,42 +96,100 @@ export interface Reference extends Omit = z.enum(MODALITIES) +export interface EmittedImageReference extends CommonFields, EmittedTail, ImageFields, Absent { modality: 'image' } +export interface EmittedVideoReference extends CommonFields, EmittedTail, VideoFields, Absent { modality: 'video' } +export interface EmittedAudioReference extends CommonFields, EmittedTail, AudioFields, Absent { modality: 'audio' } +export interface EmittedTextReference extends CommonFields, EmittedTail, TextFields, Absent { modality: 'text' } + +/** What a provider emits for one result: everything the SOURCE knows, as the + * variant of its modality. Core stamps id, source, verifiedAt and relevance (see + * completeReference) — providers never write those, never post-truncate, and + * never compute ids. */ +export type EmittedReference = + | EmittedImageReference + | EmittedVideoReference + | EmittedAudioReference + | EmittedTextReference + +export interface ImageReference extends CommonFields, StampedTail, ImageFields, Absent { modality: 'image' } +export interface VideoReference extends CommonFields, StampedTail, VideoFields, Absent { modality: 'video' } +export interface AudioReference extends CommonFields, StampedTail, AudioFields, Absent { modality: 'audio' } +export interface TextReference extends CommonFields, StampedTail, TextFields, Absent { modality: 'text' } + +/** A complete reference: the emitted variant without sourceUrl/canonicalUrl, plus + * the core-stamped fields. A type alias, so `Omit` collapses to the + * common keys; omit per variant instead. */ +export type Reference = ImageReference | VideoReference | AudioReference | TextReference + const mediaSchema = z.object({ url: z.string(), width: z.number().optional(), height: z.number().optional() }) -const previewSchema = z.object({ url: z.string(), mediaType: z.string(), width: z.number().optional(), height: z.number().optional() }) +const previewOf = (prefix: 'image/' | 'video/' | 'audio/') => z.object({ + url: z.string(), + mediaType: z.string().startsWith(prefix, { message: `preview.mediaType must start with "${prefix}"` }), + width: z.number().optional(), + height: z.number().optional(), +}) const visualSchema = z.object({ width: z.number(), height: z.number(), dominantColors: z.array(z.string()).optional() }) const textSchema = z.object({ excerpt: z.string(), excerptKind: z.enum(['passage', 'structure', 'quote']), locator: z.string().optional() }) +const videoMetaSchema = z.object({ durationSec: z.number().optional(), fps: z.number().optional() }) +const audioMetaSchema = z.object({ + durationSec: z.number().optional(), + bitrateKbps: z.number().optional(), + sampleRateHz: z.number().optional(), + channels: z.number().optional(), + format: z.string().optional(), +}) -const emittedFields = { - modality: modalitySchema, +const commonShape = { kind: z.string().optional(), title: z.string().optional(), description: z.string().optional(), tags: z.array(z.string()).optional(), rights: rightsRecordSchema, thumbnail: mediaSchema.optional(), - preview: previewSchema.optional(), - perceptualHash: z.string().optional(), - visual: visualSchema.optional(), - text: textSchema.optional(), sourceScore: z.number().optional(), raw: z.unknown().optional(), } -export const emittedReferenceSchema: z.ZodType = z.object({ - ...emittedFields, - sourceUrl: z.string().min(1), - canonicalUrl: z.string().min(1).optional(), -}) +// The one table of per-modality fields. A key missing here fails to compile. +const VARIANT_SHAPES = { + image: { preview: previewOf('image/').optional(), visual: visualSchema.optional(), perceptualHash: z.string().optional() }, + video: { preview: previewOf('video/').optional(), visual: visualSchema.optional(), video: videoMetaSchema.optional() }, + audio: { preview: previewOf('audio/').optional(), audio: audioMetaSchema.optional() }, + text: { text: textSchema.optional() }, +} satisfies Record -export const referenceSchema: z.ZodType = z.object({ - ...emittedFields, +const emittedTail = { sourceUrl: z.string().min(1), canonicalUrl: z.string().min(1).optional() } +const stampedTail = { id: z.string().min(1), source: z.object({ providerId: z.string().min(1), sourceUrl: z.string().min(1) }), canonicalUrl: z.string().min(1), verifiedAt: z.string().datetime(), relevance: z.number().min(0).max(1), -}) +} + +// Unknown keys are stripped (plain z.object): a field from another modality +// disappears at the boundary instead of rejecting the item. The provider testkit +// asserts that parseEmitted lost no keys, so a provider writing one fails its +// own conformance tests. +function variant(m: M, tail: T) { + return z.object({ modality: z.literal(m), ...commonShape, ...VARIANT_SHAPES[m], ...tail }) +} + +// One explicit call per modality: MODALITIES.map does not type-check as the +// non-empty tuple discriminatedUnion needs. +export const emittedReferenceSchema: z.ZodType = z.discriminatedUnion('modality', [ + variant('image', emittedTail), + variant('video', emittedTail), + variant('audio', emittedTail), + variant('text', emittedTail), +]) + +export const referenceSchema: z.ZodType = z.discriminatedUnion('modality', [ + variant('image', stampedTail), + variant('video', stampedTail), + variant('audio', stampedTail), + variant('text', stampedTail), +]) /** Validate a provider-emitted item at the core boundary. Throws on malformed input. */ export function parseEmitted(input: unknown): EmittedReference { @@ -98,7 +201,8 @@ export function parseReference(input: unknown): Reference { return referenceSchema.parse(input) } -/** Stamp the fields that are core's concern onto an emitted item. */ +/** Stamp the fields that are core's concern onto an emitted item. The rest-spread + * keeps the discriminant: the result narrows on `modality` like its input. */ export function completeReference(providerId: string, e: EmittedReference, now: string): Reference { const { sourceUrl, canonicalUrl, ...rest } = e return { diff --git a/packages/provider-testkit/src/__tests__/testkit.test.ts b/packages/provider-testkit/src/__tests__/testkit.test.ts index e5d8941..9ecdfd9 100644 --- a/packages/provider-testkit/src/__tests__/testkit.test.ts +++ b/packages/provider-testkit/src/__tests__/testkit.test.ts @@ -1,10 +1,12 @@ import { describe, expect, it } from 'vitest' -import { defineProvider, type EmittedReference, type ProviderContext } from '@refkit/core' +import { + defineProvider, type EmittedAudioReference, type EmittedImageReference, type EmittedReference, type Modality, type ProviderContext, +} from '@refkit/core' import { expectLicenseMap, searchConformant, type LicenseMapResult } from '../index' // A minimal, otherwise-conformant emitted item. Individual tests override // exactly the field under examination. -function baseRef(overrides: Partial = {}): EmittedReference { +function baseRef(overrides: Partial = {}): EmittedImageReference { return { modality: 'image', sourceUrl: 'https://example.com/photo/1', @@ -19,13 +21,28 @@ function baseRef(overrides: Partial = {}): EmittedReference { } } +function audioRef(): EmittedAudioReference { + return { + modality: 'audio', + sourceUrl: 'https://example.com/track/1', + rights: { + license: 'CC0-1.0', + rehostPolicy: 'hotlink-required', + raw: { sourceTerms: 'public domain', sourceUrl: 'https://example.com/terms' }, + }, + thumbnail: { url: 'https://example.com/track/1-cover.jpg' }, + preview: { url: 'https://example.com/track/1.mp3', mediaType: 'audio/mpeg' }, + audio: { durationSec: 2.5 }, + } +} + // Fixture-fetch fake provider: search() ignores ctx.fetch's real behavior and // just returns whatever result set the test configured, but still routes // through ctx.fetch once so the shape matches a real satellite. -function fakeProvider(results: EmittedReference[]) { +function fakeProvider(results: EmittedReference[], modalities: Modality[] = ['image']) { return defineProvider({ id: 'fake', - modalities: ['image'], + modalities, async search(_query, ctx: ProviderContext) { await ctx.fetch('https://example.com/api') return results @@ -60,11 +77,52 @@ describe('searchConformant', () => { await expect(searchConformant(provider, fixtureFetch)).rejects.toThrow(/thumbnail\.url is the item's landing page/) }) - it('fails when an image provider emits a preview with non-image mediaType (D8)', async () => { + it('fails at the schema when an image emits a preview with a non-image mediaType (D8)', async () => { const provider = fakeProvider([ baseRef({ preview: { url: 'https://example.com/photo/1', mediaType: 'text/html' } }), ]) - await expect(searchConformant(provider, fixtureFetch)).rejects.toThrow(/image preview has non-image mediaType: text\/html/) + await expect(searchConformant(provider, fixtureFetch)).rejects.toThrow(/failed emittedReferenceSchema/) + }) + + it('fails at the schema when an audio item\'s preview is not audio/*', async () => { + const provider = fakeProvider([ + { ...audioRef(), preview: { url: 'https://example.com/track/1.mp4', mediaType: 'video/mp4' } }, + ], ['audio']) + await expect(searchConformant(provider, fixtureFetch)).rejects.toThrow(/failed emittedReferenceSchema/) + }) + + it('applies the landing-page thumbnail rule to every modality, not only image providers', async () => { + const provider = fakeProvider([ + { ...audioRef(), thumbnail: { url: 'https://example.com/track/1' } }, // === sourceUrl: the page itself + ], ['audio']) + await expect(searchConformant(provider, fixtureFetch)).rejects.toThrow(/thumbnail\.url is the item's landing page/) + }) + + it('fails when a result\'s modality is not one the provider declares', async () => { + const provider = fakeProvider([baseRef(), audioRef()], ['image']) + await expect(searchConformant(provider, fixtureFetch)).rejects.toThrow(/result #1 modality "audio" is not in the provider's declared modalities \[image\]/) + }) + + it('fails when parseEmitted drops a key: a field from another modality', async () => { + // The union type rejects this at compile time; the cast stands in for a + // provider whose types were loosened (any, JSON passthrough). + const provider = fakeProvider([{ ...baseRef(), text: { excerpt: 'e', excerptKind: 'passage' } } as unknown as EmittedReference]) + await expect(searchConformant(provider, fixtureFetch)).rejects.toThrow(/result #0 lost keys in parseEmitted: text/) + }) + + it('fails when parseEmitted drops a nested key, and names its path', async () => { + const provider = fakeProvider([ + { ...baseRef(), visual: { width: 4, height: 3, colours: ['#fff'] } } as unknown as EmittedReference, + ]) + await expect(searchConformant(provider, fixtureFetch)).rejects.toThrow(/lost keys in parseEmitted: visual\.colours/) + }) + + it('does not inspect raw, and does not count a key set to undefined as lost', async () => { + const provider = fakeProvider([ + { ...baseRef(), raw: { anything: { goes: true } } }, + { ...audioRef(), visual: undefined }, + ], ['image', 'audio']) + await expect(searchConformant(provider, fixtureFetch)).resolves.toHaveLength(2) }) it('fails when a result carries no sourceUrl', async () => { diff --git a/packages/provider-testkit/src/index.ts b/packages/provider-testkit/src/index.ts index cc4e174..789d449 100644 --- a/packages/provider-testkit/src/index.ts +++ b/packages/provider-testkit/src/index.ts @@ -1,6 +1,7 @@ import { completeReference, parseEmitted, isLikelyImageUrl, - type Reference, type ReferenceProvider, type NormalizedQuery, type ProviderContext, type LicenseId, + type EmittedReference, type Reference, type ReferenceProvider, type NormalizedQuery, type ProviderContext, + type LicenseId, } from '@refkit/core' export interface ConformanceOptions { @@ -8,15 +9,37 @@ export interface ConformanceOptions { query?: string /** Extra NormalizedQuery fields (providerOptions, limit, controls…). */ queryExtras?: Partial - /** Providers whose modality is image-only must yield image-like preview/thumbnail URLs (D8). Default true when provider.modalities includes 'image'. - * Caveat: mixed-modality factories (image plus others) also default to true — pass false explicitly if their non-image results legitimately carry non-image previews. */ - enforceImageUrls?: boolean +} + +const isRecord = (v: unknown): v is Record => + typeof v === 'object' && v !== null && !Array.isArray(v) + +/** Paths of the keys in an emitted item that parseEmitted dropped. The schema + * strips unknown keys (a field from another modality, a misspelt nested key) + * instead of rejecting the item, so this is where a provider learns it wrote + * one. Compares the top-level keys and the keys of nested objects and arrays; + * skips the top-level `raw` (free-form upstream payload) and keys whose value + * is undefined (nothing is lost). */ +function lostKeys(input: unknown, parsed: unknown, path = ''): string[] { + if (Array.isArray(input) && Array.isArray(parsed)) { + return input.flatMap((v, i) => lostKeys(v, parsed[i], `${path}[${i}]`)) + } + if (!isRecord(input) || !isRecord(parsed)) return [] + return Object.keys(input).flatMap((k) => { + if ((path === '' && k === 'raw') || input[k] === undefined) return [] + const at = path ? `${path}.${k}` : k + return k in parsed ? lostKeys(input[k], parsed[k], at) : [at] + }) } /** Run provider.search through the given fetch and assert every conformance rule * refkit demands of a satellite. Throws (with a per-item message) on violation; * returns the parsed refs for further assertions. Works for fixture fetches AND - * the real global fetch (live smoke). */ + * the real global fetch (live smoke). Per item: + * - it parses as its modality's variant (incl. the preview mediaType prefix); + * - parsing lost no keys; + * - its modality is one the provider declares, and its kind one it declares; + * - its thumbnail is not the landing page (every modality). */ export async function searchConformant( provider: ReferenceProvider, fetchImpl: typeof fetch, @@ -30,43 +53,48 @@ export async function searchConformant( } const ctx: ProviderContext = { fetch: fetchImpl } const raw = await provider.search(query, ctx) - const enforceImages = opts.enforceImageUrls ?? provider.modalities.includes('image') // Provenance (id, source, verifiedAt, relevance) is core's to stamp, so the // testkit completes each emitted item exactly as runProviderSearch would and // then asserts the rules that remain the SATELLITE's responsibility. const now = new Date().toISOString() return raw.map((item, i) => { - let ref: Reference + let emitted: EmittedReference try { - ref = completeReference(provider.id, parseEmitted(item), now) // schema validity incl. sourceUrl + rights record + emitted = parseEmitted(item) // schema validity incl. sourceUrl, rights record, preview mediaType prefix } catch (e) { throw new Error(`[${provider.id}] result #${i} failed emittedReferenceSchema: ${(e as Error).message}`) } + const lost = lostKeys(item, emitted) + if (lost.length > 0) { + throw new Error(`[${provider.id}] result #${i} lost keys in parseEmitted: ${lost.join(', ')} (not part of the ${emitted.modality} variant, e.g. a field from another modality)`) + } + // Declared-modalities consistency: core rejects an item outside the + // modalities the source was asked for; a provider must never emit one it + // does not declare. + if (!provider.modalities.includes(emitted.modality)) { + throw new Error(`[${provider.id}] result #${i} modality "${emitted.modality}" is not in the provider's declared modalities [${provider.modalities.join(', ')}]`) + } + const ref: Reference = completeReference(provider.id, emitted, now) // Declared-kinds consistency: a provider stating what it offers must not // emit results outside that set. Missing kind is allowed (annotation is // optional); only a contradicting value is a violation. if (provider.kinds && provider.kinds.length > 0 && ref.kind !== undefined && !provider.kinds.includes(ref.kind)) { throw new Error(`[${provider.id}] result #${i} kind "${ref.kind}" is not in the provider's declared kinds [${provider.kinds.join(', ')}]`) } - if (enforceImages) { - // D8 thumbnail rule, calibrated for real providers: legitimate thumbnails are - // often extensionless CDN/proxy URLs that no URL heuristic can bless - // (openverse `/v1/images//thumb/`, smithsonian `deliveryService?...`, - // unsplash `photo-1?w=200`), so failing isLikelyImageUrl MUST NOT fail the - // check — it is only an optional fast-accept. We fail solely on the - // unambiguous D8 disaster case: the item's landing page reused verbatim as - // the thumbnail. - if ( - ref.thumbnail && !isLikelyImageUrl(ref.thumbnail.url) - && (ref.thumbnail.url === ref.canonicalUrl || ref.thumbnail.url === ref.source.sourceUrl) - ) { - throw new Error(`[${provider.id}] result #${i} thumbnail.url is the item's landing page, not an image resource: ${ref.thumbnail.url}`) - } - // D8 preview rule: preview is the rehostable asset — for image providers its - // declared mediaType must be image/*. - if (ref.preview && !ref.preview.mediaType.startsWith('image/')) { - throw new Error(`[${provider.id}] result #${i} image preview has non-image mediaType: ${ref.preview.mediaType}`) - } + // D8 thumbnail rule, for every modality: a thumbnail is an image a UI can + // show (a picture, a video poster, cover art, a book cover). Calibrated for + // real providers: legitimate thumbnails are often extensionless CDN/proxy + // URLs that no URL heuristic can bless (openverse `/v1/images//thumb/`, + // smithsonian `deliveryService?...`, unsplash `photo-1?w=200`), so failing + // isLikelyImageUrl MUST NOT fail the check — it is only an optional + // fast-accept. We fail solely on the unambiguous D8 disaster case: the item's + // landing page reused verbatim as the thumbnail. (The preview mediaType rule + // is the schema's: each variant checks its own prefix in parseEmitted.) + if ( + ref.thumbnail && !isLikelyImageUrl(ref.thumbnail.url) + && (ref.thumbnail.url === ref.canonicalUrl || ref.thumbnail.url === ref.source.sourceUrl) + ) { + throw new Error(`[${provider.id}] result #${i} thumbnail.url is the item's landing page, not an image resource: ${ref.thumbnail.url}`) } return ref }) From d62bd854789069067a3962e0203e3dde78d748a3 Mon Sep 17 00:00:00 2001 From: lizhixuan Date: Mon, 12 Oct 2026 23:46:24 +0800 Subject: [PATCH 04/12] =?UTF-8?q?feat(core)!:=20one=20name=20for=20the=20c?= =?UTF-8?q?atalog=20a=20search=20queries=20=E2=80=94=20source?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.md | 28 +-- packages/core/README.md | 20 +- packages/core/src/__tests__/client.test.ts | 176 +++++++++++------- .../core/src/__tests__/confidence.test.ts | 2 +- packages/core/src/__tests__/dedup-key.test.ts | 2 +- packages/core/src/__tests__/dedup.test.ts | 2 +- packages/core/src/__tests__/merge.test.ts | 10 +- .../core/src/__tests__/provider-run.test.ts | 4 +- packages/core/src/__tests__/query.test.ts | 4 +- .../src/__tests__/reference-types.test.ts | 2 +- packages/core/src/__tests__/reference.test.ts | 8 +- packages/core/src/__tests__/rerank.test.ts | 30 +-- packages/core/src/__tests__/select.test.ts | 76 +++++++- packages/core/src/client.ts | 91 +++++---- packages/core/src/dedup-key.ts | 4 +- packages/core/src/index.ts | 12 +- packages/core/src/pipeline.ts | 59 +++--- packages/core/src/provider-run.ts | 18 +- packages/core/src/provider.ts | 4 +- packages/core/src/query.ts | 6 +- packages/core/src/reference.ts | 20 +- packages/core/src/rerank.ts | 8 +- packages/core/src/schemas.ts | 18 +- packages/core/src/select.ts | 71 +++++-- packages/mcp/README.md | 10 +- packages/mcp/src/__tests__/mcp.test.ts | 84 +++++++-- packages/mcp/src/index.ts | 32 ++-- .../src/__tests__/integration.test.ts | 6 +- packages/provider-gutendex/README.md | 2 +- .../src/__tests__/integration.test.ts | 6 +- packages/provider-nailbook/README.md | 2 +- .../src/__tests__/integration.test.ts | 6 +- .../src/__tests__/testkit.test.ts | 56 +++++- packages/provider-testkit/src/index.ts | 18 +- scripts/build-hero.mts | 18 +- 35 files changed, 600 insertions(+), 315 deletions(-) diff --git a/README.md b/README.md index fc4a7a0..1231146 100644 --- a/README.md +++ b/README.md @@ -75,14 +75,14 @@ await refkit.search({ }) ``` -Use `providerOptions` for provider-specific escape hatches that do not belong in the common contract. These are **typed whitelists**, not raw passthrough maps: each provider package translates the practical official search parameters it supports and ignores unsupported values. +Use `sourceOptions`, keyed by source id, for provider-specific escape hatches that do not belong in the common contract. These are **typed whitelists**, not raw passthrough maps: each provider package translates the practical official search parameters it supports and ignores unsupported values. ```ts await refkit.search({ query: 'forest path', modalities: ['image'], controls: { orientation: 'landscape', safety: 'strict' }, - providerOptions: { + sourceOptions: { unsplash: { collections: ['abc', 'def'], page: 2 }, flickr: { tags: ['forest', 'path'], tagMode: 'all', minTakenDate: '2020-01-01' }, brave: { country: 'US', searchLang: 'en', spellcheck: false }, @@ -96,7 +96,7 @@ The provider package owns its native options surface, e.g. `UnsplashSearchOption ### Site-scoped discovery -Pass `sources` to restrict a search to specific provider ids (still intersected with modality matching; omit to fan out to every configured source). This is the clean way to point a web-discovery source at another site's index with a `site:` operator — without that operator leaking into the other providers' queries: +Pass `sources` to restrict a search to specific source ids (still intersected with modality matching; omit to fan out to every configured source). This is the clean way to point a web-discovery source at another site's index with a `site:` operator — without that operator leaking into the other sources' queries: ```ts // Query Brave's index for Xiaohongshu nail-art notes — Brave only, so the @@ -108,11 +108,11 @@ const notes = await refkit.search({ }) ``` -A `sources` list that matches nothing for the requested modalities throws (a source typo fails loudly instead of reading as "no results"); an id that resolves to nothing while others still match is reported in `meta.warnings`, and every excluded provider appears in `meta.providers` with `reason: 'not-selected'`. +A `sources` list that matches nothing for the requested modalities throws (a source typo fails loudly instead of reading as "no results"); an id that resolves to nothing while others still match is reported in `meta.warnings`, and every excluded source appears in `meta.sources` with `reason: 'not-selected'`. ### Source routing & acceptance -A provider may declare `accepts({ text, modalities })` — a pure, fetch-free predicate that lets a narrow source opt out of queries it cannot answer. On an **unscoped** search a provider that returns `false` never runs: it is skipped with `reason: 'declined'` in `meta.providers` (routing, not a failure — no warning, no error). If every modality-matching provider declines, the search returns an empty result instead of throwing. +A provider may declare `accepts({ text, modalities })` — a pure, fetch-free predicate that lets a narrow source opt out of queries it cannot answer. On an **unscoped** search a provider that returns `false` never runs: it is skipped with `reason: 'declined'` in `meta.sources` (routing, not a failure — no warning, no error). If every modality-matching provider declines, the search returns an empty result instead of throwing. Naming a source in `sources` **bypasses** its `accepts` — asking for a source explicitly is the caller overriding the source's own judgement: @@ -152,9 +152,9 @@ const { references, meta } = await refkit.searchWithMeta({ gateFor: 'commercial-product', }) -console.log(meta.controls?.appliedByProvider) -console.log(meta.controls?.ignoredByProvider) -console.log(meta.providers) +console.log(meta.controls?.appliedBySource) +console.log(meta.controls?.ignoredBySource) +console.log(meta.sources) console.log(meta.warnings) ``` @@ -174,7 +174,7 @@ The cursor first **drains the overfetched pool** of the current provider page (e Every search fuses the per-source lists with **Reciprocal Rank Fusion** and then re-ranks the fused pool for the query. Both stages are on by default — no model, no network, nothing to configure: -1. **Source confidence** weights each source's RRF contribution by how much of the batch it returned actually mentions the query (`floor + (1 − floor) × hitRate`, floor `0.1`), so a source that answered something else stops out-ranking the ones that answered. Each fulfilled source reports its weight as `meta.providers[].confidence`. +1. **Source confidence** weights each source's RRF contribution by how much of the batch it returned actually mentions the query (`floor + (1 − floor) × hitRate`, floor `0.1`), so a source that answered something else stops out-ranking the ones that answered. Each fulfilled source reports its weight as `meta.sources[].confidence`. 2. **`lexicalReranker()`** then scores every candidate over `title + description + tags + text.excerpt` (CJK included, via character bigrams), blends in the fused relevance the candidate arrived with, adds a resolution boost, and emits greedily with a per-source diversity penalty plus a same-source near-duplicate-title penalty — so neither one provider nor one upload batch owns the top. ```ts @@ -241,7 +241,7 @@ createRefkit({ providers, sourceConfidence: { floor: 0.3 } }) // dampen a miss l createRefkit({ providers, sourceConfidence: false }) // unweighted fusion ``` -Confidence reaches the final **order** through the reranker's `fusionWeight`; with `fusionWeight: 0` — or a BYO reranker that ignores the incoming `relevance` — it only breaks ties among otherwise-equal refs and survives as the `meta.providers[].confidence` diagnostic. A fulfilled source that returned nothing reports no `confidence` at all: an empty batch neither mentions the query nor fails to. +Confidence reaches the final **order** through the reranker's `fusionWeight`; with `fusionWeight: 0` — or a BYO reranker that ignores the incoming `relevance` — it only breaks ties among otherwise-equal refs and survives as the `meta.sources[].confidence` diagnostic. A fulfilled source that returned nothing reports no `confidence` at all: an empty batch neither mentions the query nor fails to. ### Descriptive fields @@ -273,7 +273,7 @@ const refkit = createRefkit({ ## Resilience & caching -Fan-out is hardened by default: each provider gets a **soft 10s timeout** and **one retry** (429/5xx/network errors, exponential backoff). A slow or hanging source is reported in `meta.providers` as `failed` with `timeout after Nms` — the search still returns everyone else. Tune or disable per client: +Fan-out is hardened by default: each provider gets a **soft 10s timeout** and **one retry** (429/5xx/network errors, exponential backoff). A slow or hanging source is reported in `meta.sources` as `failed` with `timeout after Nms` — the search still returns everyone else. Tune or disable per client: ```ts createRefkit({ providers, resilience: { timeoutMs: 4000, retries: 2 } }) @@ -294,7 +294,7 @@ With many sources registered, bound the fan-out with `concurrency` — at most N createRefkit({ providers, concurrency: 6 }) // default: unlimited ``` -Pass a `cache` to memoize **per-provider** results (short fixed-shape hashed keys, safe for strict KV backends; the cached value embeds the full normalized query and is verified on read, so a key collision degrades to a miss instead of serving another query's results; TTL `cacheTtlMs`, default 5 min). Merging, reranking, and the license gate always run fresh; cache hits are flagged `cached: true` in `meta.providers`, and every provider status carries `latencyMs`. Pass `cacheRaw: false` to strip each result's `raw` provider payload from cache entries (smaller entries; raw-reading `isDuplicate` hooks then won't see `raw` on hits): +Pass a `cache` to memoize **per-provider** results (short fixed-shape hashed keys, safe for strict KV backends; the cached value embeds the full normalized query and is verified on read, so a key collision degrades to a miss instead of serving another query's results; TTL `cacheTtlMs`, default 5 min). Merging, reranking, and the license gate always run fresh; cache hits are flagged `cached: true` in `meta.sources`, and every source status carries `latencyMs`. Pass `cacheRaw: false` to strip each result's `raw` provider payload from cache entries (smaller entries; raw-reading `isDuplicate` hooks then won't see `raw` on hits): ```ts createRefkit({ providers, cache: myKvCache, cacheTtlMs: 60_000, cacheRaw: false }) @@ -324,7 +324,7 @@ createRefkit({ providers, cache: myKvCache, cacheTtlMs: 60_000, cacheRaw: false | `@refkit/provider-internet-archive` | Internet Archive | video · text | keyless | per-item CC (dirty) → unknown | | `@refkit/provider-nailbook` | Nailbook (Japanese nail art) | image | keyless | unknown → needs-review | -¹ gutendex's default host (`gutendex.com`) is the upstream maintainer's **test instance** — its docs say "You should run your own server", and its Cloudflare front blocks datacenter IPs. Desktop/local use works out of the box; for production or server-side traffic, [self-host Gutendex](https://github.com/garethbjohnson/gutendex) and pass `gutendex({ baseUrl: 'https://your-instance' })`. When blocked, the source degrades gracefully (a `failed` entry in `meta.providers`; other sources still return). +¹ gutendex's default host (`gutendex.com`) is the upstream maintainer's **test instance** — its docs say "You should run your own server", and its Cloudflare front blocks datacenter IPs. Desktop/local use works out of the box; for production or server-side traffic, [self-host Gutendex](https://github.com/garethbjohnson/gutendex) and pass `gutendex({ baseUrl: 'https://your-instance' })`. When blocked, the source degrades gracefully (a `failed` entry in `meta.sources`; other sources still return). Audio/video are extra factories on existing packages: `openverseAudio()`, `pexelsVideo()`, `pixabayVideo()`. Modality routing is automatic — an `['audio']` search only hits audio-capable providers. @@ -350,7 +350,7 @@ One search pass is a fixed pipeline: **select** (modality → `sources` → `kin ### Providers describe, core completes -A provider's `search` returns `EmittedReference[]` — only what the **source** knows: `modality, kind?, title?, description?, tags?, sourceUrl, canonicalUrl?, rights, thumbnail?, preview?, perceptualHash?, visual?, text?, sourceScore?, raw?`. Core validates each item against `emittedReferenceSchema` at the boundary and then stamps everything that is its own concern — `id` (content-addressed from providerId + sourceUrl), `source`, `canonicalUrl` (defaults to `sourceUrl`), `verifiedAt`, `relevance` — and applies the per-provider `limit`. Providers never compute ids, never write provenance or scores, and never post-truncate; a satellite is a mapper plus a rights mapping. +A provider's `search` returns `EmittedReference[]` — only what the **source** knows: `modality, kind?, title?, description?, tags?, sourceUrl, canonicalUrl?, rights, thumbnail?, preview?, perceptualHash?, visual?, text?, sourceScore?, raw?`. Core validates each item against `emittedReferenceSchema` at the boundary and then stamps everything that is its own concern — `id` (content-addressed from the source id + sourceUrl), `source` (`{ id, url }`), `canonicalUrl` (defaults to `sourceUrl`), `verifiedAt`, `relevance` — and applies the per-provider `limit`. Providers never compute ids, never write provenance or scores, and never post-truncate; a satellite is a mapper plus a rights mapping. ## Core invariants (enforced by tests in `@refkit/core`) diff --git a/packages/core/README.md b/packages/core/README.md index cca8738..0047229 100644 --- a/packages/core/README.md +++ b/packages/core/README.md @@ -6,6 +6,8 @@ The neutral brain of **refkit** — a dependency-light reference-retrieval toolk pnpm add @refkit/core @refkit/provider-openverse @refkit/provider-met ``` +**Providers and sources.** A *provider* is adapter code (a `ReferenceProvider` from a `@refkit/provider-*` package). Each provider instance serves one *source*, whose id (`provider.id`) is what `sources`, `sourceOptions` and `meta.sources` speak. + ## Quickstart ```ts @@ -56,14 +58,14 @@ await refkit.search({ }) ``` -Provider-specific escape hatches go under `providerOptions`, keyed by provider id. Core routes only the matching entry; providers own typed whitelists for the practical official search parameters they translate: +Provider-specific escape hatches go under `sourceOptions`, keyed by source id. Core routes only the matching entry to that source's provider (which reads it as `NormalizedQuery.providerOptions`); providers own typed whitelists for the practical official search parameters they translate: ```ts await refkit.search({ query: 'mountain trail', modalities: ['image'], controls: { orientation: 'landscape', safety: 'strict' }, - providerOptions: { + sourceOptions: { unsplash: { collections: ['abc', 'def'], page: 2 }, flickr: { sort: 'relevance', tags: ['mountain', 'trail'], tagMode: 'all' }, openverse: { source: ['flickr'], category: 'photograph', aspectRatio: 'wide' }, @@ -72,11 +74,11 @@ await refkit.search({ }) ``` -`providerOptions` is not a raw upstream passthrough. Each provider package exports its own `*SearchOptions` interface and keeps response-format/debug/auth-only parameters out when they would conflict with normalized references or provider credentials. +`sourceOptions` is not a raw upstream passthrough. Each provider package exports its own `*SearchOptions` interface and keeps response-format/debug/auth-only parameters out when they would conflict with normalized references or provider credentials. Currently supported unified controls: -| Provider id | Unified controls | +| Source id | Unified controls | |---|---| | `unsplash` | `orientation`, `color`, `language`, `sort`, `safety`, `page` | | `pexels` | `orientation`, `color`, `language`, `media.size`, `page` | @@ -102,9 +104,9 @@ const { references, meta } = await refkit.searchWithMeta({ gateFor: 'commercial-product', }) -meta.controls?.appliedByProvider -meta.controls?.ignoredByProvider -meta.providers // per-source status: fulfilled / failed / skipped (+ reason), latencyMs, confidence +meta.controls?.appliedBySource +meta.controls?.ignoredBySource +meta.sources // per-source status: fulfilled / failed / skipped (+ reason), latencyMs, confidence meta.gate // before/after/dropped counts when gateFor is used meta.threshold // { minRelevance, dropped } when minRelevance is set meta.passes // fan-out passes this call ran (> 1 only when a cursor advanced pages) @@ -116,7 +118,7 @@ meta.warnings // partial-result, rights-conflict and gate/threshold-drop notes Results are fused across sources with **Reciprocal Rank Fusion**, weighted per source by how well that source's batch matched the query, and then reranked for the query. Both are defaults: -- **Source confidence** (`RefkitOptions.sourceConfidence`, default on) weights each source's RRF contribution by `floor + (1 − floor) × hitRate`, where `hitRate` is the fraction of its refs whose text mentions any query token and `floor` is `0.1` — a source is dampened, never erased. `sourceConfidence: false` for unweighted fusion, `{ floor: 0.3 }` to dampen less. Every fulfilled source reports its weight as `meta.providers[].confidence`; a source that returned nothing reports none. +- **Source confidence** (`RefkitOptions.sourceConfidence`, default on) weights each source's RRF contribution by `floor + (1 − floor) × hitRate`, where `hitRate` is the fraction of its refs whose text mentions any query token and `floor` is `0.1` — a source is dampened, never erased. `sourceConfidence: false` for unweighted fusion, `{ floor: 0.3 }` to dampen less. Every fulfilled source reports its weight as `meta.sources[].confidence`; a source that returned nothing reports none. - **`lexicalReranker(opts?)`** is the default reranker: no model, no network. It scores each result over `title + description + tags + text.excerpt` (CJK via character bigrams), blends in the incoming fused relevance (`fusionWeight`, default `0.5`), adds a resolution boost (`qualityWeight` `0.15`) and optionally license permissiveness (`licenseWeight`, default `0`) or the source's own `sourceScore` (`sourceScoreWeight`, default `0`), then emits greedily with a per-source diversity penalty (`sourceDiversity` `0.1`) and a same-source near-duplicate-title penalty (`nearDuplicatePenalty` `0.25` above a title-token Jaccard of `nearDuplicateThreshold` `0.7`). The greedy penalties steer order only; the reported `relevance` is the normalised blend. - **Replace or disable it** — `RefkitOptions.rerank` sets the client default, `SearchInput.rerank` overrides per call; `false` on either returns raw cross-source fusion order. - **Bring your own** — the `Reranker` hook receives `{ query, refs, signal }` and returns reordered refs, so you can wire a CLIP/embedding/LLM reranker to your own API. `core` ships no model; this is the only seam. `refText(ref)` and `tokenize(text)` are exported so a custom reranker can read the same text with the same tokenizer. @@ -177,7 +179,7 @@ export const example = () => defineProvider({ }) ``` -Core validates each item against `emittedReferenceSchema` at the boundary, then `completeReference` stamps the fields that are core's concern — `id` (content-addressed from `providerId` + `sourceUrl`), `source`, `canonicalUrl` (defaults to `sourceUrl`), `verifiedAt`, and a `relevance` of zero until the merge scores it — and truncates to the requested `limit`. A provider never computes an id, never writes provenance or a score, and never post-truncates. A single malformed item is reported and dropped (`meta.providers[].rejected`), not fatal. +Core validates each item against `emittedReferenceSchema` at the boundary, then `completeReference` stamps the fields that are core's concern — `id` (content-addressed from the source id + `sourceUrl`), `source` (`{ id, url }`), `canonicalUrl` (defaults to `sourceUrl`), `verifiedAt`, and a `relevance` of zero until the merge scores it — and truncates to the requested `limit`. A provider never computes an id, never writes provenance or a score, and never post-truncates. A single malformed item is reported and dropped (`meta.sources[].rejected`), not fatal. `LicenseId` is an **open** string. Known ids carry a row in `LICENSE_FACTS`; anything else resolves to the `unknown` row, which grants nothing. A source whose terms are narrower than the label it declares ships its own `rights.facts` row, and every consumer — the use-gate, the merge's conflict resolution, attribution, the reranker's license boost — reads facts via `factsOf(rights)`, never the id string. diff --git a/packages/core/src/__tests__/client.test.ts b/packages/core/src/__tests__/client.test.ts index 66b7b80..efd44f8 100644 --- a/packages/core/src/__tests__/client.test.ts +++ b/packages/core/src/__tests__/client.test.ts @@ -6,6 +6,7 @@ import { defineProvider, type ReferenceProvider } from '../provider' import { lexicalReranker } from '../rerank' import { completeReference, type EmittedImageReference, type EmittedReference } from '../reference' import type { LicenseId } from '../license' +import { SourceSelectionError } from '../index' // the public entry: proves the export const ref = (url: string, license: LicenseId = 'CC0-1.0'): EmittedImageReference => ({ modality: 'image', @@ -31,12 +32,12 @@ describe('createRefkit', () => { expect(out[0].relevance).toBeGreaterThan(0) }) - it('degrades gracefully when one provider fails (onProviderError, survivors merged)', async () => { - const onProviderError = vi.fn() + it('degrades gracefully when one provider fails (onSourceError, survivors merged)', async () => { + const onSourceError = vi.fn() const rk = createRefkit({ providers: [provider('a', [ref('https://a/1')]), failing('b')] }) - const out = await rk.search({ query: 'x', modalities: ['image'], onProviderError }) + const out = await rk.search({ query: 'x', modalities: ['image'], onSourceError }) expect(out).toHaveLength(1) - expect(onProviderError).toHaveBeenCalledWith(expect.objectContaining({ providerId: 'b' })) + expect(onSourceError).toHaveBeenCalledWith(expect.objectContaining({ sourceId: 'b' })) }) it('throws AggregateError only when ALL providers fail', async () => { @@ -175,6 +176,13 @@ describe('createRefkit', () => { promised.catch(() => {}) }) + it('throws when two providers serve the same source id, naming the id', () => { + expect(() => createRefkit({ providers: [provider('a', []), provider('b', []), provider('a', [])] })) + .toThrow('createRefkit: duplicate source id(s) [a]') + expect(() => createRefkit({ providers: [provider('b', []), provider('a', []), provider('b', []), provider('a', []), provider('a', [])] })) + .toThrow('createRefkit: duplicate source id(s) [b, a]') + }) + it('surfaces cross-source license conflicts as meta.warnings with conservative rights', async () => { const rk = createRefkit({ providers: [ @@ -275,13 +283,13 @@ describe('createRefkit', () => { const imageOnly = provider('img', [ref('https://img/1')]) const rk = createRefkit({ providers: [imageOnly] }) await expect(rk.search({ query: 'x', modalities: ['video'] })).rejects.toThrow( - "refkit.search: no registered provider supports modalities [video]" + "refkit.search: no configured source supports modalities [video]" ) await expect(rk.search({ query: 'x', modalities: ['video'] })).rejects.not.toBeInstanceOf(AggregateError) }) - it('provider fulfills but returns malformed data: onProviderError called, result excluded, no throw', async () => { - const onProviderError = vi.fn() + it('provider fulfills but returns malformed data: onSourceError called, result excluded, no throw', async () => { + const onSourceError = vi.fn() const malformedProvider = defineProvider({ id: 'bad', modalities: ['image'], @@ -289,8 +297,8 @@ describe('createRefkit', () => { }) const goodProvider = provider('good', [ref('https://good/1')]) const rk = createRefkit({ providers: [malformedProvider, goodProvider] }) - const out = await rk.search({ query: 'x', modalities: ['image'], onProviderError }) - expect(onProviderError).toHaveBeenCalledWith(expect.objectContaining({ providerId: 'bad' })) + const out = await rk.search({ query: 'x', modalities: ['image'], onSourceError }) + expect(onSourceError).toHaveBeenCalledWith(expect.objectContaining({ sourceId: 'bad' })) expect(out.every(r => r.id !== '')).toBe(true) expect(out).toHaveLength(1) }) @@ -358,7 +366,7 @@ describe('createRefkit', () => { await rk.search({ query: 'x', modalities: ['image'], - providerOptions: { a: { orderBy: 'latest' }, b: { sort: 'relevance' } }, + sourceOptions: { a: { orderBy: 'latest' }, b: { sort: 'relevance' } }, }) expect(seenA).toEqual({ orderBy: 'latest' }) expect(seenB).toEqual({ sort: 'relevance' }) @@ -380,13 +388,13 @@ describe('createRefkit', () => { const out = await rk.searchWithMeta({ query: 'x', modalities: ['image'], gateFor: 'commercial-product' }) expect(out.references.map(r => r.canonicalUrl)).toEqual(['https://ok/1']) - expect(out.meta.providers).toEqual([ - { providerId: 'ok', status: 'fulfilled', returned: 2, accepted: 2, rejected: 0, latencyMs: expect.any(Number), confidence: 1 }, - { providerId: 'bad', status: 'failed', error: 'boom', latencyMs: expect.any(Number) }, - { providerId: 'text', status: 'skipped', reason: 'unsupported-modality' }, + expect(out.meta.sources).toEqual([ + { sourceId: 'ok', status: 'fulfilled', returned: 2, accepted: 2, rejected: 0, latencyMs: expect.any(Number), confidence: 1 }, + { sourceId: 'bad', status: 'failed', error: 'boom', latencyMs: expect.any(Number) }, + { sourceId: 'text', status: 'skipped', reason: 'unsupported-modality' }, ]) expect(out.meta.gate).toEqual({ intent: 'commercial-product', before: 2, after: 1, dropped: 1 }) - expect(out.meta.warnings).toContain('1 provider(s) failed; returning partial results.') + expect(out.meta.warnings).toContain('1 source(s) failed; returning partial results.') }) it('does not count limit-truncated items as rejected — only schema-invalid items are rejected', async () => { @@ -402,8 +410,8 @@ describe('createRefkit', () => { // poolFactor: 1 so fetchLimit === limit === 2 — the per-provider truncation // point — isolating "dropped by limit" from "dropped by overfetch pooling". const out = await rk.searchWithMeta({ query: 'x', modalities: ['image'], limit: 2, poolFactor: 1 }) - expect(out.meta.providers).toEqual([ - { providerId: 'many', status: 'fulfilled', returned: 5, accepted: 2, rejected: 0, latencyMs: expect.any(Number), confidence: 1 }, + expect(out.meta.sources).toEqual([ + { sourceId: 'many', status: 'fulfilled', returned: 5, accepted: 2, rejected: 0, latencyMs: expect.any(Number), confidence: 1 }, ]) }) @@ -418,16 +426,16 @@ describe('createRefkit', () => { { modality: 'video', sourceUrl: 'https://leaky/2', rights: ref('https://leaky/2').rights }, ], }) - const onProviderError = vi.fn() + const onSourceError = vi.fn() const rk = createRefkit({ providers: [leaky] }) - const out = await rk.searchWithMeta({ query: 'x', modalities: ['image', 'video'], onProviderError }) + const out = await rk.searchWithMeta({ query: 'x', modalities: ['image', 'video'], onSourceError }) expect(out.references.map(r => r.canonicalUrl)).toEqual(['https://leaky/1']) - expect(out.meta.providers).toEqual([ - { providerId: 'leaky', status: 'fulfilled', returned: 2, accepted: 1, rejected: 1, latencyMs: expect.any(Number), confidence: 1 }, + expect(out.meta.sources).toEqual([ + { sourceId: 'leaky', status: 'fulfilled', returned: 2, accepted: 1, rejected: 1, latencyMs: expect.any(Number), confidence: 1 }, ]) - expect(onProviderError).toHaveBeenCalledTimes(1) - expect(onProviderError).toHaveBeenCalledWith(expect.objectContaining({ - providerId: 'leaky', + expect(onSourceError).toHaveBeenCalledTimes(1) + expect(onSourceError).toHaveBeenCalledWith(expect.objectContaining({ + sourceId: 'leaky', error: expect.objectContaining({ message: expect.stringMatching(/modality "video"/) }), })) }) @@ -468,8 +476,8 @@ describe('createRefkit', () => { }) expect(out.meta.controls).toEqual({ requested: ['orientation', 'color', 'safety'], - appliedByProvider: { controlled: ['orientation', 'color'], plain: [] }, - ignoredByProvider: { controlled: ['safety'], plain: ['orientation', 'color', 'safety'] }, + appliedBySource: { controlled: ['orientation', 'color'], plain: [] }, + ignoredBySource: { controlled: ['safety'], plain: ['orientation', 'color', 'safety'] }, }) }) @@ -486,9 +494,9 @@ describe('createRefkit', () => { controls: { orientation: 'landscape', color: 'blue' }, }) expect(out.meta.controls?.requested).toEqual(['orientation', 'color']) - expect(out.meta.controls?.ignoredByProvider.bare).toEqual(['orientation', 'color']) + expect(out.meta.controls?.ignoredBySource.bare).toEqual(['orientation', 'color']) // declares nothing → applies nothing (an undeclared control is never routed) - expect(out.meta.controls?.appliedByProvider).toEqual({ bare: [] }) + expect(out.meta.controls?.appliedBySource).toEqual({ bare: [] }) }) it('produces meta that validates against the exported searchMetaSchema', async () => { @@ -498,7 +506,7 @@ describe('createRefkit', () => { query: 'x', modalities: ['image'], controls: { orientation: 'landscape' }, - providerOptions: { a: { orderBy: 'latest' } }, + sourceOptions: { a: { orderBy: 'latest' } }, gateFor: 'commercial-product', }) const parsed = searchMetaSchema.safeParse(out.meta) @@ -518,7 +526,7 @@ describe('createRefkit', () => { await vi.advanceTimersByTimeAsync(10_000) const out = await p expect(out.references).toHaveLength(1) - const hangStatus = out.meta.providers.find(s => s.providerId === 'hang') + const hangStatus = out.meta.sources.find(s => s.sourceId === 'hang') expect(hangStatus?.status).toBe('failed') expect(hangStatus?.error).toContain('timeout after 10000ms') } finally { @@ -544,7 +552,7 @@ describe('createRefkit', () => { await vi.advanceTimersByTimeAsync(10_000) const out = await p expect(observedAbort).toBe(true) - const wbStatus = out.meta.providers.find(s => s.providerId === 'wb') + const wbStatus = out.meta.sources.find(s => s.sourceId === 'wb') expect(wbStatus?.status).toBe('failed') } finally { vi.useRealTimers() @@ -584,7 +592,7 @@ describe('createRefkit', () => { // the search must already have settled from the parent abort, not the timer await vi.advanceTimersByTimeAsync(50) const result = await p - expect(result).toBeInstanceOf(AggregateError) // all providers failed → AggregateError + expect(result).toBeInstanceOf(AggregateError) // all sources failed → AggregateError } finally { vi.useRealTimers() } @@ -612,7 +620,7 @@ describe('createRefkit', () => { const textOnly = defineProvider({ id: 'text', modalities: ['text'], search: async () => [] }) const rk = createRefkit({ providers: [provider('a', [ref('https://a/1')]), failing('bad'), textOnly] }) const out = await rk.searchWithMeta({ query: 'x', modalities: ['image'] }) - const byId = Object.fromEntries(out.meta.providers.map(s => [s.providerId, s])) + const byId = Object.fromEntries(out.meta.sources.map(s => [s.sourceId, s])) expect(byId.a.latencyMs).toEqual(expect.any(Number)) expect(byId.bad.latencyMs).toEqual(expect.any(Number)) expect(byId.text.latencyMs).toBeUndefined() @@ -640,7 +648,7 @@ describe('createRefkit', () => { const out = await rk.searchWithMeta({ query: 'x', modalities: ['image'] }) expect(calls).toBe(1) expect(out.references).toHaveLength(1) - expect(out.meta.providers[0]).toMatchObject({ status: 'fulfilled', cached: true }) + expect(out.meta.sources[0]).toMatchObject({ status: 'fulfilled', cached: true }) expect(cache.ttls).toEqual([300_000]) // default cacheTtlMs, one set for the first (live) search }) @@ -693,7 +701,7 @@ describe('createRefkit', () => { const rk = createRefkit({ providers: [provider('a', [ref('https://a/1', 'proprietary')])], cache }) await rk.search({ query: 'x', modalities: ['image'] }) const out = await rk.searchWithMeta({ query: 'x', modalities: ['image'], gateFor: 'commercial-product' }) - expect(out.meta.providers[0]).toMatchObject({ status: 'fulfilled', cached: true }) + expect(out.meta.sources[0]).toMatchObject({ status: 'fulfilled', cached: true }) expect(out.references).toHaveLength(0) expect(out.meta.gate).toMatchObject({ intent: 'commercial-product', before: 1, after: 0, dropped: 1 }) }) @@ -719,7 +727,7 @@ describe('createRefkit', () => { } }) - it('providerOptions key order does not change the cache key', async () => { + it('sourceOptions key order does not change the cache key', async () => { const cache = mapCache() let calls = 0 const counted = defineProvider({ @@ -727,8 +735,8 @@ describe('createRefkit', () => { search: async () => { calls++; return [ref('https://c/1')] }, }) const rk = createRefkit({ providers: [counted], cache }) - await rk.search({ query: 'x', modalities: ['image'], providerOptions: { c: { b: 2, a: 1 } } }) - await rk.search({ query: 'x', modalities: ['image'], providerOptions: { c: { a: 1, b: 2 } } }) + await rk.search({ query: 'x', modalities: ['image'], sourceOptions: { c: { b: 2, a: 1 } } }) + await rk.search({ query: 'x', modalities: ['image'], sourceOptions: { c: { a: 1, b: 2 } } }) expect(calls).toBe(1) // second search is a cache hit despite the different key order }) @@ -754,7 +762,7 @@ describe('createRefkit', () => { it('a cache entry with one malformed item validates per-item (matches the live path): good kept, bad reported+rejected', async () => { const cache = mapCache() - const onProviderError = vi.fn() + const onSourceError = vi.fn() const counted = defineProvider({ id: 'c', modalities: ['image'], search: async () => [ref('https://c/1')], @@ -766,11 +774,11 @@ describe('createRefkit', () => { const payload = JSON.parse(cache.store.get(k)!) cache.store.set(k, JSON.stringify({ ...payload, refs: [...payload.refs, { id: '', modality: 'image' }] })) } - const out = await rk.searchWithMeta({ query: 'x', modalities: ['image'], onProviderError }) + const out = await rk.searchWithMeta({ query: 'x', modalities: ['image'], onSourceError }) expect(out.references.map(r => r.canonicalUrl)).toEqual(['https://c/1']) - expect(out.meta.providers[0]).toMatchObject({ cached: true, returned: 2, accepted: 1, rejected: 1 }) - expect(onProviderError).toHaveBeenCalledTimes(1) - expect(onProviderError).toHaveBeenCalledWith(expect.objectContaining({ providerId: 'c' })) + expect(out.meta.sources[0]).toMatchObject({ cached: true, returned: 2, accepted: 1, rejected: 1 }) + expect(onSourceError).toHaveBeenCalledTimes(1) + expect(onSourceError).toHaveBeenCalledWith(expect.objectContaining({ sourceId: 'c' })) }) it('stableStringify cache keys treat undefined-valued keys the same as absent keys', async () => { @@ -781,8 +789,8 @@ describe('createRefkit', () => { search: async () => { calls++; return [ref('https://c/1')] }, }) const rk = createRefkit({ providers: [counted], cache }) - await rk.search({ query: 'x', modalities: ['image'], providerOptions: { c: { a: 1, b: undefined } } }) - await rk.search({ query: 'x', modalities: ['image'], providerOptions: { c: { a: 1 } } }) + await rk.search({ query: 'x', modalities: ['image'], sourceOptions: { c: { a: 1, b: undefined } } }) + await rk.search({ query: 'x', modalities: ['image'], sourceOptions: { c: { a: 1 } } }) expect(calls).toBe(1) // second search hits the same cache entry as the first }) @@ -798,7 +806,7 @@ describe('createRefkit', () => { const out = await rk.searchWithMeta({ query: 'x', modalities: ['image'], sources: ['a'] }) expect(out.references.map(r => r.canonicalUrl)).toEqual(['https://a/1']) expect(bCalled).toBe(false) // never queried — its query is untouched - expect(out.meta.providers.find(s => s.providerId === 'b')).toEqual({ providerId: 'b', status: 'skipped', reason: 'not-selected' }) + expect(out.meta.sources.find(s => s.sourceId === 'b')).toEqual({ sourceId: 'b', status: 'skipped', reason: 'not-selected' }) }) it('a modality miss stays unsupported-modality; only a source exclusion is not-selected', async () => { @@ -809,15 +817,15 @@ describe('createRefkit', () => { const textOnly = defineProvider({ id: 'text', modalities: ['text'], search: async () => [] }) const rk = createRefkit({ providers: [a, b, textOnly] }) const out = await rk.searchWithMeta({ query: 'x', modalities: ['image'], sources: ['a'] }) - const byId = Object.fromEntries(out.meta.providers.map(s => [s.providerId, s])) + const byId = Object.fromEntries(out.meta.sources.map(s => [s.sourceId, s])) expect(byId.b).toMatchObject({ status: 'skipped', reason: 'not-selected' }) expect(byId.text).toMatchObject({ status: 'skipped', reason: 'unsupported-modality' }) }) - it('throws a clear Error (not AggregateError) when sources match no configured provider', async () => { + it('throws a clear Error (not AggregateError) when sources match no configured source', async () => { const rk = createRefkit({ providers: [provider('a', [ref('https://a/1')])] }) await expect(rk.search({ query: 'x', modalities: ['image'], sources: ['nope'] })).rejects.toThrow( - 'refkit.search: no configured provider matches source id(s) [nope] for modalities [image]', + 'refkit.search: no configured source matches id(s) [nope] for modalities [image]', ) await expect(rk.search({ query: 'x', modalities: ['image'], sources: ['nope'] })).rejects.not.toBeInstanceOf(AggregateError) }) @@ -828,7 +836,19 @@ describe('createRefkit', () => { const rk = createRefkit({ providers: [imageOnly, textOnly] }) // txt is registered, but scoping an image search to [txt] has an empty intersection await expect(rk.search({ query: 'x', modalities: ['image'], sources: ['txt'] })).rejects.toThrow( - /no configured provider matches source id\(s\) \[txt\]/, + /no configured source matches id\(s\) \[txt\]/, + ) + }) + + it('throws a SourceSelectionError (exported from the package) a host can branch on without reading the message', async () => { + const rk = createRefkit({ providers: [provider('a', [ref('https://a/1')])] }) + await expect(rk.search({ query: 'x', modalities: ['image'], sources: ['nope'] })).rejects.toSatisfy( + (e: unknown) => e instanceof SourceSelectionError + && e.code === 'no-matching-source' && e.unknown.join() === 'nope' && e.requested?.join() === 'nope', + ) + await expect(rk.search({ query: 'x', modalities: ['video'] })).rejects.toSatisfy( + (e: unknown) => e instanceof SourceSelectionError + && e.code === 'no-source-for-query' && e.requested === undefined && e.modalities.join() === 'video', ) }) @@ -839,6 +859,26 @@ describe('createRefkit', () => { expect(out.meta.warnings).toContain('unknown source id(s) ignored: ghost.') }) + it('warns about unknown sourceOptions ids, worded like the unknown-sources warning', async () => { + const textOnly = defineProvider({ id: 'txt', modalities: ['text'], search: async () => [] }) + const rk = createRefkit({ providers: [provider('a', [ref('https://a/1')]), textOnly] }) + const out = await rk.searchWithMeta({ + query: 'x', + modalities: ['image'], + // `txt` is configured (it just sits this image search out), so only the typos warn. + sourceOptions: { a: { orderBy: 'latest' }, ghost: { sort: 'new' }, txt: { lang: 'en' }, phantom: undefined }, + }) + expect(out.references).toHaveLength(1) + expect(out.meta.sourceOptions).toEqual(['a', 'ghost', 'txt', 'phantom']) + expect(out.meta.warnings).toContain('unknown sourceOptions id(s) ignored: ghost, phantom.') + }) + + it('does not warn when every sourceOptions id is a configured source', async () => { + const rk = createRefkit({ providers: [provider('a', [ref('https://a/1')])] }) + const out = await rk.searchWithMeta({ query: 'x', modalities: ['image'], sourceOptions: { a: { orderBy: 'latest' } } }) + expect(out.meta.warnings.some(w => w.includes('sourceOptions'))).toBe(false) + }) + it('does not warn when every requested source id resolves', async () => { const rk = createRefkit({ providers: [provider('a', [ref('https://a/1')]), provider('b', [ref('https://b/1')])] }) const out = await rk.searchWithMeta({ query: 'x', modalities: ['image'], sources: ['a', 'b'] }) @@ -887,7 +927,7 @@ describe('kind-aware routing', () => { query: 'x', modalities: ['image'], controls: { media: { kind: 'texture' } }, }) expect(references.map(r => r.canonicalUrl)).toEqual(['https://t/1']) - expect(meta.providers.find(p => p.providerId === 'ph')) + expect(meta.sources.find(p => p.sourceId === 'ph')) .toMatchObject({ status: 'skipped', reason: 'unsupported-kind' }) }) @@ -915,7 +955,7 @@ describe('query acceptance (accepts)', () => { const picky = defineProvider({ id: 'picky', modalities: ['image'], accepts: () => false, search: async () => [ref('https://x.test/1')] }) const { references, meta } = await createRefkit({ providers: [picky], resilience: false }).searchWithMeta({ query: 'lion', modalities: ['image'] }) expect(references).toEqual([]) - expect(meta.providers[0]).toMatchObject({ providerId: 'picky', status: 'skipped', reason: 'declined' }) + expect(meta.sources[0]).toMatchObject({ sourceId: 'picky', status: 'skipped', reason: 'declined' }) expect(meta.nextCursor).toBeUndefined() // A decline is routing, not a failure: nothing to warn about. expect(meta.warnings).toEqual([]) @@ -932,7 +972,7 @@ describe('query acceptance (accepts)', () => { const out = await rk.searchWithMeta({ query: 'lion', modalities: ['image'] }) expect(out.references.map(r => r.canonicalUrl)).toEqual(['https://a/1']) expect(pickyCalled).toBe(false) - expect(out.meta.providers.find(p => p.providerId === 'picky')).toEqual({ providerId: 'picky', status: 'skipped', reason: 'declined' }) + expect(out.meta.sources.find(p => p.sourceId === 'picky')).toEqual({ sourceId: 'picky', status: 'skipped', reason: 'declined' }) }) it('hands accepts the real query text, then runs the provider', async () => { @@ -998,13 +1038,13 @@ describe('multi-pass fidelity, deadline and gate context', () => { expect(first.meta.passes).toBe(1) const second = await rk.searchWithMeta({ query: 'q', modalities: ['image'], limit: 5, cursor: first.meta.nextCursor }) expect(second.meta.passes).toBeGreaterThan(1) - expect(second.meta.warnings.some(w => /pass 1: 1 provider\(s\) failed/.test(w))).toBe(true) + expect(second.meta.warnings.some(w => /pass 1: 1 source\(s\) failed/.test(w))).toBe(true) expect(second.references.map(r => r.canonicalUrl)).toEqual(['https://f.test/2']) expect(calls).toBeGreaterThan(1) // Latency is SUMMED over every pass, so a multi-pass call never under-reports // it: `steady` slept STEADY_SLEEP_MS on each of the two passes, a total one // pass alone could not reach. - expect(second.meta.providers.find(p => p.providerId === 'steady')?.latencyMs) + expect(second.meta.sources.find(p => p.sourceId === 'steady')?.latencyMs) .toBeGreaterThan(STEADY_SLEEP_MS * 1.5) }) @@ -1060,7 +1100,7 @@ describe('multi-pass fidelity, deadline and gate context', () => { const { references, meta } = await rk.searchWithMeta({ query: 'q', modalities: ['image'], deadlineMs: 100 }) expect(Date.now() - started).toBeLessThan(2000) expect(references).toHaveLength(1) - expect(meta.providers.find(p => p.providerId === 'hung')?.status).toBe('failed') + expect(meta.sources.find(p => p.sourceId === 'hung')?.status).toBe('failed') }) it('gateContext forwards the user jurisdiction to the search-time gate', async () => { @@ -1096,9 +1136,9 @@ describe('accuracy defaults', () => { const { references, meta } = await rk.searchWithMeta({ query: 'lion', modalities: ['image'] }) // Raw RRF would put noise's rank-0 item first; the confidence weight sinks // the whole batch to a tenth of its contribution instead. - expect(references[0].source.providerId).toBe('signal') - expect(meta.providers.find(p => p.providerId === 'noise')?.confidence).toBeCloseTo(0.1, 5) - expect(meta.providers.find(p => p.providerId === 'signal')?.confidence).toBe(1) + expect(references[0].source.id).toBe('signal') + expect(meta.sources.find(p => p.sourceId === 'noise')?.confidence).toBeCloseTo(0.1, 5) + expect(meta.sources.find(p => p.sourceId === 'signal')?.confidence).toBe(1) }) it('under the shipped defaults the trustworthy source wins an equal lexical hit', async () => { @@ -1125,9 +1165,9 @@ describe('accuracy defaults', () => { // rank 0, max-normalised): signal 1, noise's lion 0.325. Equal lexical 1, so // base(signal) = 1 + 0.5·1 + 0.15·0.5 = 1.575 beats // base(noise) = 1 + 0.5·0.325 + 0.15·1 = 1.3125 despite the resolution edge. - expect(references[0].source.providerId).toBe('signal') - expect(meta.providers.find(p => p.providerId === 'signal')?.confidence).toBeCloseTo(1, 5) - expect(meta.providers.find(p => p.providerId === 'noise')?.confidence).toBeCloseTo(0.325, 5) + expect(references[0].source.id).toBe('signal') + expect(meta.sources.find(p => p.sourceId === 'signal')?.confidence).toBeCloseTo(1, 5) + expect(meta.sources.find(p => p.sourceId === 'noise')?.confidence).toBeCloseTo(0.325, 5) }) it('sourceConfidence: false leaves the fusion unweighted', async () => { @@ -1141,8 +1181,8 @@ describe('accuracy defaults', () => { }) const rk = createRefkit({ providers: [noise, signal], resilience: false, rerank: false, sourceConfidence: false }) const { references, meta } = await rk.searchWithMeta({ query: 'lion', modalities: ['image'] }) - expect(references[0].source.providerId).toBe('noise') // rank-0 tie, input order - expect(meta.providers.every(p => p.confidence === undefined)).toBe(true) + expect(references[0].source.id).toBe('noise') // rank-0 tie, input order + expect(meta.sources.every(p => p.confidence === undefined)).toBe(true) }) it('omits confidence for a fulfilled provider that returned nothing', async () => { @@ -1156,9 +1196,9 @@ describe('accuracy defaults', () => { }) const { meta } = await createRefkit({ providers: [empty, p], resilience: false }) .searchWithMeta({ query: 'lion', modalities: ['image'] }) - const status = meta.providers.find(s => s.providerId === 'empty') - expect(status).toEqual({ providerId: 'empty', status: 'fulfilled', returned: 0, accepted: 0, rejected: 0, latencyMs: expect.any(Number) }) - expect(meta.providers.find(s => s.providerId === 'p')?.confidence).toBe(1) + const status = meta.sources.find(s => s.sourceId === 'empty') + expect(status).toEqual({ sourceId: 'empty', status: 'fulfilled', returned: 0, accepted: 0, rejected: 0, latencyMs: expect.any(Number) }) + expect(meta.sources.find(s => s.sourceId === 'p')?.confidence).toBe(1) }) it('a custom confidence floor deepens the dampening', async () => { @@ -1168,7 +1208,7 @@ describe('accuracy defaults', () => { }) const rk = createRefkit({ providers: [noise], resilience: false, sourceConfidence: { floor: 0.5 } }) const { meta } = await rk.searchWithMeta({ query: 'lion', modalities: ['image'] }) - expect(meta.providers[0].confidence).toBeCloseTo(0.5, 5) + expect(meta.sources[0].confidence).toBeCloseTo(0.5, 5) }) it('the lexical reranker runs by default and rerank:false restores raw fusion order', async () => { diff --git a/packages/core/src/__tests__/confidence.test.ts b/packages/core/src/__tests__/confidence.test.ts index 196d02b..2c52ca0 100644 --- a/packages/core/src/__tests__/confidence.test.ts +++ b/packages/core/src/__tests__/confidence.test.ts @@ -8,7 +8,7 @@ const ref = (id: string, title: string, opts: Partial = {}): Ref id, modality: 'image', title, - source: { providerId: 'p', sourceUrl: `https://x/${id}` }, + source: { id: 'p', url: `https://x/${id}` }, canonicalUrl: `https://x/${id}`, rights: { license: 'CC0-1.0', rehostPolicy: 'cache-allowed', raw: { sourceTerms: 't', sourceUrl: 'u' } }, verifiedAt: '2026-06-24T00:00:00.000Z', diff --git a/packages/core/src/__tests__/dedup-key.test.ts b/packages/core/src/__tests__/dedup-key.test.ts index fc94710..c2a6da1 100644 --- a/packages/core/src/__tests__/dedup-key.test.ts +++ b/packages/core/src/__tests__/dedup-key.test.ts @@ -25,7 +25,7 @@ describe('canonicalizeUrl', () => { }) describe('referenceId', () => { - it('is stable for the same provider+sourceUrl', () => { + it('is stable for the same source id + sourceUrl', () => { expect(referenceId('unsplash', 'https://x/1')).toBe(referenceId('unsplash', 'https://x/1')) }) diff --git a/packages/core/src/__tests__/dedup.test.ts b/packages/core/src/__tests__/dedup.test.ts index a379d09..ea862a0 100644 --- a/packages/core/src/__tests__/dedup.test.ts +++ b/packages/core/src/__tests__/dedup.test.ts @@ -5,7 +5,7 @@ import type { ImageReference, Reference } from '../reference' const make = (over: Partial): Reference => ({ id: over.id ?? 'x', modality: 'image', - source: over.source ?? { providerId: 'p', sourceUrl: 'https://x/1' }, + source: over.source ?? { id: 'p', url: 'https://x/1' }, canonicalUrl: over.canonicalUrl ?? 'https://x/1', rights: { license: 'CC0-1.0', rehostPolicy: 'cache-allowed', raw: { sourceTerms: 't', sourceUrl: 'u' } }, verifiedAt: '2026-06-22T00:00:00.000Z', diff --git a/packages/core/src/__tests__/merge.test.ts b/packages/core/src/__tests__/merge.test.ts index 5383539..8c93c5c 100644 --- a/packages/core/src/__tests__/merge.test.ts +++ b/packages/core/src/__tests__/merge.test.ts @@ -7,7 +7,7 @@ import { evaluateUse } from '../evaluate-use' const make = (id: string, url: string, hash?: string): Reference => ({ id, modality: 'image', - source: { providerId: id.split('-')[0], sourceUrl: url }, + source: { id: id.split('-')[0], url: url }, canonicalUrl: url, rights: { license: 'CC0-1.0', rehostPolicy: 'cache-allowed', raw: { sourceTerms: 't', sourceUrl: url } }, verifiedAt: '2026-06-22T00:00:00.000Z', @@ -150,8 +150,8 @@ describe('mergeReferences (RRF)', () => { }) describe('cross-source rights resolution (facts)', () => { - const ref = (providerId: string, license: string, facts?: LicenseFacts): Reference => ({ - id: `${providerId}:1`, modality: 'image', source: { providerId, sourceUrl: 'https://x.test/a' }, canonicalUrl: 'https://x.test/a', + const ref = (sourceId: string, license: string, facts?: LicenseFacts): Reference => ({ + id: `${sourceId}:1`, modality: 'image', source: { id: sourceId, url: 'https://x.test/a' }, canonicalUrl: 'https://x.test/a', rights: { license, ...(facts ? { facts } : {}), rehostPolicy: 'cache-allowed', raw: { sourceTerms: 't', sourceUrl: 'https://x.test/a' } }, verifiedAt: new Date().toISOString(), relevance: 0, }) @@ -203,8 +203,8 @@ describe('cross-source rights resolution (facts)', () => { // indeterminate side) must not be what opens the conflict, or agreeing // sources would spuriously report one. const seen: RightsConflict[] = [] - const make = (providerId: string): Reference => ({ - id: `${providerId}:1`, modality: 'image', source: { providerId, sourceUrl: 'https://x.test/a' }, canonicalUrl: 'https://x.test/a', + const make = (sourceId: string): Reference => ({ + id: `${sourceId}:1`, modality: 'image', source: { id: sourceId, url: 'https://x.test/a' }, canonicalUrl: 'https://x.test/a', rights: { license: 'unknown', rehostPolicy: 'no-store', raw: { sourceTerms: 't', sourceUrl: 'https://x.test/a' } }, verifiedAt: new Date().toISOString(), relevance: 0, }) diff --git a/packages/core/src/__tests__/provider-run.test.ts b/packages/core/src/__tests__/provider-run.test.ts index d9e91dc..02fbd0f 100644 --- a/packages/core/src/__tests__/provider-run.test.ts +++ b/packages/core/src/__tests__/provider-run.test.ts @@ -45,7 +45,7 @@ describe('providerCacheKey', () => { const key = providerCacheKey('p', q) expect(key.startsWith('refkit:v4:p:')).toBe(true) const cache = memoryCache() - const old = { id: 'p:old', modality: 'image', source: { providerId: 'p', sourceUrl: 'https://x.test/old' }, canonicalUrl: 'https://x.test/old', rights: ref('https://x.test/old').rights, verifiedAt: '2026-01-01T00:00:00.000Z', relevance: 0 } + const old = { id: 'p:old', modality: 'image', source: { id: 'p', url: 'https://x.test/old' }, canonicalUrl: 'https://x.test/old', rights: ref('https://x.test/old').rights, verifiedAt: '2026-01-01T00:00:00.000Z', relevance: 0 } cache.store.set(key.replace(/^refkit:v4:/, 'refkit:v3:'), JSON.stringify({ q: stableStringify(q), refs: [old] })) const run = await runProviderSearch(provider([ref('https://x.test/live')]), q, { fetch: (async () => new Response('')) as typeof fetch, cache, cacheTtlMs: 60_000, cacheRaw: true }) expect(run.ok && run.cached).toBeUndefined() @@ -86,7 +86,7 @@ describe('runProviderSearch completion', () => { const run = await runProviderSearch(provider, { text: 'q', modalities: ['image'], limit: 3 }, { fetch: (async () => new Response('')) as typeof fetch, cacheTtlMs: 0, cacheRaw: true }) expect(run.ok && run.valid.length).toBe(3) expect(run.ok && run.returned).toBe(5) - expect(run.ok && run.valid[0].source.providerId).toBe('p') + expect(run.ok && run.valid[0].source.id).toBe('p') expect(run.ok && run.valid[0].id.startsWith('p:')).toBe(true) }) }) diff --git a/packages/core/src/__tests__/query.test.ts b/packages/core/src/__tests__/query.test.ts index 4c33f45..f3ada49 100644 --- a/packages/core/src/__tests__/query.test.ts +++ b/packages/core/src/__tests__/query.test.ts @@ -36,12 +36,12 @@ describe('normalizeQuery', () => { expect(nq.limit).toBe(10) }) - it('passes only the matching providerOptions entry to the provider query', () => { + it('passes only the matching sourceOptions entry to the provider query', () => { const nq = normalizeQuery( { query: 'cat', modalities: ['image'], - providerOptions: { + sourceOptions: { p: { orderBy: 'latest' }, other: { orderBy: 'relevant' }, }, diff --git a/packages/core/src/__tests__/reference-types.test.ts b/packages/core/src/__tests__/reference-types.test.ts index 1ba631f..723794b 100644 --- a/packages/core/src/__tests__/reference-types.test.ts +++ b/packages/core/src/__tests__/reference-types.test.ts @@ -106,7 +106,7 @@ describe('Reference variants (types)', () => { // @ts-expect-error audio metadata on a video emitted({ modality: 'video', ...base, audio: audioMeta }) // @ts-expect-error the complete Reference rejects it too - complete({ modality: 'text', id: 'p:1', source: { providerId: 'p', sourceUrl: 'https://x/1' }, canonicalUrl: 'https://x/1', rights, verifiedAt: '2026-01-01T00:00:00.000Z', relevance: 0, visual: visualMeta }) + complete({ modality: 'text', id: 'p:1', source: { id: 'p', url: 'https://x/1' }, canonicalUrl: 'https://x/1', rights, verifiedAt: '2026-01-01T00:00:00.000Z', relevance: 0, visual: visualMeta }) }) it('the `?: never` fields catch what excess-property checks miss (non-fresh objects, map callbacks)', () => { diff --git a/packages/core/src/__tests__/reference.test.ts b/packages/core/src/__tests__/reference.test.ts index 0456ecc..62cb828 100644 --- a/packages/core/src/__tests__/reference.test.ts +++ b/packages/core/src/__tests__/reference.test.ts @@ -6,7 +6,7 @@ const ref: Reference = { id: 'p:abc', modality: 'image', title: 'Sunset', - source: { providerId: 'unsplash', sourceUrl: 'https://unsplash.com/photos/1' }, + source: { id: 'unsplash', url: 'https://unsplash.com/photos/1' }, canonicalUrl: 'https://unsplash.com/photos/1', rights: { license: 'unsplash', @@ -46,7 +46,7 @@ describe('referenceSchema / parseReference', () => { const base = { id: 'p:1', modality: 'image', - source: { providerId: 'p', sourceUrl: 'https://p/1' }, + source: { id: 'p', url: 'https://p/1' }, canonicalUrl: 'https://p/1', rights: { license: 'CC0-1.0', rehostPolicy: 'cache-allowed', raw: { sourceTerms: 't', sourceUrl: 'https://p/1' } }, verifiedAt: '2026-07-24T00:00:00.000Z', @@ -66,7 +66,7 @@ describe('EmittedReference → Reference', () => { it('completeReference stamps id, source, canonicalUrl, verifiedAt and relevance', () => { const r = completeReference('p', parseEmitted(emitted), '2026-01-01T00:00:00.000Z') expect(r.id).toMatch(/^p:[0-9a-z]+$/) - expect(r.source).toEqual({ providerId: 'p', sourceUrl: 'https://X.test/a/' }) + expect(r.source).toEqual({ id: 'p', url: 'https://X.test/a/' }) expect(r.canonicalUrl).toBe('https://X.test/a/') expect(r.verifiedAt).toBe('2026-01-01T00:00:00.000Z') expect(r.relevance).toBe(0) @@ -77,7 +77,7 @@ describe('EmittedReference → Reference', () => { it('an explicit canonicalUrl is kept', () => { const r = completeReference('p', parseEmitted({ ...emitted, canonicalUrl: 'https://x.test/canon' }), '2026-01-01T00:00:00.000Z') expect(r.canonicalUrl).toBe('https://x.test/canon') - expect(r.source.sourceUrl).toBe('https://X.test/a/') + expect(r.source.url).toBe('https://X.test/a/') }) it('parseEmitted rejects a missing sourceUrl', () => { expect(() => parseEmitted({ ...emitted, sourceUrl: undefined })).toThrow() diff --git a/packages/core/src/__tests__/rerank.test.ts b/packages/core/src/__tests__/rerank.test.ts index 5d161af..9f82404 100644 --- a/packages/core/src/__tests__/rerank.test.ts +++ b/packages/core/src/__tests__/rerank.test.ts @@ -7,7 +7,7 @@ const ref = (id: string, title: string, opts: Partial = {}): Ref id, modality: 'image', title, - source: { providerId: 'p', sourceUrl: `https://x/${id}` }, + source: { id: 'p', url: `https://x/${id}` }, canonicalUrl: `https://x/${id}`, rights: { license: 'CC0-1.0', rehostPolicy: 'cache-allowed', raw: { sourceTerms: 't', sourceUrl: 'u' } }, verifiedAt: '2026-06-24T00:00:00.000Z', @@ -21,7 +21,7 @@ const textRef = (id: string, title: string, text: TextMeta): Reference => ({ modality: 'text', title, text, - source: { providerId: 'p', sourceUrl: `https://x/${id}` }, + source: { id: 'p', url: `https://x/${id}` }, canonicalUrl: `https://x/${id}`, rights: { license: 'CC0-1.0', rehostPolicy: 'cache-allowed', raw: { sourceTerms: 't', sourceUrl: 'u' } }, verifiedAt: '2026-06-24T00:00:00.000Z', @@ -71,13 +71,13 @@ describe('lexicalReranker', () => { // 3 from "a", 1 from "b", all equal lexical score → default diversity must // interleave "b" before the third "a". const refs = [ - ref('a1', 'lion', { source: { providerId: 'a', sourceUrl: 'https://x/a1' } }), - ref('a2', 'lion', { source: { providerId: 'a', sourceUrl: 'https://x/a2' } }), - ref('a3', 'lion', { source: { providerId: 'a', sourceUrl: 'https://x/a3' } }), - ref('b1', 'lion', { source: { providerId: 'b', sourceUrl: 'https://x/b1' } }), + ref('a1', 'lion', { source: { id: 'a', url: 'https://x/a1' } }), + ref('a2', 'lion', { source: { id: 'a', url: 'https://x/a2' } }), + ref('a3', 'lion', { source: { id: 'a', url: 'https://x/a3' } }), + ref('b1', 'lion', { source: { id: 'b', url: 'https://x/b1' } }), ] const out = await lexicalReranker()({ query: 'lion', refs }) - const sources = out.map((r) => r.source.providerId) + const sources = out.map((r) => r.source.id) expect(sources.slice(0, 2)).toEqual(['a', 'b']) // b promoted above the 2nd+ a }) @@ -91,11 +91,11 @@ describe('lexicalReranker', () => { // still surfaces second. Titles differ per ref, so no near-duplicate penalty // is doing this work. const fromA = (id: string) => - ref(id, `lion ${id}`, { relevance: 1, source: { providerId: 'a', sourceUrl: `https://x/${id}` } }) - const b1 = ref('b1', 'lion cub', { relevance: 0.9, source: { providerId: 'b', sourceUrl: 'https://x/b1' } }) + ref(id, `lion ${id}`, { relevance: 1, source: { id: 'a', url: `https://x/${id}` } }) + const b1 = ref('b1', 'lion cub', { relevance: 0.9, source: { id: 'b', url: 'https://x/b1' } }) const out = await lexicalReranker()({ query: 'lion', refs: [fromA('a1'), fromA('a2'), fromA('a3'), b1] }) expect(out.map(r => r.id)).toEqual(['a1', 'b1', 'a2', 'a3']) - expect(out.findIndex(r => r.source.providerId === 'b')).toBeLessThan(3) + expect(out.findIndex(r => r.source.id === 'b')).toBeLessThan(3) }) it('prefers a more permissive license on a tie when licenseWeight > 0', async () => { @@ -109,7 +109,7 @@ describe('lexicalReranker', () => { it('license boost is derived from facts: CC0 outranks CC-BY outranks unknown', () => { const mk = (id: string, license: string): Reference => ({ - id, modality: 'image', title: 'same', source: { providerId: 'p', sourceUrl: `https://x.test/${id}` }, canonicalUrl: `https://x.test/${id}`, + id, modality: 'image', title: 'same', source: { id: 'p', url: `https://x.test/${id}` }, canonicalUrl: `https://x.test/${id}`, rights: { license, rehostPolicy: 'cache-allowed', raw: { sourceTerms: 't', sourceUrl: 'u' } }, verifiedAt: new Date().toISOString(), relevance: 0, }) const out = lexicalReranker({ lexicalWeight: 0, qualityWeight: 0, licenseWeight: 1, sourceDiversity: 0 })({ query: 'same', refs: [mk('u', 'unknown'), mk('b', 'CC-BY'), mk('z', 'CC0-1.0')] }) as Reference[] @@ -161,7 +161,7 @@ describe('lexicalReranker', () => { // A Commons upload batch: three near-identical titles from one source, all // matching the query as well as the genuinely different fourth result. const wiki = (id: string, title: string) => - ref(id, title, { source: { providerId: 'wiki', sourceUrl: `https://x/${id}` } }) + ref(id, title, { source: { id: 'wiki', url: `https://x/${id}` } }) const dupes = [ 'Forest path near Graigddu-isaf 1', 'Forest path near Graigddu-isaf 2', @@ -185,7 +185,7 @@ describe('lexicalReranker', () => { // its place to a weaker-matching sibling. `u1`'s title tokenizes to nothing // (all stopwords), which is the same empty set by another route. const wiki = (id: string, opts: Partial) => - ref(id, '', { source: { providerId: 'wiki', sourceUrl: `https://x/${id}` }, ...opts }) + ref(id, '', { source: { id: 'wiki', url: `https://x/${id}` }, ...opts }) const described = 'a forest path near the river bridge' const refs = [ wiki('u0', { title: undefined, description: described }), @@ -219,7 +219,7 @@ describe('lexicalReranker', () => { it('nearDuplicateThreshold decides what counts as a repeat (0.8 pair penalised at the default, spared at 1)', () => { const wiki = (id: string, title: string) => - ref(id, title, { source: { providerId: 'wiki', sourceUrl: `https://x/${id}` } }) + ref(id, title, { source: { id: 'wiki', url: `https://x/${id}` } }) // Title-token Jaccard against 'Forest path near river': exact1 → 1.0, // bridge → 4/5 = 0.8 (over the 0.7 default, under a raised 1), weak → 0.4. // Query coverage over 5 query tokens: 4/5 = 0.8 for the three 'Forest path @@ -277,7 +277,7 @@ describe('lexicalReranker', () => { // re-derived against every already-picked title on every round: the latter is // O(n³) and would blow the test timeout on a realistic overfetched pool. const refs = Array.from({ length: 1200 }, (_, i) => - ref(`r${i}`, `Forest path near Graigddu-isaf ${i}`, { source: { providerId: 'wiki', sourceUrl: `https://x/r${i}` } })) + ref(`r${i}`, `Forest path near Graigddu-isaf ${i}`, { source: { id: 'wiki', url: `https://x/r${i}` } })) const out = lexicalReranker()({ query: 'forest path', refs }) as Reference[] expect(out).toHaveLength(1200) expect(new Set(out.map(r => r.id)).size).toBe(1200) // a reorder, not a resample diff --git a/packages/core/src/__tests__/select.test.ts b/packages/core/src/__tests__/select.test.ts index 5655b00..7b50568 100644 --- a/packages/core/src/__tests__/select.test.ts +++ b/packages/core/src/__tests__/select.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from 'vitest' import { defineProvider } from '../provider' -import { selectProviders } from '../select' +import { selectProviders, SourceSelectionError } from '../select' const p = (id: string, modalities: Array<'image' | 'text'>, kinds?: string[]) => defineProvider({ id, modalities, ...(kinds ? { kinds } : {}), search: async () => [] }) @@ -30,9 +30,79 @@ describe('selectProviders', () => { it('throws when nothing matches', () => { expect(() => selectProviders(providers, { modalities: ['image'], sources: ['zzz'], text: 'x' })) - .toThrow(/no configured provider matches source id/) + .toThrow(/no configured source matches id/) expect(() => selectProviders(providers, { modalities: ['video'], text: 'x' })) - .toThrow(/no registered provider supports/) + .toThrow(/no configured source supports/) + }) + + describe('SourceSelectionError', () => { + const miss = (input: Parameters[1], from = providers): SourceSelectionError => { + try { + selectProviders(from, input) + } catch (e) { + expect(e).toBeInstanceOf(SourceSelectionError) + expect(e).toBeInstanceOf(Error) + return e as SourceSelectionError + } + throw new Error('expected selectProviders to throw') + } + + it('an unknown id: no-matching-source, the id listed as unknown', () => { + const e = miss({ modalities: ['image'], sources: ['zzz'], text: 'x' }) + expect(e.name).toBe('SourceSelectionError') + expect(e.code).toBe('no-matching-source') + expect(e.requested).toEqual(['zzz']) + expect(e.unknown).toEqual(['zzz']) + expect(e.modalities).toEqual(['image']) + expect(e.kind).toBeUndefined() + }) + + it('a known id without the modality: no-matching-source, nothing unknown', () => { + // `c` is configured, but only for text: it is not unknown. + const e = miss({ modalities: ['image'], sources: ['c'], text: 'x' }) + expect(e.code).toBe('no-matching-source') + expect(e.requested).toEqual(['c']) + expect(e.unknown).toEqual([]) + expect(e.modalities).toEqual(['image']) + // Mixed with a typo, only the typo is unknown. + expect(miss({ modalities: ['image'], sources: ['c', 'zzz'], text: 'x' }).unknown).toEqual(['zzz']) + }) + + it('a known id excluded by kind: no-matching-source, carrying the kind', () => { + const e = miss({ modalities: ['image'], sources: ['a'], kind: 'texture', text: 'x' }) + expect(e.code).toBe('no-matching-source') + expect(e.requested).toEqual(['a']) + expect(e.unknown).toEqual([]) + expect(e.kind).toBe('texture') + expect(e.message).toContain('kind "texture"') + }) + + it('sources: [] is a no-matching-source miss with nothing requested', () => { + const e = miss({ modalities: ['image'], sources: [], text: 'x' }) + expect(e.code).toBe('no-matching-source') + expect(e.requested).toEqual([]) + expect(e.unknown).toEqual([]) + }) + + it('no source for the query: no-source-for-query, no requested list', () => { + const e = miss({ modalities: ['video'], text: 'x' }) + expect(e.code).toBe('no-source-for-query') + expect(e.requested).toBeUndefined() + expect(e.unknown).toEqual([]) + expect(e.modalities).toEqual(['video']) + expect(e.kind).toBeUndefined() + }) + + it('a kind miss without sources: no-source-for-query, carrying the kind', () => { + // Every image source declares a kind other than the one asked for. + const kinded = [p('a', ['image'], ['photo']), p('d', ['image'], ['vector'])] + const e = miss({ modalities: ['image'], kind: 'texture', text: 'x' }, kinded) + expect(e.code).toBe('no-source-for-query') + expect(e.requested).toBeUndefined() + expect(e.unknown).toEqual([]) + expect(e.kind).toBe('texture') + expect(e.message).toContain('kind "texture"') + }) }) it('accepts=false skips with reason declined; an explicit sources entry bypasses it', () => { diff --git a/packages/core/src/client.ts b/packages/core/src/client.ts index 200f433..ff57182 100644 --- a/packages/core/src/client.ts +++ b/packages/core/src/client.ts @@ -5,13 +5,13 @@ import type { Intent, Verdict } from './evaluate-use' import { evaluateUse } from './evaluate-use' import type { Attribution } from './attribution' import { buildAttribution } from './attribution' -import type { ReferenceProvider, KeyValueCache, ProviderOptionsById } from './provider' +import type { ReferenceProvider, KeyValueCache, SourceOptionsById } from './provider' import type { SearchControlKey, SearchControls } from './controls' import type { MergeOptions, RightsConflict } from './merge' import { DEFAULT_CONFIDENCE_FLOOR } from './confidence' import { retryingFetch, withDefaultUserAgent, withTimeout, type TimeoutHandle } from './resilience' import { cursorSeenKey, decodeCursor, encodeCursor } from './cursor' -import { selectProviders, type ProviderSkipReason } from './select' +import { selectProviders, type SourceSkipReason } from './select' import { runPass, type PassDeps, type PassOutcome } from './pipeline' export interface ResilienceOptions { @@ -43,7 +43,7 @@ export interface RefkitOptions { * its score. With `fusionWeight: 0` — or a BYO reranker that ignores the * incoming `relevance` — confidence only breaks ties among refs the reranker * scores equally, and otherwise survives as the `confidence` diagnostic on - * each `meta.providers` entry. */ + * each `meta.sources` entry. */ sourceConfidence?: boolean | { floor?: number } /** Per-provider timeout + retry (H8). Defaults ON; pass `false` to disable both. */ resilience?: ResilienceOptions | false @@ -72,27 +72,27 @@ export interface RefkitOptions { maxCursorSeen?: number } -export interface ProviderError { - providerId: string +export interface SourceError { + sourceId: string error: unknown } -export interface ProviderSearchStatus { - providerId: string +export interface SourceStatus { + sourceId: string status: 'fulfilled' | 'failed' | 'skipped' returned?: number accepted?: number - /** Items the provider returned that failed schema validation, or whose + /** Items the source returned that failed schema validation, or whose * modality is outside this source's share of the requested modalities (the - * ones it declares); each also fires onProviderError. Items dropped by the + * ones it declares); each also fires onSourceError. Items dropped by the * `limit` truncation are neither accepted nor rejected. */ rejected?: number - reason?: ProviderSkipReason + reason?: SourceSkipReason error?: string latencyMs?: number cached?: boolean /** How well this source's batch matched the query (0..1), the multiplier its - * rank positions carried into the fusion. Present on fulfilled providers while + * rank positions carried into the fusion. Present on fulfilled sources while * `sourceConfidence` is on. */ confidence?: number } @@ -111,8 +111,8 @@ export interface SearchThresholdMeta { export interface SearchControlsMeta { requested: SearchControlKey[] - appliedByProvider: Record - ignoredByProvider: Record + appliedBySource: Record + ignoredBySource: Record } export interface SearchMeta { @@ -124,8 +124,9 @@ export interface SearchMeta { /** Fan-out passes this call ran (>1 only when the cursor advanced pages). */ passes: number controls?: SearchControlsMeta - providerOptions?: string[] - providers: ProviderSearchStatus[] + /** The `sourceOptions` keys the caller passed. */ + sourceOptions?: string[] + sources: SourceStatus[] gate?: SearchGateMeta /** Present when `minRelevance` was set: the bar and how many results it cut. */ threshold?: SearchThresholdMeta @@ -144,20 +145,23 @@ export interface SearchResult { export interface SearchInput { query: string modalities: Modality[] - /** Restrict this search to these provider ids (intersected with modality + /** Restrict this search to these source ids (intersected with modality * matching). Omit to fan out to every configured source. Lets the caller * scope search-engine operators (e.g. `site:xiaohongshu.com`) to a - * web-discovery source without polluting other providers' queries. + * web-discovery source without polluting other sources' queries. * - * A total miss — no requested id matches a configured provider for the - * requested modalities — throws (a source typo must fail loudly, not read as - * "no results"); ids that resolve to nothing while others still match are + * A total miss — no requested id matches a configured source for the + * requested modalities — throws a `SourceSelectionError` with code + * `'no-matching-source'` (a source typo must fail loudly, not read as "no + * results"); ids that resolve to nothing while others still match are * reported in `meta.warnings`. */ sources?: string[] controls?: SearchControls - /** Provider-specific search controls keyed by provider id. Core routes only the - * matching entry to each provider; providers whitelist what they translate. */ - providerOptions?: ProviderOptionsById + /** Source-specific search options keyed by source id. Core routes only the + * matching entry to each source (its provider reads it as + * `NormalizedQuery.providerOptions`); providers whitelist what they translate. + * Ids that match no configured source are reported in `meta.warnings`. */ + sourceOptions?: SourceOptionsById limit?: number /** Opaque cursor from a previous search's `meta.nextCursor`. Resumes the * provider-local page (overriding `controls.page`), filters out results @@ -181,7 +185,7 @@ export interface SearchInput { gateFor?: Intent /** Context for the search-time gate (`gateFor`), matching evaluateUse's ctx. */ gateContext?: { userJurisdiction?: string } - onProviderError?: (e: ProviderError) => void + onSourceError?: (e: SourceError) => void /** Override the client's reranker for this call: a {@link Reranker} to use * instead, or `false` for raw cross-source rank fusion. Omit to keep the * client's default (the lexical reranker unless `RefkitOptions.rerank` says @@ -237,6 +241,15 @@ export function createRefkit(options: RefkitOptions): RefkitClient { if (!Array.isArray(options.providers) || options.providers.length === 0) { throw new Error('createRefkit: providers must be a non-empty array (did you forget to await an async provider factory?)') } + // A source id names one source in `sources`, `sourceOptions`, `meta.sources`, + // reference ids and cache keys. Two providers sharing one would be + // indistinguishable in all of them, so the second must not pass silently. + const sourceIds = new Set() + const duplicateIds = new Set() + for (const p of options.providers) (sourceIds.has(p.id) ? duplicateIds : sourceIds).add(p.id) + if (duplicateIds.size > 0) { + throw new Error(`createRefkit: duplicate source id(s) [${[...duplicateIds].join(', ')}] — each provider instance must serve its own source id`) + } // Built once, not per search: the default reranker is stateless and its weight // resolution is fixed for the life of the client. const defaultReranker = options.rerank === undefined ? lexicalReranker() : (options.rerank || undefined) @@ -298,7 +311,7 @@ export function createRefkit(options: RefkitOptions): RefkitClient { query: input.query, modalities: input.modalities, controls: input.controls, - providerOptions: input.providerOptions, + sourceOptions: input.sourceOptions, fetchLimit, run: { fetch: sharedFetch, @@ -317,7 +330,7 @@ export function createRefkit(options: RefkitOptions): RefkitClient { gateContext: input.gateContext, seen, signal, - onProviderError: (providerId, error) => input.onProviderError?.({ providerId, error }), + onSourceError: (sourceId, error) => input.onSourceError?.({ sourceId, error }), } // Providers fetch fetchLimit (≥ limit) candidates per page, but each call // returns only `limit` — so the cursor must NOT advance the provider page @@ -355,14 +368,20 @@ export function createRefkit(options: RefkitOptions): RefkitClient { : undefined const warnings: string[] = [] if (selection.unknownSources.length > 0) warnings.push(`unknown source id(s) ignored: ${selection.unknownSources.join(', ')}.`) - // Per-pass failure counts: a provider that failed on an earlier page and + // Same for an option keyed by a typo: it would otherwise be dropped silently. + // A configured source that merely sat this search out is not unknown. + const unknownOptionIds = input.sourceOptions + ? Object.keys(input.sourceOptions).filter(id => !options.providers.some(p => p.id === id)) + : [] + if (unknownOptionIds.length > 0) warnings.push(`unknown sourceOptions id(s) ignored: ${unknownOptionIds.join(', ')}.`) + // Per-pass failure counts: a source that failed on an earlier page and // recovered on a later one must still be visible. passes.forEach((pass, i) => { - const failed = [...pass.statusByProvider.values()].filter(s => s.status === 'failed').length + const failed = [...pass.statusBySource.values()].filter(s => s.status === 'failed').length if (failed === 0) return warnings.push(passes.length > 1 - ? `pass ${i + 1}: ${failed} provider(s) failed; returning partial results.` - : `${failed} provider(s) failed; returning partial results.`) + ? `pass ${i + 1}: ${failed} source(s) failed; returning partial results.` + : `${failed} source(s) failed; returning partial results.`) }) // Conflicts concatenate across passes but are reported once per URL — the // same URL conflicts again on every page that returns it. @@ -378,11 +397,11 @@ export function createRefkit(options: RefkitOptions): RefkitClient { if (last.gate && last.gate.dropped > 0) warnings.push(`${last.gate.dropped} result(s) dropped by ${last.gate.intent} gate.`) // Status: the last pass wins (it produced the results), but latency sums // over every pass — a multi-pass call really did spend that long. - const providers = options.providers.map((p): ProviderSearchStatus => { - const status = last.statusByProvider.get(p.id) - ?? { providerId: p.id, status: 'skipped' as const, reason: selection.skipReasons.get(p.id) ?? 'unsupported-modality' as const } + const sources = options.providers.map((p): SourceStatus => { + const status = last.statusBySource.get(p.id) + ?? { sourceId: p.id, status: 'skipped' as const, reason: selection.skipReasons.get(p.id) ?? 'unsupported-modality' as const } if (status.status === 'skipped') return status - return { ...status, latencyMs: passes.reduce((sum, pass) => sum + (pass.statusByProvider.get(p.id)?.latencyMs ?? 0), 0) } + return { ...status, latencyMs: passes.reduce((sum, pass) => sum + (pass.statusBySource.get(p.id)?.latencyMs ?? 0), 0) } }) return { references, @@ -394,8 +413,8 @@ export function createRefkit(options: RefkitOptions): RefkitClient { fetchLimit, passes: passes.length, ...(last.controlsMeta ? { controls: last.controlsMeta } : {}), - ...(input.providerOptions ? { providerOptions: Object.keys(input.providerOptions) } : {}), - providers, + ...(input.sourceOptions ? { sourceOptions: Object.keys(input.sourceOptions) } : {}), + sources, ...(last.gate ? { gate: last.gate } : {}), ...(last.threshold ? { threshold: last.threshold } : {}), ...(nextCursor ? { nextCursor } : {}), diff --git a/packages/core/src/dedup-key.ts b/packages/core/src/dedup-key.ts index a866ae8..a3b6d50 100644 --- a/packages/core/src/dedup-key.ts +++ b/packages/core/src/dedup-key.ts @@ -17,6 +17,6 @@ export function canonicalizeUrl(url: string): string { } /** Content-addressed id: stable within a result set, not promised across calls. */ -export function referenceId(providerId: string, sourceUrl: string): string { - return `${providerId}:${fnv1a(canonicalizeUrl(sourceUrl))}` +export function referenceId(sourceId: string, sourceUrl: string): string { + return `${sourceId}:${fnv1a(canonicalizeUrl(sourceUrl))}` } diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 4ab84f4..7c50c21 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -56,7 +56,7 @@ export type { WellKnownKind, ResourceKind, } from './controls' -export { searchMetaSchema, providerSearchStatusSchema, searchControlKeySchema } from './schemas' +export { searchMetaSchema, sourceStatusSchema, searchControlKeySchema } from './schemas' export { defineProvider } from './provider' export type { ReferenceProvider, @@ -65,7 +65,7 @@ export type { ProviderCapabilities, ProviderOptionValue, ProviderOptions, - ProviderOptionsById, + SourceOptionsById, KeyValueCache, } from './provider' export { @@ -77,8 +77,8 @@ export { export { normalizeQuery } from './query' export { runProviderSearch, providerCacheKey, stableStringify } from './provider-run' export type { ProviderRun, ProviderRunDeps } from './provider-run' -export { selectProviders, PROVIDER_SKIP_REASONS } from './select' -export type { ProviderSkipReason, ProviderSelection } from './select' +export { selectProviders, SOURCE_SKIP_REASONS, SourceSelectionError } from './select' +export type { SourceSkipReason, ProviderSelection } from './select' export { runPass } from './pipeline' export type { PassDeps, PassOutcome } from './pipeline' export { createRefkit } from './client' @@ -92,8 +92,8 @@ export type { SearchControlsMeta, SearchGateMeta, SearchThresholdMeta, - ProviderSearchStatus, - ProviderError, + SourceStatus, + SourceError, } from './client' export { lexicalReranker, tokenize, refText } from './rerank' export type { Reranker, RerankInput, LexicalRerankOptions } from './rerank' diff --git a/packages/core/src/pipeline.ts b/packages/core/src/pipeline.ts index cfb9153..17b6548 100644 --- a/packages/core/src/pipeline.ts +++ b/packages/core/src/pipeline.ts @@ -9,26 +9,27 @@ import { sourceConfidence } from './confidence' import type { Modality } from './modality' import type { Intent } from './evaluate-use' import { evaluateUse } from './evaluate-use' -import type { ProviderOptionsById, ReferenceProvider } from './provider' +import type { ReferenceProvider, SourceOptionsById } from './provider' import type { SearchControls } from './controls' import { mergeReferences, type MergeOptions, type RightsConflict } from './merge' import { normalizeQuery, requestedControlKeys, supportedControlKeys, unsupportedControlKeys } from './query' import { runProviderSearch, type ProviderRun, type ProviderRunDeps } from './provider-run' import { cursorSeenKey } from './cursor' -import type { ProviderSkipReason } from './select' +import type { SourceSkipReason } from './select' // Types only — the client imports this module, never the other way round. -import type { ProviderSearchStatus, SearchControlsMeta, SearchGateMeta, SearchThresholdMeta } from './client' +import type { SourceStatus, SearchControlsMeta, SearchGateMeta, SearchThresholdMeta } from './client' export interface PassDeps { /** Every configured provider — statuses are reported for all of them. */ providers: readonly ReferenceProvider[] /** The subset this search fans out to (from selectProviders). */ chosen: readonly ReferenceProvider[] - skipReasons: Map + skipReasons: Map query: string modalities: Modality[] controls?: SearchControls - providerOptions?: ProviderOptionsById + /** Per-source options keyed by source id; each provider gets only its own entry. */ + sourceOptions?: SourceOptionsById /** Per-provider candidate budget (the overfetched pool, ≥ the caller's limit). */ fetchLimit: number run: ProviderRunDeps @@ -48,13 +49,13 @@ export interface PassDeps { /** Already-returned cursor keys to filter out (load-more). */ seen?: Set signal?: AbortSignal - onProviderError?: (providerId: string, error: unknown) => void + onSourceError?: (sourceId: string, error: unknown) => void } export interface PassOutcome { refs: Reference[] // post merge/rerank/threshold/gate/seen-filter, best-first controlsMeta?: SearchControlsMeta - statusByProvider: Map + statusBySource: Map gate?: SearchGateMeta threshold?: SearchThresholdMeta rightsConflicts: RightsConflict[] @@ -81,15 +82,15 @@ async function mapBounded(items: readonly T[], limit: number, fn: (item: T return results } -/** What each provider was asked for vs. what it declares it can honour. */ +/** What each source was asked for vs. what its provider declares it can honour. */ export function controlsMetaFor(providers: readonly ReferenceProvider[], controls: SearchControls | undefined): SearchControlsMeta | undefined { const asked = controls ?? {} const requested = requestedControlKeys(asked) if (requested.length === 0) return undefined return { requested, - appliedByProvider: Object.fromEntries(providers.map(p => [p.id, supportedControlKeys(p, asked)])), - ignoredByProvider: Object.fromEntries(providers.map(p => [p.id, unsupportedControlKeys(p, asked)])), + appliedBySource: Object.fromEntries(providers.map(p => [p.id, supportedControlKeys(p, asked)])), + ignoredBySource: Object.fromEntries(providers.map(p => [p.id, unsupportedControlKeys(p, asked)])), } } @@ -101,31 +102,31 @@ export async function fanOut(deps: PassDeps, controls: SearchControls | undefine query: deps.query, modalities: deps.modalities, controls, - providerOptions: deps.providerOptions, + sourceOptions: deps.sourceOptions, limit: deps.fetchLimit, }, p) - return runProviderSearch(p, query, { ...deps.run, onError: error => deps.onProviderError?.(p.id, error) }) + return runProviderSearch(p, query, { ...deps.run, onError: error => deps.onSourceError?.(p.id, error) }) } return deps.concurrency ? mapBounded(deps.chosen, deps.concurrency, runProvider) : Promise.all(deps.chosen.map(runProvider)) } -/** Per-provider diagnostics for this pass, plus the ranked lists to merge. - * `perSourceIds` is parallel to `perSource` (a failed provider contributes no +/** Per-source diagnostics for this pass, plus the ranked lists to merge. + * `perSourceIds` is parallel to `perSource` (a failed source contributes no * list, so index i of `perSource` is not index i of `chosen`). */ export function collectStatuses(deps: PassDeps, runs: readonly ProviderRun[]): { - statusByProvider: Map + statusBySource: Map perSource: Reference[][] perSourceIds: string[] totalReturned: number } { - const statusByProvider = new Map() + const statusBySource = new Map() for (const p of deps.providers) { - // skipReasons explains WHY a provider sat this search out (sources filter vs + // skipReasons explains WHY a source sat this search out (sources filter vs // wrong modality vs undeclared kind). const reason = deps.skipReasons.get(p.id) - if (reason) statusByProvider.set(p.id, { providerId: p.id, status: 'skipped', reason }) + if (reason) statusBySource.set(p.id, { sourceId: p.id, status: 'skipped', reason }) } const perSource: Reference[][] = [] const perSourceIds: string[] = [] @@ -134,8 +135,8 @@ export function collectStatuses(deps: PassDeps, runs: readonly ProviderRun[]): { const provider = deps.chosen[i] if (run.ok) { totalReturned += run.returned - statusByProvider.set(provider.id, { - providerId: provider.id, + statusBySource.set(provider.id, { + sourceId: provider.id, status: 'fulfilled', returned: run.returned, accepted: run.valid.length, @@ -146,10 +147,10 @@ export function collectStatuses(deps: PassDeps, runs: readonly ProviderRun[]): { perSource.push(run.valid) perSourceIds.push(provider.id) } else { - statusByProvider.set(provider.id, { providerId: provider.id, status: 'failed', error: errorSummary(run.error), latencyMs: run.latencyMs }) + statusBySource.set(provider.id, { sourceId: provider.id, status: 'failed', error: errorSummary(run.error), latencyMs: run.latencyMs }) } }) - return { statusByProvider, perSource, perSourceIds, totalReturned } + return { statusBySource, perSource, perSourceIds, totalReturned } } /** Per-source merge weights from lexical confidence, stamped onto each fulfilled @@ -166,15 +167,15 @@ export function confidenceStage( deps: PassDeps, perSource: readonly Reference[][], perSourceIds: readonly string[], - statusByProvider: Map, + statusBySource: Map, ): number[] | undefined { if (!deps.confidence) return undefined const { floor } = deps.confidence const qTokens = [...new Set(tokenize(deps.query))] const weights = perSource.map(list => sourceConfidence(qTokens, list, floor)) perSourceIds.forEach((id, i) => { - const status = statusByProvider.get(id) - if (status && perSource[i].length > 0) statusByProvider.set(id, { ...status, confidence: weights[i] }) + const status = statusBySource.get(id) + if (status && perSource[i].length > 0) statusBySource.set(id, { ...status, confidence: weights[i] }) }) return weights } @@ -233,16 +234,16 @@ export async function runPass(deps: PassDeps, page: number | undefined): Promise const controls = page !== undefined ? { ...deps.controls, page } : deps.controls const controlsMeta = controlsMetaFor(deps.providers, controls) const runs = await fanOut(deps, controls) - const { statusByProvider, perSource, perSourceIds, totalReturned } = collectStatuses(deps, runs) + const { statusBySource, perSource, perSourceIds, totalReturned } = collectStatuses(deps, runs) if (deps.chosen.length > 0 && !runs.some(r => r.ok)) { - throw new AggregateError(runs.filter(r => !r.ok).map(r => (r as { error: unknown }).error), 'refkit.search: all providers failed') + throw new AggregateError(runs.filter(r => !r.ok).map(r => (r as { error: unknown }).error), 'refkit.search: all sources failed') } const rightsConflicts: RightsConflict[] = [] - const weights = confidenceStage(deps, perSource, perSourceIds, statusByProvider) + const weights = confidenceStage(deps, perSource, perSourceIds, statusBySource) const merged = mergeStage(deps, perSource, weights, rightsConflicts) const ranked = await rerankStage(deps, merged) const thresholded = applyThreshold(ranked, deps.minRelevance) const gated = applyGate(thresholded.refs, deps.gateFor, deps.gateContext) const refs = filterSeen(gated.refs, deps.seen) - return { refs, controlsMeta, statusByProvider, gate: gated.gate, threshold: thresholded.threshold, rightsConflicts, totalReturned } + return { refs, controlsMeta, statusBySource, gate: gated.gate, threshold: thresholded.threshold, rightsConflicts, totalReturned } } diff --git a/packages/core/src/provider-run.ts b/packages/core/src/provider-run.ts index 6a6f995..7211b04 100644 --- a/packages/core/src/provider-run.ts +++ b/packages/core/src/provider-run.ts @@ -8,8 +8,8 @@ import type { KeyValueCache, NormalizedQuery, ProviderContext, ReferenceProvider import { withTimeout } from './resilience' import { fnv1a } from './hash' -// Deterministic JSON for cache keys: object keys sorted recursively, so a -// caller's providerOptions key order can't split otherwise-identical keys. +// Deterministic JSON for cache keys: object keys sorted recursively, so the key +// order of a caller's sourceOptions entry can't split otherwise-identical keys. // Keys whose value is `undefined` are skipped, matching JSON.stringify's own // object semantics — `{ a: 1, b: undefined }` and `{ a: 1 }` must key alike. export function stableStringify(value: unknown): string { @@ -28,14 +28,16 @@ export function stableStringify(value: unknown): string { * normalized-query string and the read path verifies it before trusting a hit * (see runProviderSearch), so a colliding key degrades to a cache miss. The two * hash passes (raw + length-salted) exist only to make that degradation rare. */ -export function providerCacheKey(providerId: string, query: NormalizedQuery): string { - return keyForFingerprint(providerId, stableStringify(query)) +export function providerCacheKey(sourceId: string, query: NormalizedQuery): string { + return keyForFingerprint(sourceId, stableStringify(query)) } -// v4: Reference became a union keyed by modality. Entries cached in an older -// shape are missed rather than parsed (and rejected item by item). -function keyForFingerprint(providerId: string, fingerprint: string): string { - return `refkit:v4:${providerId}:${fnv1a(fingerprint)}${fnv1a(`${fingerprint.length}:${fingerprint}`)}` +// v4: `source.providerId` became `source.id` (and `sourceUrl` became `url`), and +// Reference became a union keyed by modality. Every entry cached in the old shape +// would fail parseReference and empty the search for the TTL, so it is missed +// instead of parsed. +function keyForFingerprint(sourceId: string, fingerprint: string): string { + return `refkit:v4:${sourceId}:${fnv1a(fingerprint)}${fnv1a(`${fingerprint.length}:${fingerprint}`)}` } /** Shape of a cached entry: the query fingerprint (verified on read so a key diff --git a/packages/core/src/provider.ts b/packages/core/src/provider.ts index 4a6a374..4383a7c 100644 --- a/packages/core/src/provider.ts +++ b/packages/core/src/provider.ts @@ -10,12 +10,14 @@ export interface ProviderCapabilities { export type ProviderOptionValue = string | number | boolean | readonly string[] | undefined export type ProviderOptions = Record -export type ProviderOptionsById = Record +/** Per-source options, keyed by source id (`provider.id`). */ +export type SourceOptionsById = Record export interface NormalizedQuery { text: string modalities: Modality[] controls?: SearchControls + /** This provider's own options: the caller's `sourceOptions` entry for its source id. */ providerOptions?: ProviderOptions limit?: number } diff --git a/packages/core/src/query.ts b/packages/core/src/query.ts index 2db0273..82d2f35 100644 --- a/packages/core/src/query.ts +++ b/packages/core/src/query.ts @@ -1,5 +1,5 @@ import type { Modality } from './modality' -import type { NormalizedQuery, ProviderOptionsById, ReferenceProvider } from './provider' +import type { NormalizedQuery, ReferenceProvider, SourceOptionsById } from './provider' import { SEARCH_CONTROL_KEYS, getControl, @@ -40,7 +40,7 @@ function normalizeControlsForProvider(controls: SearchControls | undefined, prov } export function normalizeQuery( - input: { query: string; modalities: Modality[]; controls?: SearchControls; providerOptions?: ProviderOptionsById; limit?: number }, + input: { query: string; modalities: Modality[]; controls?: SearchControls; sourceOptions?: SourceOptionsById; limit?: number }, provider: ReferenceProvider, ): NormalizedQuery { const controls = normalizeControlsForProvider(input.controls, provider) @@ -48,7 +48,7 @@ export function normalizeQuery( text: input.query, modalities: input.modalities.filter(m => provider.modalities.includes(m)), ...(controls ? { controls } : {}), - ...(input.providerOptions?.[provider.id] ? { providerOptions: input.providerOptions[provider.id] } : {}), + ...(input.sourceOptions?.[provider.id] ? { providerOptions: input.sourceOptions[provider.id] } : {}), ...(input.limit !== undefined ? { limit: input.limit } : {}), } } diff --git a/packages/core/src/reference.ts b/packages/core/src/reference.ts index 5edf5f9..966a771 100644 --- a/packages/core/src/reference.ts +++ b/packages/core/src/reference.ts @@ -21,6 +21,7 @@ export interface VideoMeta { export interface AudioMeta { /** Seconds, as a float. */ durationSec?: number + /** Kilobits per second (bits/s ÷ 1000). */ bitrateKbps?: number sampleRateHz?: number channels?: number @@ -86,9 +87,11 @@ interface EmittedTail { } /** What core stamps onto an emitted item (see completeReference). */ interface StampedTail { - /** Content-addressed: `${providerId}:${hash(sourceUrl)}`; stable within a result set. */ + /** Content-addressed: `${sourceId}:${hash(sourceUrl)}`; stable within a result set. */ id: string - source: { providerId: string; sourceUrl: string } + /** Where this reference came from: the source's id (`provider.id`, what `sources` + * and `meta.sources` speak) and the landing page at that source. */ + source: { id: string; url: string } canonicalUrl: string /** ISO; the moment the satellite's output was parsed. */ verifiedAt: string @@ -117,8 +120,9 @@ export interface AudioReference extends CommonFields, StampedTail, AudioFields, export interface TextReference extends CommonFields, StampedTail, TextFields, Absent { modality: 'text' } /** A complete reference: the emitted variant without sourceUrl/canonicalUrl, plus - * the core-stamped fields. A type alias, so `Omit` collapses to the - * common keys; omit per variant instead. */ + * the core-stamped fields. A type alias of a union, so `Omit` loses + * the link between `modality` and its fields (use a distributive omit), and an + * interface can no longer `extend` it. */ export type Reference = ImageReference | VideoReference | AudioReference | TextReference const mediaSchema = z.object({ url: z.string(), width: z.number().optional(), height: z.number().optional() }) @@ -161,7 +165,7 @@ const VARIANT_SHAPES = { const emittedTail = { sourceUrl: z.string().min(1), canonicalUrl: z.string().min(1).optional() } const stampedTail = { id: z.string().min(1), - source: z.object({ providerId: z.string().min(1), sourceUrl: z.string().min(1) }), + source: z.object({ id: z.string().min(1), url: z.string().min(1) }), canonicalUrl: z.string().min(1), verifiedAt: z.string().datetime(), relevance: z.number().min(0).max(1), @@ -203,12 +207,12 @@ export function parseReference(input: unknown): Reference { /** Stamp the fields that are core's concern onto an emitted item. The rest-spread * keeps the discriminant: the result narrows on `modality` like its input. */ -export function completeReference(providerId: string, e: EmittedReference, now: string): Reference { +export function completeReference(sourceId: string, e: EmittedReference, now: string): Reference { const { sourceUrl, canonicalUrl, ...rest } = e return { ...rest, - id: referenceId(providerId, sourceUrl), - source: { providerId, sourceUrl }, + id: referenceId(sourceId, sourceUrl), + source: { id: sourceId, url: sourceUrl }, canonicalUrl: canonicalUrl ?? sourceUrl, verifiedAt: now, relevance: 0, diff --git a/packages/core/src/rerank.ts b/packages/core/src/rerank.ts index 79140a4..0749559 100644 --- a/packages/core/src/rerank.ts +++ b/packages/core/src/rerank.ts @@ -127,8 +127,8 @@ function sourceScoreScores(refs: readonly Reference[]): number[] { for (const r of refs) { const s = scoreOf(r) if (s === undefined) continue - const cur = span.get(r.source.providerId) - if (!cur) span.set(r.source.providerId, { min: s, max: s }) + const cur = span.get(r.source.id) + if (!cur) span.set(r.source.id, { min: s, max: s }) else { if (s < cur.min) cur.min = s if (s > cur.max) cur.max = s @@ -137,7 +137,7 @@ function sourceScoreScores(refs: readonly Reference[]): number[] { return refs.map((r) => { const s = scoreOf(r) if (s === undefined) return 0.5 - const { min, max } = span.get(r.source.providerId)! + const { min, max } = span.get(r.source.id)! return max > min ? (s - min) / (max - min) : 0.5 }) } @@ -181,7 +181,7 @@ export function lexicalReranker(opts: LexicalRerankOptions = {}): Reranker { const upstream = sourceScoreScores(refs) const scored = refs.map((ref, i) => ({ ref, - sid: ref.source.providerId, + sid: ref.source.id, titleTokens: new Set(tokenize(ref.title ?? '')), // Set once a title already picked from this source turns out to repeat this // one; never cleared, since the picked set only grows. diff --git a/packages/core/src/schemas.ts b/packages/core/src/schemas.ts index e5d15d1..6548443 100644 --- a/packages/core/src/schemas.ts +++ b/packages/core/src/schemas.ts @@ -2,21 +2,21 @@ import { z } from 'zod' import { MODALITIES } from './modality' import { SEARCH_CONTROL_KEYS, type SearchControlKey } from './controls' import { INTENTS } from './evaluate-use' -import { PROVIDER_SKIP_REASONS } from './select' -import type { ProviderSearchStatus, SearchMeta } from './client' +import { SOURCE_SKIP_REASONS } from './select' +import type { SourceStatus, SearchMeta } from './client' // Runtime validation for the search-result metadata core produces, derived from // the same registries the orchestrator routes on — hosts (e.g. the MCP server's // tool outputSchema) describe `meta` without re-typing any vocabulary. export const searchControlKeySchema = z.enum(SEARCH_CONTROL_KEYS as [SearchControlKey, ...SearchControlKey[]]) -export const providerSearchStatusSchema: z.ZodType = z.object({ - providerId: z.string(), +export const sourceStatusSchema: z.ZodType = z.object({ + sourceId: z.string(), status: z.enum(['fulfilled', 'failed', 'skipped']), returned: z.number().optional(), accepted: z.number().optional(), rejected: z.number().optional(), - reason: z.enum(PROVIDER_SKIP_REASONS).optional(), + reason: z.enum(SOURCE_SKIP_REASONS).optional(), error: z.string().optional(), latencyMs: z.number().optional(), cached: z.boolean().optional(), @@ -32,11 +32,11 @@ export const searchMetaSchema: z.ZodType = z.object({ passes: z.number(), controls: z.object({ requested: z.array(searchControlKeySchema), - appliedByProvider: z.record(z.string(), z.array(searchControlKeySchema)), - ignoredByProvider: z.record(z.string(), z.array(searchControlKeySchema)), + appliedBySource: z.record(z.string(), z.array(searchControlKeySchema)), + ignoredBySource: z.record(z.string(), z.array(searchControlKeySchema)), }).optional(), - providerOptions: z.array(z.string()).optional(), - providers: z.array(providerSearchStatusSchema), + sourceOptions: z.array(z.string()).optional(), + sources: z.array(sourceStatusSchema), gate: z.object({ intent: z.enum(INTENTS), before: z.number(), after: z.number(), dropped: z.number() }).optional(), threshold: z.object({ minRelevance: z.number(), dropped: z.number() }).optional(), nextCursor: z.string().optional().describe('opaque load-more cursor; pass back as `cursor` to fetch the next page with cross-page dedup'), diff --git a/packages/core/src/select.ts b/packages/core/src/select.ts index b6b7ce8..b758bd2 100644 --- a/packages/core/src/select.ts +++ b/packages/core/src/select.ts @@ -6,15 +6,53 @@ import type { Modality } from './modality' import type { ReferenceProvider } from './provider' -/** Why a configured provider sat a search out (no fetch attempted). */ -export const PROVIDER_SKIP_REASONS = ['unsupported-modality', 'not-selected', 'unsupported-kind', 'declined'] as const -export type ProviderSkipReason = (typeof PROVIDER_SKIP_REASONS)[number] +/** Why a configured source sat a search out (no fetch attempted). */ +export const SOURCE_SKIP_REASONS = ['unsupported-modality', 'not-selected', 'unsupported-kind', 'declined'] as const +export type SourceSkipReason = (typeof SOURCE_SKIP_REASONS)[number] + +/** Thrown when a search has no source to fan out to. A host branches on `code` + * and the fields, never on the message: + * - `'no-matching-source'`: `sources` was given and none of it can serve this + * search (an unknown id, a known id lacking the modality or the kind, or + * `sources: []`) — a caller mistake to report back with the valid ids; + * - `'no-source-for-query'`: no `sources`, and no configured source supports + * the modalities (and kind) asked for. */ +export class SourceSelectionError extends Error { + readonly code: 'no-matching-source' | 'no-source-for-query' + /** The `sources` as passed; absent when the search was unscoped. */ + readonly requested?: string[] + /** Ids in `sources` that match no configured source. */ + readonly unknown: string[] + readonly modalities: Modality[] + /** `controls.media.kind`, when the search narrowed by kind. */ + readonly kind?: string + + constructor(fields: { + code: SourceSelectionError['code'] + requested?: string[] + unknown: string[] + modalities: Modality[] + kind?: string + }) { + const kindSuffix = fields.kind !== undefined ? ` with kind "${fields.kind}"` : '' + const modalities = `modalities [${fields.modalities.join(', ')}]${kindSuffix}` + super(fields.code === 'no-matching-source' + ? `refkit.search: no configured source matches id(s) [${(fields.requested ?? []).join(', ')}] for ${modalities}` + : `refkit.search: no configured source supports ${modalities}`) + this.name = 'SourceSelectionError' + this.code = fields.code + if (fields.requested !== undefined) this.requested = fields.requested + this.unknown = fields.unknown + this.modalities = fields.modalities + if (fields.kind !== undefined) this.kind = fields.kind + } +} export interface ProviderSelection { chosen: ReferenceProvider[] /** Why each excluded provider sat this search out. */ - skipReasons: Map - /** `sources` ids that match no configured provider (tolerated, surfaced as a warning). */ + skipReasons: Map + /** `sources` ids that match no configured source (tolerated, surfaced as a warning). */ unknownSources: string[] } @@ -28,12 +66,13 @@ export interface SelectionInput { } /** Decide which providers a search fans out to, with an explanation per exclusion. - * Throws when nothing is left: a source-scoped miss is a caller typo and must fail - * loudly rather than read as "no results" — EXCEPT when a provider declined the - * query itself, which is a real "this source has nothing for you", not a typo. */ + * Throws a {@link SourceSelectionError} when nothing is left: a source-scoped + * miss is a caller typo and must fail loudly rather than read as "no results" — + * EXCEPT when a provider declined the query itself, which is a real "this + * source has nothing for you", not a typo. */ export function selectProviders(providers: readonly ReferenceProvider[], input: SelectionInput): ProviderSelection { - const skipReasons = new Map() - const reasonFor = (p: ReferenceProvider): ProviderSkipReason | undefined => { + const skipReasons = new Map() + const reasonFor = (p: ReferenceProvider): SourceSkipReason | undefined => { if (!p.modalities.some(m => input.modalities.includes(m))) return 'unsupported-modality' if (input.sources != null && !input.sources.includes(p.id)) return 'not-selected' // Declaration-gated kind narrowing: providers with no `kinds` declared are @@ -59,11 +98,13 @@ export function selectProviders(providers: readonly ReferenceProvider[], input: // result, not a routing mistake, so the pipeline runs a no-provider pass. const someDeclined = [...skipReasons.values()].includes('declined') if (chosen.length === 0 && !someDeclined) { - const kindSuffix = input.kind !== undefined ? ` with kind "${input.kind}"` : '' - if (input.sources != null) { - throw new Error(`refkit.search: no configured provider matches source id(s) [${input.sources.join(', ')}] for modalities [${input.modalities.join(', ')}]${kindSuffix}`) - } - throw new Error(`refkit.search: no registered provider supports modalities [${input.modalities.join(', ')}]${kindSuffix}`) + throw new SourceSelectionError({ + code: input.sources != null ? 'no-matching-source' : 'no-source-for-query', + ...(input.sources != null ? { requested: [...input.sources] } : {}), + unknown: unknownSources, + modalities: [...input.modalities], + ...(input.kind !== undefined ? { kind: input.kind } : {}), + }) } return { chosen, skipReasons, unknownSources } } diff --git a/packages/mcp/README.md b/packages/mcp/README.md index db60ec2..9292b1f 100644 --- a/packages/mcp/README.md +++ b/packages/mcp/README.md @@ -45,7 +45,7 @@ await serveStdio(createRefkit({ ## The `search_references` tool -Input: `{ query, modalities?, sources?, controls?, providerOptions?, limit?, cursor?, rerank?, minRelevance?, deadlineMs?, intent?, gateFor?, gateContext?, explain? }`. +Input: `{ query, modalities?, sources?, controls?, sourceOptions?, limit?, cursor?, rerank?, minRelevance?, deadlineMs?, intent?, gateFor?, gateContext?, explain? }`. - `controls` — provider-neutral search controls such as `{ orientation, color, language, sort, safety, license, media, page }`; providers translate supported controls and report ignored controls when `explain: true`. - `sources` — restrict the search to specific source ids (the ids are listed in the tool description under "Configured sources"). Naming a source also **bypasses** that source's own query-acceptance predicate, so a narrow source like Nailbook — which declines non-nail queries on an unscoped search and then appears as `skipped` with `reason: 'declined'` — still answers when asked for by name. @@ -56,15 +56,15 @@ Input: `{ query, modalities?, sources?, controls?, providerOptions?, limit?, cur - `gateFor` — return only results whose license allows that intent. - `gateContext` — `{ userJurisdiction }` for that gate, matching `evaluate_use`'s jurisdiction input: a source-declared jurisdiction that differs from the caller's defaults to `needs-review`, which `gateFor` then drops. - `cursor` — the previous result's top-level `nextCursor` (always returned, no `explain` needed); fetches the next batch, deduped against earlier ones. -- `explain` — include provider status (incl. per-source `confidence`, `latencyMs`, skip `reason`), applied and ignored unified controls, warnings, and gate/threshold-drop metadata. -- `providerOptions` — typed provider-specific whitelisted controls keyed by provider id, for example: +- `explain` — include per-source status (incl. `confidence`, `latencyMs`, skip `reason`), applied and ignored unified controls, warnings, and gate/threshold-drop metadata. +- `sourceOptions` — typed provider-specific whitelisted controls keyed by source id, for example: ```json { "query": "forest path", "modalities": ["image"], "controls": { "orientation": "landscape", "color": "green", "safety": "strict" }, - "providerOptions": { + "sourceOptions": { "unsplash": { "collections": ["abc", "def"], "page": 2 }, "flickr": { "tags": ["forest", "path"], "tagMode": "all", "minTakenDate": "2020-01-01" }, "brave": { "country": "US", "searchLang": "en" } @@ -72,7 +72,7 @@ Input: `{ query, modalities?, sources?, controls?, providerOptions?, limit?, cur } ``` -Output: `{ references: [{ id, title?, description?, tags?, modality, kind?, provider, canonicalUrl, license, author?, rehostPolicy, relevance, thumbnail?, excerpt?, useVerdict?, useExplanation?, attribution? }], nextCursor?, meta? }`. `description` is capped at 280 characters and `tags` at the first 8, to keep agent context small. `relevance` is the 0..1 cross-source score after fusion and rerank; the per-source `sourceScore` is left out because it is not comparable across sources. `rehostPolicy` tells the agent what it may do with the media before storing anything: `hotlink-required` (link only), `cache-allowed`, `thumbnail-only` or `no-store`. When `intent` (or `gateFor`) is set, each result carries `useVerdict { decision, reason, confidence }`, a plain `useExplanation`, and — if the license requires it — a ready-to-use `attribution` credit line. When `explain: true`, `meta` includes per-provider `fulfilled` / `failed` / `skipped` status, applied/ignored control details, warnings, and gate/threshold-drop counts. The `meta` shape comes from core's own `searchMetaSchema` — this server keeps no copy of the vocabulary. +Output: `{ references: [{ id, title?, description?, tags?, modality, kind?, source, canonicalUrl, license, author?, rehostPolicy, relevance, thumbnail?, excerpt?, useVerdict?, useExplanation?, attribution? }], nextCursor?, meta? }`. `description` is capped at 280 characters and `tags` at the first 8, to keep agent context small. `relevance` is the 0..1 cross-source score after fusion and rerank; the per-source `sourceScore` is left out because it is not comparable across sources. `rehostPolicy` tells the agent what it may do with the media before storing anything: `hotlink-required` (link only), `cache-allowed`, `thumbnail-only` or `no-store`. When `intent` (or `gateFor`) is set, each result carries `useVerdict { decision, reason, confidence }`, a plain `useExplanation`, and — if the license requires it — a ready-to-use `attribution` credit line. When `explain: true`, `meta` includes per-source `fulfilled` / `failed` / `skipped` status (`meta.sources`), applied/ignored control details, warnings, and gate/threshold-drop counts. The `meta` shape comes from core's own `searchMetaSchema` — this server keeps no copy of the vocabulary. > Results are references with a license id + source link — **not rights clearance, not legal advice**. `unknown` / `needs-review` results require the caller to verify the source's terms. diff --git a/packages/mcp/src/__tests__/mcp.test.ts b/packages/mcp/src/__tests__/mcp.test.ts index d60b4b6..188fbbc 100644 --- a/packages/mcp/src/__tests__/mcp.test.ts +++ b/packages/mcp/src/__tests__/mcp.test.ts @@ -1,7 +1,7 @@ import { describe, expect, it, vi } from 'vitest' import { Client } from '@modelcontextprotocol/sdk/client/index.js' import { InMemoryTransport } from '@modelcontextprotocol/sdk/inMemory.js' -import { createRefkit, defineProvider, PROVIDER_SKIP_REASONS, SEARCH_CONTROL_KEYS } from '@refkit/core' +import { createRefkit, defineProvider, SOURCE_SKIP_REASONS, SEARCH_CONTROL_KEYS } from '@refkit/core' import { openverse } from '@refkit/provider-openverse' import { readFileSync } from 'node:fs' import { createRefkitMcpServer } from '../index' @@ -37,12 +37,13 @@ describe('@refkit/mcp', () => { it('search_references returns license-normalized structured references', async () => { const client = await connectedClient() const res = await client.callTool({ name: 'search_references', arguments: { query: 'sky', modalities: ['image'] } }) - const structured = res.structuredContent as { references: Array<{ title?: string; license: string; canonicalUrl: string; provider: string }> } + const structured = res.structuredContent as { references: Array<{ title?: string; license: string; canonicalUrl: string; source: string }> } expect(structured.references).toHaveLength(1) expect(structured.references[0].title).toBe('cc0 sky') expect(structured.references[0].license).toBe('CC0-1.0') expect(structured.references[0].canonicalUrl).toBe('https://ov/aaa') - expect(structured.references[0].provider).toBe('openverse') + expect(structured.references[0].source).toBe('openverse') + expect(structured.references[0]).not.toHaveProperty('provider') await client.close() }) @@ -120,7 +121,7 @@ describe('@refkit/mcp', () => { await client.close() }) - it('accepts providerOptions and forwards the matching entry to its provider', async () => { + it('accepts sourceOptions and forwards the matching entry to that source\'s provider', async () => { let seen: unknown const fakeProvider = defineProvider({ id: 'fake', @@ -140,7 +141,7 @@ describe('@refkit/mcp', () => { arguments: { query: 'sky', modalities: ['image'], - providerOptions: { fake: { sort: 'latest' } }, + sourceOptions: { fake: { sort: 'latest' } }, }, }) expect(seen).toEqual({ sort: 'latest' }) @@ -198,15 +199,15 @@ describe('@refkit/mcp', () => { meta?: { controls?: { requested: string[] - appliedByProvider: Record - ignoredByProvider: Record + appliedBySource: Record + ignoredBySource: Record } } } expect(structured.meta?.controls).toEqual({ requested: ['orientation', 'color'], - appliedByProvider: { fake: ['orientation'] }, - ignoredByProvider: { fake: ['color'] }, + appliedBySource: { fake: ['orientation'] }, + ignoredBySource: { fake: ['color'] }, }) await client.close() }) @@ -249,6 +250,26 @@ describe('@refkit/mcp', () => { await client.close() }) + it('detects the source-selection miss by its type, so sources: [] gets the valid ids too', async () => { + const client = await connectedClient() // openverse-only server + const res = await client.callTool({ name: 'search_references', arguments: { query: 'x', modalities: ['image'], sources: [] } }) + expect(res.isError).toBe(true) + const text = (res.content as Array<{ type: string; text: string }>).map(c => c.text).join('\n') + expect(text).toContain('Enabled source ids: openverse.') + await client.close() + }) + + it('does not dress up other errors raised while sources is set as a source miss', async () => { + const client = await connectedClient() // openverse-only server + // `sources` resolves; the cursor is what is wrong. + const res = await client.callTool({ name: 'search_references', arguments: { query: 'x', modalities: ['image'], sources: ['openverse'], cursor: 'not-a-cursor' } }) + expect(res.isError).toBe(true) + const text = (res.content as Array<{ type: string; text: string }>).map(c => c.text).join('\n') + expect(text).toContain('invalid cursor') + expect(text).not.toContain('Enabled source ids') + await client.close() + }) + it('projects description, tags, author, relevance and rehostPolicy for agents (not the per-source sourceScore)', async () => { const long = `Lighthouse on a rocky cape at dusk, ${'with long exposure waves '.repeat(20)}`.trim() const tags = ['lighthouse', 'coast', 'dusk', 'sea', 'rocks', 'waves', 'beacon', 'cape', 'night', 'tower'] @@ -399,10 +420,10 @@ describe('@refkit/mcp', () => { }) const structured = res.structuredContent as { references: Array<{ title?: string }> - meta?: { providers: Array<{ providerId: string; status: string }> } + meta?: { sources: Array<{ sourceId: string; status: string }> } } expect(structured.references.map(r => r.title)).toEqual(['A lion']) - expect(structured.meta?.providers.find(p => p.providerId === 'hung')?.status).toBe('failed') + expect(structured.meta?.sources.find(p => p.sourceId === 'hung')?.status).toBe('failed') await client.close() }) @@ -459,15 +480,15 @@ describe('@refkit/mcp', () => { }) const structured = res.structuredContent as { references: Array<{ useExplanation?: string }> - meta?: { providers: Array<{ providerId: string; status: string; error?: string; latencyMs?: number; confidence?: number }>; warnings: string[] } + meta?: { sources: Array<{ sourceId: string; status: string; error?: string; latencyMs?: number; confidence?: number }>; warnings: string[] } } expect(structured.references[0].useExplanation).toContain('allowed-with-attribution') - expect(structured.meta?.providers).toEqual([ + expect(structured.meta?.sources).toEqual([ // 'credit me' matches the query, so the source is fully trusted in the fusion - { providerId: 'good', status: 'fulfilled', returned: 1, accepted: 1, rejected: 0, latencyMs: expect.any(Number), confidence: 1 }, - { providerId: 'bad', status: 'failed', error: 'offline', latencyMs: expect.any(Number) }, + { sourceId: 'good', status: 'fulfilled', returned: 1, accepted: 1, rejected: 0, latencyMs: expect.any(Number), confidence: 1 }, + { sourceId: 'bad', status: 'failed', error: 'offline', latencyMs: expect.any(Number) }, ]) - expect(structured.meta?.warnings).toContain('1 provider(s) failed; returning partial results.') + expect(structured.meta?.warnings).toContain('1 source(s) failed; returning partial results.') await client.close() }) }) @@ -671,6 +692,19 @@ describe('defaultProviders (zero-config CLI wiring)', () => { expect((await defaultProviders({ SI_KEY: 'k' })).map(p => p.id)).toContain('smithsonian') }) + it('serves distinct source ids even with every BYOK key set, so createRefkit accepts the list', async () => { + const env = { + REFKIT_UNSPLASH_KEY: 'k', REFKIT_PEXELS_KEY: 'k', REFKIT_PIXABAY_KEY: 'k', REFKIT_FLICKR_KEY: 'k', + REFKIT_SMITHSONIAN_KEY: 'k', REFKIT_BRAVE_KEY: 'k', REFKIT_FREESOUND_KEY: 'k', REFKIT_JAMENDO_CLIENT_ID: 'k', + REFKIT_EUROPEANA_KEY: 'k', + } + const providers = await defaultProviders(env) + expect(providers.length).toBeGreaterThan(20) // every BYOK source loaded + const ids = providers.map(p => p.id) + expect(new Set(ids).size).toBe(ids.length) + expect(() => createRefkit({ providers })).not.toThrow() + }) + it('BYOK_SOURCES and package.json optionalDependencies stay in sync', () => { const pkg = JSON.parse(readFileSync(new URL('../../package.json', import.meta.url), 'utf8')) as { optionalDependencies?: Record @@ -726,14 +760,28 @@ describe('dynamic declaration-derived schema', () => { await client.close() }) + it('speaks of sources in its schemas: sourceOptions in, source on each reference out', async () => { + const client = await declClient() + const { tools } = await client.listTools() + const tool = tools.find(t => t.name === 'search_references')! + const input = (tool.inputSchema as { properties: Record }).properties + expect(input.sourceOptions?.description).toContain('keyed by source id') + expect(input).not.toHaveProperty('providerOptions') + const ref = (tool.outputSchema as Record).properties.references.items + expect(ref.properties.source.type).toBe('string') + expect(ref.required).toContain('source') + expect(ref.properties).not.toHaveProperty('provider') + await client.close() + }) + it('declares the control-key vocabulary straight from core (no local mirror)', async () => { const client = await declClient() const { tools } = await client.listTools() const out = tools.find(t => t.name === 'search_references')!.outputSchema as Record const requestedEnum = out.properties.meta.properties.controls.properties.requested.items.enum as string[] expect(requestedEnum).toEqual([...SEARCH_CONTROL_KEYS]) - const reasonEnum = out.properties.meta.properties.providers.items.properties.reason.enum as string[] - expect(reasonEnum).toEqual([...PROVIDER_SKIP_REASONS]) + const reasonEnum = out.properties.meta.properties.sources.items.properties.reason.enum as string[] + expect(reasonEnum).toEqual([...SOURCE_SKIP_REASONS]) await client.close() }) }) diff --git a/packages/mcp/src/index.ts b/packages/mcp/src/index.ts index 5b5518e..7216a50 100644 --- a/packages/mcp/src/index.ts +++ b/packages/mcp/src/index.ts @@ -2,8 +2,8 @@ import { readFileSync } from 'node:fs' import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js' import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js' import { z } from 'zod' -import { LICENSE_IDS, INTENTS, REHOST_POLICIES, evaluateUse, buildAttribution, ccVersionFor, buildSearchControlsSchema, searchMetaSchema } from '@refkit/core' -import type { RefkitClient, Reference, Verdict, Attribution, SearchControls, SearchInput, ProviderOptionsById, RightsRecord, Modality } from '@refkit/core' +import { LICENSE_IDS, INTENTS, REHOST_POLICIES, SourceSelectionError, evaluateUse, buildAttribution, ccVersionFor, buildSearchControlsSchema, searchMetaSchema } from '@refkit/core' +import type { RefkitClient, Reference, Verdict, Attribution, SearchControls, SearchInput, SourceOptionsById, RightsRecord, Modality } from '@refkit/core' // Legacy media.kind control values — kept in the dynamic enum because they stay // meaningful as upstream filter translations for providers that support the @@ -11,7 +11,7 @@ import type { RefkitClient, Reference, Verdict, Attribution, SearchControls, Sea const BASE_MEDIA_KINDS = ['photo', 'illustration', 'vector', 'film', 'animation'] as const const providerOptionValueSchema = z.union([z.string(), z.number(), z.boolean(), z.array(z.string())]) -const providerOptionsSchema = z.record(z.string(), z.record(z.string(), providerOptionValueSchema)) +const sourceOptionsSchema = z.record(z.string(), z.record(z.string(), providerOptionValueSchema)) // Reported in the MCP initialize handshake. Read the real version (the dist sits // next to package.json, which npm always ships) instead of a hardcoded placeholder. @@ -46,7 +46,7 @@ function toAgentRef(r: Reference, assessment?: { verdict: Verdict; attribution: tags: r.tags && r.tags.length > 0 ? r.tags.slice(0, AGENT_TAGS_MAX) : undefined, modality: r.modality, kind: r.kind, - provider: r.source.providerId, + source: r.source.id, canonicalUrl: r.canonicalUrl, license: r.rights.license, author: r.rights.author, @@ -74,7 +74,7 @@ const agentRefSchema = z.object({ tags: z.array(z.string()).optional().describe(`the source's subjects or tags, first ${AGENT_TAGS_MAX}`), modality: z.string(), kind: z.string().optional().describe('fine-grained resource kind, e.g. photo / texture / ebook'), - provider: z.string(), + source: z.string().describe('id of the source this result came from (see "Configured sources")'), canonicalUrl: z.string(), license: z.string(), author: z.string().optional().describe('creator as declared by the source'), @@ -131,8 +131,8 @@ export function createRefkitMcpServer(refkit: RefkitClient): McpServer { + 'Use to scope a search-engine operator (e.g. "site:example.com") to a web-discovery source without affecting other sources\' queries.', ), controls: searchControlsSchema.optional().describe('provider-neutral search controls; providers translate supported controls and report ignored controls in explain metadata'), - providerOptions: providerOptionsSchema.optional().describe('provider-specific search controls keyed by provider id; each provider whitelists supported keys'), - explain: z.boolean().optional().describe('include provider status, applied and ignored controls, warnings, gate/drop metadata, and the load-more cursor'), + sourceOptions: sourceOptionsSchema.optional().describe('source-specific search options keyed by source id; each source whitelists the keys it supports'), + explain: z.boolean().optional().describe('include per-source status, applied and ignored controls, warnings, gate/drop metadata, and the load-more cursor'), limit: z.number().int().positive().optional(), cursor: z.string().optional().describe('opaque cursor from a previous result\'s nextCursor — fetches the next batch, deduped against earlier batches'), rerank: z.boolean().optional().describe('re-rank results by query relevance (term coverage incl. CJK over title/description/tags/excerpt, fused cross-source relevance, resolution, source and near-duplicate diversity). Default true — pass false for raw cross-source rank fusion. true cannot re-enable reranking when the host built the client with rerank: false'), @@ -154,7 +154,7 @@ export function createRefkitMcpServer(refkit: RefkitClient): McpServer { meta: searchMetaSchema.optional(), }, }, - async ({ query, modalities, controls, providerOptions, explain, limit, cursor, rerank, minRelevance, deadlineMs, gateContext, intent, gateFor, sources }) => { + async ({ query, modalities, controls, sourceOptions, explain, limit, cursor, rerank, minRelevance, deadlineMs, gateContext, intent, gateFor, sources }) => { // Typed as core's SearchInput so every parameter above is checked against // the contract it is forwarded into, not just against zod. const searchInput: SearchInput = { @@ -162,7 +162,7 @@ export function createRefkitMcpServer(refkit: RefkitClient): McpServer { modalities: modalities ?? ['image'], sources, controls: controls as SearchControls | undefined, - providerOptions: providerOptions as ProviderOptionsById | undefined, + sourceOptions: sourceOptions as SourceOptionsById | undefined, limit, cursor, // Core reranks by default; only an explicit false turns it off. @@ -178,16 +178,16 @@ export function createRefkitMcpServer(refkit: RefkitClient): McpServer { try { result = await refkit.searchWithMeta(searchInput) } catch (err) { - // A source-selection miss (no requested id resolves for the modality) is a - // caller mistake, not an outage — turn core's throw into an agent-actionable - // tool error listing the valid ids. AggregateError (every chosen provider - // failed at fetch) is a genuine upstream fault, so let it propagate. - if (sources && sources.length > 0 && !(err instanceof AggregateError)) { + // A source-selection miss (the requested ids resolve to no configured + // source for the modality or kind) is a caller mistake, not an outage — + // turn core's typed throw into an agent-actionable tool error listing the + // valid ids. Everything else (every source failing, an invalid cursor) + // propagates as it is. + if (err instanceof SourceSelectionError && err.code === 'no-matching-source') { const available = enabledSourceIds.join(', ') || '(none)' - const detail = err instanceof Error ? err.message : String(err) return { isError: true, - content: [{ type: 'text', text: `${detail} Enabled source ids: ${available}.` }], + content: [{ type: 'text', text: `${err.message} Enabled source ids: ${available}.` }], } } throw err diff --git a/packages/provider-brave/src/__tests__/integration.test.ts b/packages/provider-brave/src/__tests__/integration.test.ts index 92244fe..52fb7ad 100644 --- a/packages/provider-brave/src/__tests__/integration.test.ts +++ b/packages/provider-brave/src/__tests__/integration.test.ts @@ -20,9 +20,9 @@ describe('clean + discovery integration (offline)', () => { it('merges clean (openverse) and discovery (brave) results', async () => { const rk = createRefkit({ providers: [openverse(), brave({ token: 'k' })], fetch: routedFetch }) const refs = await rk.search({ query: 'sky', modalities: ['image'] }) - const providers = new Set(refs.map(r => r.source.providerId)) - expect(providers.has('openverse')).toBe(true) - expect(providers.has('brave')).toBe(true) + const sources = new Set(refs.map(r => r.source.id)) + expect(sources.has('openverse')).toBe(true) + expect(sources.has('brave')).toBe(true) }) it('gateFor commercial-product keeps the clean cc0 result, drops the discovery (needs-review) one', async () => { diff --git a/packages/provider-gutendex/README.md b/packages/provider-gutendex/README.md index 467275e..1125788 100644 --- a/packages/provider-gutendex/README.md +++ b/packages/provider-gutendex/README.md @@ -28,4 +28,4 @@ The default host, `gutendex.com`, is the upstream maintainer's **test instance** gutendex({ baseUrl: 'https://gutendex.your-domain.example' }) ``` -When the public instance blocks a request, the search degrades gracefully: this source is reported as `failed` in `meta.providers` and results from other providers still return. +When the public instance blocks a request, the search degrades gracefully: this source is reported as `failed` in `meta.sources` and results from other sources still return. diff --git a/packages/provider-gutendex/src/__tests__/integration.test.ts b/packages/provider-gutendex/src/__tests__/integration.test.ts index 3ea5e53..e177517 100644 --- a/packages/provider-gutendex/src/__tests__/integration.test.ts +++ b/packages/provider-gutendex/src/__tests__/integration.test.ts @@ -21,9 +21,9 @@ describe('P2 integration: createRefkit + gutendex + poetrydb (offline)', () => { it('merges text references across both sources', async () => { const rk = createRefkit({ providers: [gutendex(), poetrydb()], fetch: routedFetch }) const refs = await rk.search({ query: 'great', modalities: ['text'] }) - const providers = new Set(refs.map(r => r.source.providerId)) - expect(providers.has('gutendex')).toBe(true) - expect(providers.has('poetrydb')).toBe(true) + const sources = new Set(refs.map(r => r.source.id)) + expect(sources.has('gutendex')).toBe(true) + expect(sources.has('poetrydb')).toBe(true) expect(refs.every(r => r.modality === 'text')).toBe(true) }) diff --git a/packages/provider-nailbook/README.md b/packages/provider-nailbook/README.md index 6cb86dc..0c658a7 100644 --- a/packages/provider-nailbook/README.md +++ b/packages/provider-nailbook/README.md @@ -30,7 +30,7 @@ Nailbook indexes nothing but nail designs, so it declares core's `accepts` predi **declines** any query that does not name nails — in English (`nail`, `manicure`, `gel`), Japanese (`ネイル`, `ジェル`, `マニキュア`) or Chinese (`美甲`, `指甲`, `甲油`). `nail` inside `snail` does not count. A declined search never issues a request: the source is reported in -`meta.providers` as `skipped` with `reason: 'declined'`, and the other configured sources +`meta.sources` as `skipped` with `reason: 'declined'`, and the other configured sources answer alone (a decline is routing, not a failure — no warning, no error). Naming the source explicitly bypasses acceptance, which is how a motif-only query still diff --git a/packages/provider-openverse/src/__tests__/integration.test.ts b/packages/provider-openverse/src/__tests__/integration.test.ts index 4e7ac60..5186eba 100644 --- a/packages/provider-openverse/src/__tests__/integration.test.ts +++ b/packages/provider-openverse/src/__tests__/integration.test.ts @@ -22,9 +22,9 @@ describe('P1 integration: createRefkit + openverse + pexels (offline)', () => { it('merges results across both sources', async () => { const rk = createRefkit({ providers: [openverse(), pexels({ apiKey: 'k' })], fetch: routedFetch }) const refs = await rk.search({ query: 'sky', modalities: ['image'] }) - const providers = new Set(refs.map(r => r.source.providerId)) - expect(providers.has('openverse')).toBe(true) - expect(providers.has('pexels')).toBe(true) + const sources = new Set(refs.map(r => r.source.id)) + expect(sources.has('openverse')).toBe(true) + expect(sources.has('pexels')).toBe(true) expect(refs.length).toBe(3) // 2 openverse + 1 pexels expect(refs[0].relevance).toBe(1) // RRF top normalized }) diff --git a/packages/provider-testkit/src/__tests__/testkit.test.ts b/packages/provider-testkit/src/__tests__/testkit.test.ts index 9ecdfd9..5a2a306 100644 --- a/packages/provider-testkit/src/__tests__/testkit.test.ts +++ b/packages/provider-testkit/src/__tests__/testkit.test.ts @@ -1,6 +1,7 @@ import { describe, expect, it } from 'vitest' import { - defineProvider, type EmittedAudioReference, type EmittedImageReference, type EmittedReference, type Modality, type ProviderContext, + defineProvider, type EmittedAudioReference, type EmittedImageReference, type EmittedReference, type EmittedTextReference, + type EmittedVideoReference, type Modality, type ProviderContext, } from '@refkit/core' import { expectLicenseMap, searchConformant, type LicenseMapResult } from '../index' @@ -36,6 +37,30 @@ function audioRef(): EmittedAudioReference { } } +function videoRef(): EmittedVideoReference { + return { + modality: 'video', + sourceUrl: 'https://example.com/film/1', + rights: { + license: 'PD', + rehostPolicy: 'hotlink-required', + raw: { sourceTerms: 'public domain', sourceUrl: 'https://example.com/terms' }, + }, + } +} + +function textRef(): EmittedTextReference { + return { + modality: 'text', + sourceUrl: 'https://example.com/book/1', + rights: { + license: 'PD', + rehostPolicy: 'hotlink-required', + raw: { sourceTerms: 'public domain', sourceUrl: 'https://example.com/terms' }, + }, + } +} + // Fixture-fetch fake provider: search() ignores ctx.fetch's real behavior and // just returns whatever result set the test configured, but still routes // through ctx.fetch once so the shape matches a real satellite. @@ -58,7 +83,7 @@ describe('searchConformant', () => { const refs = await searchConformant(provider, fixtureFetch) expect(refs).toHaveLength(1) expect(refs[0].id).toMatch(/^fake:/) // stamped by core, not by the provider - expect(refs[0].source).toEqual({ providerId: 'fake', sourceUrl: 'https://example.com/photo/1' }) + expect(refs[0].source).toEqual({ id: 'fake', url: 'https://example.com/photo/1' }) expect(refs[0].canonicalUrl).toBe('https://example.com/photo/1') }) @@ -103,6 +128,15 @@ describe('searchConformant', () => { await expect(searchConformant(provider, fixtureFetch)).rejects.toThrow(/result #1 modality "audio" is not in the provider's declared modalities \[image\]/) }) + it('fails when a result\'s modality is declared but outside the query it ran', async () => { + // Declares video and text (like internet-archive), is asked for text only, + // and leaks a video: core rejects that item, so the testkit must too. + const provider = fakeProvider([textRef(), videoRef()], ['video', 'text']) + await expect(searchConformant(provider, fixtureFetch, { queryExtras: { modalities: ['text'] } })) + .rejects.toThrow(/result #1 modality "video" is not in the query's modalities \[text\]/) + await expect(searchConformant(provider, fixtureFetch)).resolves.toHaveLength(2) // default query: both declared + }) + it('fails when parseEmitted drops a key: a field from another modality', async () => { // The union type rejects this at compile time; the cast stands in for a // provider whose types were loosened (any, JSON passthrough). @@ -117,9 +151,25 @@ describe('searchConformant', () => { await expect(searchConformant(provider, fixtureFetch)).rejects.toThrow(/lost keys in parseEmitted: visual\.colours/) }) + it('fails when parseEmitted drops a key named like an Object.prototype member', async () => { + // `in` would find these on the prototype and miss that zod stripped them. + const provider = fakeProvider([ + { ...baseRef(), constructor: 'Photo' } as unknown as EmittedReference, + { ...baseRef(), visual: { width: 4, height: 3, toString: 'x' } } as unknown as EmittedReference, + ]) + await expect(searchConformant(provider, fixtureFetch)).rejects.toThrow(/result #0 lost keys in parseEmitted: constructor \(/) + await expect(searchConformant(fakeProvider([ + { ...baseRef(), visual: { width: 4, height: 3, toString: 'x' } } as unknown as EmittedReference, + ]), fixtureFetch)).rejects.toThrow(/result #0 lost keys in parseEmitted: visual\.toString/) + }) + it('does not inspect raw, and does not count a key set to undefined as lost', async () => { + // A self-referencing raw makes the skip observable: walking into it would + // never end, so the search only resolves if raw is left alone. + const raw: Record = { anything: { goes: true } } + raw.self = raw const provider = fakeProvider([ - { ...baseRef(), raw: { anything: { goes: true } } }, + { ...baseRef(), raw }, { ...audioRef(), visual: undefined }, ], ['image', 'audio']) await expect(searchConformant(provider, fixtureFetch)).resolves.toHaveLength(2) diff --git a/packages/provider-testkit/src/index.ts b/packages/provider-testkit/src/index.ts index 789d449..b848fab 100644 --- a/packages/provider-testkit/src/index.ts +++ b/packages/provider-testkit/src/index.ts @@ -28,7 +28,9 @@ function lostKeys(input: unknown, parsed: unknown, path = ''): string[] { return Object.keys(input).flatMap((k) => { if ((path === '' && k === 'raw') || input[k] === undefined) return [] const at = path ? `${path}.${k}` : k - return k in parsed ? lostKeys(input[k], parsed[k], at) : [at] + // Own keys only: `in` would find `constructor` or `toString` on the + // prototype and miss that the schema stripped them. + return Object.hasOwn(parsed, k) ? lostKeys(input[k], parsed[k], at) : [at] }) } @@ -38,7 +40,8 @@ function lostKeys(input: unknown, parsed: unknown, path = ''): string[] { * the real global fetch (live smoke). Per item: * - it parses as its modality's variant (incl. the preview mediaType prefix); * - parsing lost no keys; - * - its modality is one the provider declares, and its kind one it declares; + * - its modality is one the provider declares and one the query asked for, and + * its kind one the provider declares; * - its thumbnail is not the landing page (every modality). */ export async function searchConformant( provider: ReferenceProvider, @@ -68,12 +71,15 @@ export async function searchConformant( if (lost.length > 0) { throw new Error(`[${provider.id}] result #${i} lost keys in parseEmitted: ${lost.join(', ')} (not part of the ${emitted.modality} variant, e.g. a field from another modality)`) } - // Declared-modalities consistency: core rejects an item outside the - // modalities the source was asked for; a provider must never emit one it - // does not declare. + // Modality consistency: a provider must never emit a modality it does not + // declare, nor one the query did not ask for — core rejects the latter as a + // modality leak (runProviderSearch checks query.modalities). if (!provider.modalities.includes(emitted.modality)) { throw new Error(`[${provider.id}] result #${i} modality "${emitted.modality}" is not in the provider's declared modalities [${provider.modalities.join(', ')}]`) } + if (!query.modalities.includes(emitted.modality)) { + throw new Error(`[${provider.id}] result #${i} modality "${emitted.modality}" is not in the query's modalities [${query.modalities.join(', ')}]`) + } const ref: Reference = completeReference(provider.id, emitted, now) // Declared-kinds consistency: a provider stating what it offers must not // emit results outside that set. Missing kind is allowed (annotation is @@ -92,7 +98,7 @@ export async function searchConformant( // is the schema's: each variant checks its own prefix in parseEmitted.) if ( ref.thumbnail && !isLikelyImageUrl(ref.thumbnail.url) - && (ref.thumbnail.url === ref.canonicalUrl || ref.thumbnail.url === ref.source.sourceUrl) + && (ref.thumbnail.url === ref.canonicalUrl || ref.thumbnail.url === ref.source.url) ) { throw new Error(`[${provider.id}] result #${i} thumbnail.url is the item's landing page, not an image resource: ${ref.thumbnail.url}`) } diff --git a/scripts/build-hero.mts b/scripts/build-hero.mts index 50c6557..806cb81 100644 --- a/scripts/build-hero.mts +++ b/scripts/build-hero.mts @@ -44,9 +44,9 @@ function licLabel(rights: RightsRecord): string { // Providers hand back small preview thumbs; pull a larger rendition per source so the // 3× hero stays crisp (artic defaults to 200px, the met to web-large/500px). -function upscale(url: string, providerId: string): string { - if (providerId === 'artic') return url.replace(/\/full\/\d+,\//, '/full/1200,/') - if (providerId === 'met') return url.replace('/web-large/', '/original/') +function upscale(url: string, sourceId: string): string { + if (sourceId === 'artic') return url.replace(/\/full\/\d+,\//, '/full/1200,/') + if (sourceId === 'met') return url.replace('/web-large/', '/original/') return url } @@ -84,7 +84,7 @@ const results = await rk.search({ modalities: ['image'], rerank: lexicalReranker(), limit: 40, - onProviderError: (e) => console.warn(` [warn] ${e.providerId}: ${(e.error as Error)?.message ?? e.error}`), + onSourceError: (e) => console.warn(` [warn] ${e.sourceId}: ${(e.error as Error)?.message ?? e.error}`), }) // Top result per source, in rerank order — a clean one-card-per-source strip. @@ -92,20 +92,20 @@ const bySource = new Map() for (const r of results) { const thumb = r.thumbnail?.url ?? r.preview?.url if (!thumb) continue - if (!bySource.has(r.source.providerId)) bySource.set(r.source.providerId, r) + if (!bySource.has(r.source.id)) bySource.set(r.source.id, r) } const picks = [...bySource.values()].slice(0, 4) if (picks.length < 2) throw new Error(`only ${picks.length} source(s) returned usable results — aborting`) -console.log(`picked ${picks.length} sources: ${picks.map((r) => r.source.providerId).join(', ')}`) +console.log(`picked ${picks.length} sources: ${picks.map((r) => r.source.id).join(', ')}`) // ---- bake thumbnails first; a source whose image won't fetch is dropped, not fatal ---- const baked: { r: Reference; href: string }[] = [] for (const r of picks) { - const thumbUrl = upscale(r.thumbnail?.url ?? r.preview!.url, r.source.providerId) + const thumbUrl = upscale(r.thumbnail?.url ?? r.preview!.url, r.source.id) try { baked.push({ r, href: await dataUri(thumbUrl) }) } catch (e) { - console.warn(` [warn] dropped ${r.source.providerId} thumbnail: ${(e as Error)?.message ?? e}`) + console.warn(` [warn] dropped ${r.source.id} thumbnail: ${(e as Error)?.message ?? e}`) } } if (baked.length < 2) throw new Error(`only ${baked.length} thumbnail(s) baked — aborting`) @@ -120,7 +120,7 @@ for (let i = 0; i < baked.length; i++) { const lic = licLabel(r.rights) const decision = rk.evaluateUse(r, 'commercial-product').decision const { verdict, tint, licColor, vColor } = verdictStyle(decision) - const src = SOURCE_LABEL[r.source.providerId] ?? `via ${r.source.providerId}` + const src = SOURCE_LABEL[r.source.id] ?? `via ${r.source.id}` const title = splitTitle((r.title ?? '').replace(/^\d+[\s_.-]+/, '')) // drop filename-prefix numbers like "002 " defs += `` body += ` From b505b591b30a906b06d1effc273829ef9e91b6d8 Mon Sep 17 00:00:00 2001 From: lizhixuan Date: Mon, 12 Oct 2026 23:48:23 +0800 Subject: [PATCH 05/12] =?UTF-8?q?docs(spec):=20Reference=20alias=20consequ?= =?UTF-8?q?ences=20=E2=80=94=20Omit=20loses=20the=20modality=20link,=20int?= =?UTF-8?q?erfaces=20cannot=20extend=20it?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../specs/2026-10-12-modality-union-and-source-naming-design.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/superpowers/specs/2026-10-12-modality-union-and-source-naming-design.md b/docs/superpowers/specs/2026-10-12-modality-union-and-source-naming-design.md index 16f792b..643ebce 100644 --- a/docs/superpowers/specs/2026-10-12-modality-union-and-source-naming-design.md +++ b/docs/superpowers/specs/2026-10-12-modality-union-and-source-naming-design.md @@ -54,7 +54,7 @@ export interface AudioMeta { durationSec?: number; bitrateKbps?: number; sampleR - Exported types: `ImageReference`, `VideoReference`, `AudioReference`, `TextReference` and their `Emitted…` counterparts; `EmittedReference` and `Reference` become union type aliases. `VideoMeta` and `AudioMeta` are new exports. `ReferenceMedia`, `MediaPreview`, `VisualMeta` and `TextMeta` stay. - `Reference` can no longer use `interface … extends Omit<…>`. It is built per variant: the emitted variant minus `sourceUrl`/`canonicalUrl`, plus the core-stamped fields. `completeReference` must keep the discriminant through its rest-spread; verify by type tests. - A strict union (fields absent on other variants) is rejected: 8 more code edits and about 16 more test reads, on top of the 6 fixture-builder retypes both options need, for no extra safety on the emit side. -- Consequences for hosts, stated in the changeset: `Reference` is a type alias, so it can no longer be augmented by declaration merging, and `Omit` in host code collapses to the common keys (use a distributive omit). +- Consequences for hosts, stated in the changeset: `Reference` is a type alias of a union, so it can no longer be augmented by declaration merging, an interface can no longer `extend` it (TS2312), and `Omit` in host code loses the link between `modality` and its fields (use a distributive omit). ### D3 — Runtime schemas From dd02074fedc9d83723f18c2cb823252b0fe2d867 Mon Sep 17 00:00:00 2001 From: lizhixuan Date: Tue, 13 Oct 2026 00:09:11 +0800 Subject: [PATCH 06/12] feat(providers): audio and video durations, honest thumbnails and kinds, no modality leaks --- .../src/__tests__/flickr.test.ts | 5 +- packages/provider-flickr/src/index.ts | 3 +- .../src/__tests__/freesound.test.ts | 23 +++++ packages/provider-freesound/src/index.ts | 6 +- packages/provider-internet-archive/README.md | 2 + .../src/__tests__/internet-archive.test.ts | 43 ++++++++- .../src/__tests__/live.test.ts | 2 + .../provider-internet-archive/src/index.ts | 13 ++- .../src/__tests__/jamendo.test.ts | 23 +++++ packages/provider-jamendo/src/index.ts | 5 ++ .../src/__tests__/openverse.test.ts | 87 +++++++++++++++++-- packages/provider-openverse/src/index.ts | 57 ++++++++++-- .../src/__tests__/pexels.test.ts | 17 ++++ packages/provider-pexels/src/index.ts | 7 +- .../src/__tests__/pixabay.test.ts | 55 +++++++++++- packages/provider-pixabay/src/index.ts | 24 ++++- packages/provider-polyhaven/README.md | 9 ++ .../src/__tests__/live.test.ts | 1 + .../src/__tests__/polyhaven.test.ts | 11 ++- packages/provider-polyhaven/src/index.ts | 6 +- 20 files changed, 373 insertions(+), 26 deletions(-) diff --git a/packages/provider-flickr/src/__tests__/flickr.test.ts b/packages/provider-flickr/src/__tests__/flickr.test.ts index 23ec4dd..3cf54e6 100644 --- a/packages/provider-flickr/src/__tests__/flickr.test.ts +++ b/packages/provider-flickr/src/__tests__/flickr.test.ts @@ -55,8 +55,9 @@ describe('flickr provider', () => { expect(r.sourceUrl).toBe('https://www.flickr.com/photos/99@N00/111') expect(r.title).toBe('Sunset over bay') expect(r.thumbnail?.url).toBe('https://live.staticflickr.com/1/111_t.jpg') - expect(r.preview?.url).toBe('https://live.staticflickr.com/1/111_b.jpg') - expect(r.visual).toEqual({ width: 1024, height: 683 }) + // the ≤1024px rendition's size stays on preview; visual (the original's size) is unset + expect(r.preview).toEqual({ url: 'https://live.staticflickr.com/1/111_b.jpg', mediaType: 'image/jpeg', width: 1024, height: 683 }) + expect(r.visual).toBeUndefined() expect(r.rights.rehostPolicy).toBe('cache-allowed') }) diff --git a/packages/provider-flickr/src/index.ts b/packages/provider-flickr/src/index.ts index c7a7858..594327a 100644 --- a/packages/provider-flickr/src/index.ts +++ b/packages/provider-flickr/src/index.ts @@ -156,6 +156,8 @@ function toReference(p: FlickrPhoto): EmittedReference { rehostPolicy: 'cache-allowed', raw: { sourceTerms: 'https://www.flickr.com/help/terms', sourceUrl }, } + // url_l / url_m are ≤1024px renditions: their size goes on preview only. + // `visual` is the original's size, which this response does not carry. const previewUrl = p.url_l ?? p.url_m const previewW = p.url_l ? p.width_l : p.width_m const previewH = p.url_l ? p.height_l : p.height_m @@ -168,7 +170,6 @@ function toReference(p: FlickrPhoto): EmittedReference { rights, ...(thumbUrl ? { thumbnail: { url: thumbUrl } } : {}), ...(previewUrl ? { preview: { url: previewUrl, mediaType: 'image/jpeg', width: previewW, height: previewH } } : {}), - ...(previewW && previewH ? { visual: { width: previewW, height: previewH } } : {}), raw: p, } } diff --git a/packages/provider-freesound/src/__tests__/freesound.test.ts b/packages/provider-freesound/src/__tests__/freesound.test.ts index 293af5c..4888c54 100644 --- a/packages/provider-freesound/src/__tests__/freesound.test.ts +++ b/packages/provider-freesound/src/__tests__/freesound.test.ts @@ -1,5 +1,6 @@ import { describe, expect, it } from 'vitest' import { evaluateUse, type ProviderContext } from '@refkit/core' +import { searchConformant } from '@refkit/provider-testkit' import { freesound, mapFreesoundLicense } from '../index' const ctxJson = (body: unknown, capture?: (url: string) => void): ProviderContext => ({ @@ -72,6 +73,28 @@ describe('freesound provider', () => { expect(refs[0].sourceUrl).toBe('https://freesound.org/people/frank/sounds/11/') }) + it('fills audio.durationSec from the requested duration field (float seconds)', async () => { + const refs = await freesound({ apiKey: 'k' }).search({ text: 'door', modalities: ['audio'] }, ctxJson(RESULTS)) + expect(refs.map(r => r.audio)).toEqual([{ durationSec: 2.5 }, { durationSec: 5 }, { durationSec: 1 }, { durationSec: 3 }]) + }) + + it('leaves audio unset when duration is missing or null', async () => { + const NO_DURATION = { + count: 2, next: null, previous: null, + results: [ + { ...RESULTS.results[0], duration: undefined }, + { ...RESULTS.results[1], duration: null }, + ], + } + const refs = await freesound({ apiKey: 'k' }).search({ text: 'door', modalities: ['audio'] }, ctxJson(NO_DURATION)) + expect(refs.map(r => r.audio)).toEqual([undefined, undefined]) + }) + + it('passes provider conformance (testkit) on the fixture', async () => { + const refs = await searchConformant(freesound({ apiKey: 'k' }), ctxJson(RESULTS).fetch) + expect(refs).toHaveLength(4) + }) + it('forwards query, token, and fields; respects limit', async () => { let url = '' await freesound({ apiKey: 'secret' }).search( diff --git a/packages/provider-freesound/src/index.ts b/packages/provider-freesound/src/index.ts index 5fea1fb..955d8e0 100644 --- a/packages/provider-freesound/src/index.ts +++ b/packages/provider-freesound/src/index.ts @@ -56,7 +56,8 @@ interface FreesoundResult { username?: string url: string previews?: Record - duration?: number + /** Seconds, as a float. */ + duration?: number | null filesize?: number tags?: string[] } @@ -75,6 +76,8 @@ function toAudioReference(r: FreesoundResult): EmittedReference | null { raw: { sourceTerms: 'https://freesound.org/help/tos_api/', sourceUrl }, } const previewUrl = r.previews?.['preview-hq-mp3'] ?? r.previews?.['preview-lq-mp3'] + const duration = r.duration + const hasDuration = typeof duration === 'number' && Number.isFinite(duration) && duration > 0 return { modality: 'audio', kind: 'sound-effect', @@ -83,6 +86,7 @@ function toAudioReference(r: FreesoundResult): EmittedReference | null { sourceUrl, rights, ...(previewUrl ? { preview: { url: previewUrl, mediaType: 'audio/mpeg' } } : {}), + ...(hasDuration ? { audio: { durationSec: duration } } : {}), raw: r, } } diff --git a/packages/provider-internet-archive/README.md b/packages/provider-internet-archive/README.md index 21f70ca..3535550 100644 --- a/packages/provider-internet-archive/README.md +++ b/packages/provider-internet-archive/README.md @@ -8,6 +8,8 @@ Search **Internet Archive** as license-tagged video · text references — a pro - **License:** per-item CC (dirty) → unknown fallback > **v1 scope:** only `mediatype: movies` (→ `video`) and `mediatype: texts` (→ `text`) are mapped. All other mediatypes (`audio`, `image`, `collection`, `software`, `web`, `data`, `etree`) are filtered out and are a documented follow-up. +> +> The search asks only for the mediatypes of the requested modalities: `modalities: ['text']` searches `texts` only, `['video']` searches `movies` only. ## Usage diff --git a/packages/provider-internet-archive/src/__tests__/internet-archive.test.ts b/packages/provider-internet-archive/src/__tests__/internet-archive.test.ts index 54bd800..d72d3e0 100644 --- a/packages/provider-internet-archive/src/__tests__/internet-archive.test.ts +++ b/packages/provider-internet-archive/src/__tests__/internet-archive.test.ts @@ -46,6 +46,7 @@ describe('mediatypeToModality (D1)', () => { }) import { evaluateUse, type ProviderContext } from '@refkit/core' +import { searchConformant } from '@refkit/provider-testkit' import { internetArchive } from '../index' const DOCS = [ @@ -195,7 +196,47 @@ describe('internetArchive search', () => { ctxResponding({ response: { numFound: 0, docs: [] } }, u => { seen = u }), ) const q = new URL(seen).searchParams.get('q')! - expect(q).toBe('(x\\) OR \\(mediatype\\:audio) AND mediatype:(movies OR texts)') + expect(q).toBe('(x\\) OR \\(mediatype\\:audio) AND mediatype:(movies)') + }) + + it('asks only for the mediatypes of the requested modalities (video → movies, text → texts)', async () => { + const clauseFor = async (modalities: ('video' | 'text')[]) => { + let seen = '' + await internetArchive().search( + { text: 'jazz', modalities }, + ctxResponding({ response: { numFound: 0, docs: [] } }, u => { seen = u }), + ) + return new URL(seen).searchParams.get('q') + } + expect(await clauseFor(['video'])).toBe('(jazz) AND mediatype:(movies)') + expect(await clauseFor(['text'])).toBe('(jazz) AND mediatype:(texts)') + expect(await clauseFor(['text', 'video'])).toBe('(jazz) AND mediatype:(movies OR texts)') + }) + + it('returns [] without a request when neither video nor text is requested', async () => { + let calls = 0 + const refs = await internetArchive().search( + { text: 'jazz', modalities: ['image'] }, + ctxResponding({ response: { numFound: 1, docs: DOCS } }, () => { calls++ }), + ) + expect(refs).toEqual([]) + expect(calls).toBe(0) + }) + + it('passes provider conformance (testkit) on a text-only query', async () => { + // Behaves like advancedsearch: returns only the docs whose mediatype the + // query's `mediatype:(…)` clause names, so a clause that still asks for + // movies on a text-only query leaks films, which the testkit rejects. + const archive: ProviderContext = { + fetch: (async (input: string) => { + const clause = /mediatype:\(([^)]*)\)$/.exec(new URL(String(input)).searchParams.get('q') ?? '') + const wanted = new Set((clause?.[1] ?? '').split(' OR ')) + const docs = DOCS.filter(d => wanted.has(d.mediatype)) + return new Response(JSON.stringify({ response: { numFound: docs.length, docs } }), { status: 200 }) + }) as typeof fetch, + } + const refs = await searchConformant(internetArchive(), archive.fetch, { queryExtras: { modalities: ['text'] } }) + expect(refs.map(r => r.source.url)).toEqual(['https://archive.org/details/alices_adventures']) }) it('declares kinds and description', () => { diff --git a/packages/provider-internet-archive/src/__tests__/live.test.ts b/packages/provider-internet-archive/src/__tests__/live.test.ts index e994038..d81bf0d 100644 --- a/packages/provider-internet-archive/src/__tests__/live.test.ts +++ b/packages/provider-internet-archive/src/__tests__/live.test.ts @@ -2,3 +2,5 @@ import { liveSmoke } from '@refkit/provider-testkit/live' import { internetArchive } from '../index' liveSmoke('internet-archive', () => internetArchive()) +// A text-only search must come back with texts only (the testkit rejects a film). +liveSmoke('internet-archive text-only', () => internetArchive(), { queryExtras: { modalities: ['text'] } }) diff --git a/packages/provider-internet-archive/src/index.ts b/packages/provider-internet-archive/src/index.ts index 79436bc..2151af3 100644 --- a/packages/provider-internet-archive/src/index.ts +++ b/packages/provider-internet-archive/src/index.ts @@ -37,6 +37,14 @@ export function mediatypeToModality(mt: string): Modality | null { return MEDIATYPE_MODALITY[mt] ?? null } +/** The mediatypes to ask advancedsearch for: only those of the requested + * modalities (movies for video, texts for text), in a fixed order. */ +function mediatypesFor(modalities: readonly Modality[]): string[] { + return Object.entries(MEDIATYPE_MODALITY) + .filter(([, modality]) => modalities.includes(modality)) + .map(([mediatype]) => mediatype) +} + interface IaDoc { identifier: string title?: string | string[] @@ -93,8 +101,11 @@ export function internetArchive(config: InternetArchiveConfig = {}) { description: 'Public-domain and CC films and texts from the Internet Archive', capabilities: { controls: ['page'] }, async search(q: NormalizedQuery, ctx: ProviderContext): Promise { + // Ask only for what the query wants: a text-only search must not get films. + const mediatypes = mediatypesFor(q.modalities) + if (mediatypes.length === 0) return [] const url = new URL(BASE) - url.searchParams.set('q', `(${escapeLucene(q.text)}) AND mediatype:(movies OR texts)`) + url.searchParams.set('q', `(${escapeLucene(q.text)}) AND mediatype:(${mediatypes.join(' OR ')})`) for (const f of ['identifier', 'title', 'creator', 'licenseurl', 'mediatype']) { url.searchParams.append('fl[]', f) } diff --git a/packages/provider-jamendo/src/__tests__/jamendo.test.ts b/packages/provider-jamendo/src/__tests__/jamendo.test.ts index c00a204..bddacd6 100644 --- a/packages/provider-jamendo/src/__tests__/jamendo.test.ts +++ b/packages/provider-jamendo/src/__tests__/jamendo.test.ts @@ -1,5 +1,6 @@ import { describe, expect, it } from 'vitest' import { evaluateUse, type ProviderContext } from '@refkit/core' +import { searchConformant } from '@refkit/provider-testkit' import { jamendo, mapJamendoLicense } from '../index' // Jamendo wraps results in { headers, results }. This ctx captures the request URL @@ -20,9 +21,12 @@ const envelope = (results: unknown[]) => ({ results, }) +// Fields as in Jamendo's documented /v3.0/tracks response (developer.jamendo.com, +// whose example is this same track: `"duration":272`, integer seconds). const TRACK_BY = { id: '1848357', name: 'Sunrise', + duration: 272, artist_name: 'fankel', audio: 'https://prod-1.storage.jamendo.com/?trackid=1848357&format=mp31&from=app-devsite', audiodownload: 'https://prod-1.storage.jamendo.com/download/track/1848357/mp32/', @@ -130,6 +134,25 @@ describe('jamendo provider', () => { expect(u.searchParams.get('offset')).toBe('20') }) + it('fills audio.durationSec from the track duration (integer seconds)', async () => { + const { ctx } = ctxCapturing(envelope([TRACK_BY])) + const [r] = await jamendo({ clientId: 'cid' }).search({ text: 'sunrise', modalities: ['audio'] }, ctx) + expect(r.audio).toEqual({ durationSec: 272 }) + }) + + it('leaves audio unset when the duration is missing or null', async () => { + const { duration: _omit, ...noDuration } = TRACK_BY + const { ctx } = ctxCapturing(envelope([noDuration, { ...TRACK_NC, duration: null }])) + const refs = await jamendo({ clientId: 'cid' }).search({ text: 'x', modalities: ['audio'] }, ctx) + expect(refs.map(r => r.audio)).toEqual([undefined, undefined]) + }) + + it('passes provider conformance (testkit) on the fixture', async () => { + const { ctx } = ctxCapturing(envelope([TRACK_BY, TRACK_NC, TRACK_NO_LICENSE])) + const refs = await searchConformant(jamendo({ clientId: 'cid' }), ctx.fetch) + expect(refs).toHaveLength(3) + }) + it('returns [] when Jamendo finds nothing', async () => { const { ctx } = ctxCapturing(envelope([])) const refs = await jamendo({ clientId: 'cid' }).search({ text: 'zzzz', modalities: ['audio'] }, ctx) diff --git a/packages/provider-jamendo/src/index.ts b/packages/provider-jamendo/src/index.ts index b2fc8bd..3b4d111 100644 --- a/packages/provider-jamendo/src/index.ts +++ b/packages/provider-jamendo/src/index.ts @@ -35,6 +35,8 @@ const JAMENDO_AUDIO_MIME: Record = { interface JamendoTrack { id: string name: string + /** Seconds (an integer in the documented response). */ + duration?: number | null artist_name: string audio: string audiodownload?: string @@ -71,6 +73,8 @@ function toAudioReference(t: JamendoTrack, mediaType: string): EmittedReference rehostPolicy: 'cache-allowed', raw: { sourceTerms: t.license_ccurl, sourceUrl }, } + const duration = t.duration + const hasDuration = typeof duration === 'number' && Number.isFinite(duration) && duration > 0 return { modality: 'audio', kind: 'music', @@ -80,6 +84,7 @@ function toAudioReference(t: JamendoTrack, mediaType: string): EmittedReference // audio has no native thumbnail; the album art is the closest visual handle ...(t.image ? { thumbnail: { url: t.image } } : {}), preview: { url: t.audio, mediaType }, + ...(hasDuration ? { audio: { durationSec: duration } } : {}), raw: t, } } diff --git a/packages/provider-openverse/src/__tests__/openverse.test.ts b/packages/provider-openverse/src/__tests__/openverse.test.ts index 489f5ad..0eface1 100644 --- a/packages/provider-openverse/src/__tests__/openverse.test.ts +++ b/packages/provider-openverse/src/__tests__/openverse.test.ts @@ -259,28 +259,103 @@ describe('openverse anonymous page_size cap', () => { }) describe('openverseAudio provider', () => { - const AUDIO = { results: [{ + // Shapes observed live on api.openverse.org/v1/audio/ (2026-10-12/13, keyless): + // Freesound-sourced items carry category null, thumbnail null, bit_rate 128000, + // sample_rate null and filetype mp3; Jamendo-sourced items carry category + // "music", album art at /thumb/ (image/jpeg), bit_rate null and Jamendo's own + // format code (mp32); Wikimedia-sourced items carry a sample rate, sometimes + // category "pronunciation" and filetypes such as ogx. `duration` is in ms and + // `waveform` (on every item) is JSON peak data, never an image. + const FREESOUND_SOURCED = { id: 'a1', title: 'Piano Melody', creator: 'benpm', foreign_landing_url: 'https://freesound.org/people/benpm/sounds/186942', url: 'https://cdn.freesound.org/previews/186/186942_2594536-hq.mp3', thumbnail: null, license: 'by', license_version: '4.0', license_url: 'https://creativecommons.org/licenses/by/4.0/', - filetype: 'mp3', waveform: 'https://api.openverse.org/v1/audio/a1/waveform/', - }] } + provider: 'freesound', source: 'freesound', category: null, genres: null, + filetype: 'mp3', duration: 60116, bit_rate: 128000, sample_rate: null, + waveform: 'https://api.openverse.org/v1/audio/a1/waveform/', + } + const JAMENDO_SOURCED = { + id: 'a2', title: 'Ragtime Stroll', creator: 'fankel', + foreign_landing_url: 'https://www.jamendo.com/track/1848357', + url: 'https://prod-1.storage.jamendo.com/?trackid=1848357&format=mp32', + thumbnail: 'https://api.openverse.org/v1/audio/a2/thumb/', license: 'by-sa', license_version: '3.0', + license_url: 'https://creativecommons.org/licenses/by-sa/3.0/', + provider: 'jamendo', source: 'jamendo', category: 'music', genres: ['jazz', 'ragtime'], + filetype: 'mp32', duration: 72000, bit_rate: null, sample_rate: null, + waveform: 'https://api.openverse.org/v1/audio/a2/waveform/', + } + const WIKIMEDIA_SOURCED = { + id: 'a3', title: 'De-Hallo.ogg', creator: 'Someone', + foreign_landing_url: 'https://commons.wikimedia.org/w/index.php?curid=1', + url: 'https://upload.wikimedia.org/wikipedia/commons/1/1a/De-Hallo.ogx', + thumbnail: null, license: 'by-sa', license_version: '4.0', + license_url: 'https://creativecommons.org/licenses/by-sa/4.0/', + provider: 'wikimedia_audio', source: 'wikimedia_audio', category: 'pronunciation', genres: null, + filetype: 'ogx', duration: 1234, bit_rate: 112000, sample_rate: 44100, + waveform: 'https://api.openverse.org/v1/audio/a3/waveform/', + } + const AUDIO = { results: [FREESOUND_SOURCED, JAMENDO_SOURCED, WIKIMEDIA_SOURCED] } - it('maps audio to a Reference (modality audio, CC-BY + version, waveform thumbnail, audio mime)', async () => { + it('maps audio to a Reference (modality audio, CC-BY + version, audio mime)', async () => { const refs = await openverseAudio().search({ text: 'piano', modalities: ['audio'] }, ctxWith(AUDIO)) - expect(refs).toHaveLength(1) + expect(refs).toHaveLength(3) const r = refs[0] expect(r.modality).toBe('audio') expect(r.rights.license).toBe('CC-BY') expect(r.rights.licenseVersion).toBe('4.0') expect(r.preview?.url).toBe('https://cdn.freesound.org/previews/186/186942_2594536-hq.mp3') expect(r.preview?.mediaType).toBe('audio/mpeg') - expect(r.thumbnail?.url).toContain('waveform') expect(evaluateUse(r.rights, 'commercial-product').decision).toBe('allowed-with-attribution') }) + it('fills audio metadata: duration ms → s, bit rate bps → kbps, format from filetype; null sample rate stays unset', async () => { + const [freesoundItem] = await openverseAudio().search({ text: 'piano', modalities: ['audio'] }, ctxWith(AUDIO)) + expect(freesoundItem.audio).toEqual({ durationSec: 60.116, bitrateKbps: 128, format: 'mp3' }) + }) + + it('normalises Jamendo format codes (mp31/mp32 → mp3) and leaves the null bit rate unset', async () => { + const [, jamendoItem] = await openverseAudio().search({ text: 'piano', modalities: ['audio'] }, ctxWith(AUDIO)) + expect(jamendoItem.audio).toEqual({ durationSec: 72, format: 'mp3' }) + expect(jamendoItem.preview?.mediaType).toBe('audio/mpeg') + const MP31 = { results: [{ ...JAMENDO_SOURCED, filetype: 'mp31' }] } + const [mp31] = await openverseAudio().search({ text: 'x', modalities: ['audio'] }, ctxWith(MP31)) + expect(mp31.audio?.format).toBe('mp3') + }) + + it('keeps a sample rate when present; a filetype that is not a known audio format sets no format', async () => { + const [, , wikimediaItem] = await openverseAudio().search({ text: 'hallo', modalities: ['audio'] }, ctxWith(AUDIO)) + expect(wikimediaItem.audio).toEqual({ durationSec: 1.234, bitrateKbps: 112, sampleRateHz: 44100 }) + }) + + it('leaves audio unset when every upstream field is null', async () => { + const BARE = { results: [{ ...FREESOUND_SOURCED, filetype: null, duration: null, bit_rate: null, sample_rate: null }] } + const [r] = await openverseAudio().search({ text: 'x', modalities: ['audio'] }, ctxWith(BARE)) + expect(r.audio).toBeUndefined() + }) + + it('thumbnail is the upstream album art, never the JSON waveform; unset when the art is null', async () => { + const [freesoundItem, jamendoItem] = await openverseAudio().search({ text: 'piano', modalities: ['audio'] }, ctxWith(AUDIO)) + expect(jamendoItem.thumbnail).toEqual({ url: 'https://api.openverse.org/v1/audio/a2/thumb/' }) + expect(freesoundItem.thumbnail).toBeUndefined() + }) + + it('maps category to kind only when it means music or a sound effect', async () => { + const SFX = { results: [{ ...FREESOUND_SOURCED, category: 'sound_effect' }] } + const [sfx] = await openverseAudio().search({ text: 'door', modalities: ['audio'] }, ctxWith(SFX)) + expect(sfx.kind).toBe('sound-effect') + const [freesoundItem, jamendoItem, wikimediaItem] = await openverseAudio().search({ text: 'piano', modalities: ['audio'] }, ctxWith(AUDIO)) + expect(jamendoItem.kind).toBe('music') + expect(freesoundItem.kind).toBeUndefined() // category null + expect(wikimediaItem.kind).toBeUndefined() // 'pronunciation' is not a declared kind + }) + + it('passes provider conformance (testkit) on the audio fixture', async () => { + const refs = await searchConformant(openverseAudio(), ctxWith(AUDIO).fetch) + expect(refs).toHaveLength(3) + }) + it('shares the license_type helper with audio searches', async () => { let calledUrl = '' const ctx: ProviderContext = { diff --git a/packages/provider-openverse/src/index.ts b/packages/provider-openverse/src/index.ts index 93da2b8..25ad011 100644 --- a/packages/provider-openverse/src/index.ts +++ b/packages/provider-openverse/src/index.ts @@ -2,7 +2,7 @@ import { defineProvider, okJson, ccVersionFor, setIfString, setIfStringList, setIfBoolean, setIfPositiveInt, setIfNumber, CC_FAMILY_BY_TOKEN, - type EmittedReference, type RightsRecord, type LicenseId, + type EmittedReference, type RightsRecord, type LicenseId, type AudioMeta, type NormalizedQuery, type ProviderContext, } from '@refkit/core' @@ -186,21 +186,62 @@ const AUDIO_MIME: Record = { mp3: 'audio/mpeg', wav: 'audio/wav', ogg: 'audio/ogg', oga: 'audio/ogg', flac: 'audio/flac', opus: 'audio/opus', } +// Jamendo's own format codes (mp31 = 96 kbps mp3, mp32 = VBR mp3), which Openverse +// passes through as `filetype` for Jamendo-sourced items. +const JAMENDO_MP3_CODES = new Set(['mp31', 'mp32']) + interface OpenverseAudioResult { id: string title: string | null creator: string | null foreign_landing_url: string url: string + /** Album art (`/v1/audio//thumb/`, image/jpeg); null when the item has none. */ thumbnail: string | null license: string license_version: string license_url: string filetype?: string | null - waveform?: string | null + /** Milliseconds. */ + duration?: number | null + /** Bits per second; null for Jamendo-sourced items. */ + bit_rate?: number | null + /** Hertz; often null. */ + sample_rate?: number | null + /** Top-level classification: audiobook, music, news, podcast, pronunciation, + * sound_effect; null for Freesound-sourced items. */ + category?: string | null } interface OpenverseAudioResponse { results: OpenverseAudioResult[] } +const positive = (v: number | null | undefined): v is number => + typeof v === 'number' && Number.isFinite(v) && v > 0 + +/** `filetype` as a file format: Jamendo's mp31/mp32 codes normalised to mp3; a + * value that is not a known audio format yields undefined. */ +function audioFormat(filetype: string | null | undefined): string | undefined { + const t = (filetype ?? '').toLowerCase() + const format = JAMENDO_MP3_CODES.has(t) ? 'mp3' : t + return Object.hasOwn(AUDIO_MIME, format) ? format : undefined +} + +function audioMeta(r: OpenverseAudioResult, format: string | undefined): AudioMeta | undefined { + const meta: AudioMeta = {} + if (positive(r.duration)) meta.durationSec = r.duration / 1000 + if (positive(r.bit_rate)) meta.bitrateKbps = r.bit_rate / 1000 + if (positive(r.sample_rate)) meta.sampleRateHz = r.sample_rate + if (format) meta.format = format + return Object.keys(meta).length > 0 ? meta : undefined +} + +/** Openverse `category` → kind, only for the values that clearly mean one of the + * declared kinds (pronunciation, podcast, audiobook and news stay unset). */ +function audioKind(category: string | null | undefined): string | undefined { + if (category === 'music') return 'music' + if (category === 'sound_effect') return 'sound-effect' + return undefined +} + function toAudioReference(r: OpenverseAudioResult): EmittedReference { const license = mapOpenverseLicense(r.license) const rights: RightsRecord = { @@ -211,14 +252,20 @@ function toAudioReference(r: OpenverseAudioResult): EmittedReference { rehostPolicy: 'cache-allowed', raw: { sourceTerms: r.license_url, sourceUrl: r.foreign_landing_url }, } + const format = audioFormat(r.filetype) + const kind = audioKind(r.category) + const audio = audioMeta(r, format) return { modality: 'audio', + ...(kind ? { kind } : {}), title: r.title ?? undefined, sourceUrl: r.foreign_landing_url, rights, - // audio has no image; the waveform render is the closest visual handle - ...(r.waveform ? { thumbnail: { url: r.waveform } } : {}), - preview: { url: r.url, mediaType: AUDIO_MIME[r.filetype ?? ''] ?? 'audio/mpeg' }, + // The album art. The `waveform` URL is JSON peak data, not an image, so it is + // never a thumbnail. + ...(r.thumbnail ? { thumbnail: { url: r.thumbnail } } : {}), + preview: { url: r.url, mediaType: format ? AUDIO_MIME[format] : 'audio/mpeg' }, + ...(audio ? { audio } : {}), raw: r, } } diff --git a/packages/provider-pexels/src/__tests__/pexels.test.ts b/packages/provider-pexels/src/__tests__/pexels.test.ts index 3507e39..be4184f 100644 --- a/packages/provider-pexels/src/__tests__/pexels.test.ts +++ b/packages/provider-pexels/src/__tests__/pexels.test.ts @@ -1,5 +1,6 @@ import { describe, expect, it } from 'vitest' import { evaluateUse, type ProviderContext } from '@refkit/core' +import { searchConformant } from '@refkit/provider-testkit' import { pexels, pexelsVideo } from '../index' const FIXTURE = { @@ -102,6 +103,22 @@ describe('pexelsVideo provider', () => { expect(evaluateUse(r.rights, 'commercial-product').decision).toBe('allowed') // pexels license is commercial-OK }) + it('fills video.durationSec from the upstream duration (integer seconds)', async () => { + const [r] = await pexelsVideo({ apiKey: 'k' }).search({ text: 'cat', modalities: ['video'] }, ctxWith(VIDEO_FIXTURE)) + expect(r.video).toEqual({ durationSec: 12 }) + }) + + it('leaves video unset when the duration is null', async () => { + const NO_DURATION = { videos: [{ ...VIDEO_FIXTURE.videos[0], duration: null }] } + const [r] = await pexelsVideo({ apiKey: 'k' }).search({ text: 'cat', modalities: ['video'] }, ctxWith(NO_DURATION)) + expect(r.video).toBeUndefined() + }) + + it('passes provider conformance (testkit) on the video fixture', async () => { + const refs = await searchConformant(pexelsVideo({ apiKey: 'k' }), ctxWith(VIDEO_FIXTURE).fetch) + expect(refs).toHaveLength(1) + }) + it('forwards documented video search controls and Pexels-specific options', async () => { let calledUrl = '' const ctx: ProviderContext = { diff --git a/packages/provider-pexels/src/index.ts b/packages/provider-pexels/src/index.ts index 011563a..e14e562 100644 --- a/packages/provider-pexels/src/index.ts +++ b/packages/provider-pexels/src/index.ts @@ -87,7 +87,8 @@ interface PexelsVideo { width: number height: number url: string - duration: number + /** Seconds. */ + duration: number | null image: string user: { name: string; url: string } video_files: PexelsVideoFile[] @@ -101,6 +102,8 @@ function pickVideoFile(files: PexelsVideoFile[]): PexelsVideoFile | undefined { function toVideoReference(v: PexelsVideo): EmittedReference { const file = pickVideoFile(v.video_files ?? []) + const duration = v.duration + const hasDuration = typeof duration === 'number' && Number.isFinite(duration) && duration > 0 const rights: RightsRecord = { license: 'pexels', author: v.user?.name, @@ -114,7 +117,9 @@ function toVideoReference(v: PexelsVideo): EmittedReference { rights, thumbnail: { url: v.image }, ...(file ? { preview: { url: file.link, mediaType: file.file_type || 'video/mp4', width: file.width ?? undefined, height: file.height ?? undefined } } : {}), + // the source video's dimensions (the renditions' sizes are on video_files) visual: { width: v.width, height: v.height }, + ...(hasDuration ? { video: { durationSec: duration } } : {}), raw: v, } } diff --git a/packages/provider-pixabay/src/__tests__/pixabay.test.ts b/packages/provider-pixabay/src/__tests__/pixabay.test.ts index 9813b25..5b432a4 100644 --- a/packages/provider-pixabay/src/__tests__/pixabay.test.ts +++ b/packages/provider-pixabay/src/__tests__/pixabay.test.ts @@ -1,5 +1,6 @@ import { describe, expect, it } from 'vitest' import { evaluateUse, type ProviderContext } from '@refkit/core' +import { searchConformant } from '@refkit/provider-testkit' import { pixabay, pixabayVideo } from '../index' const FIXTURE = { @@ -129,12 +130,62 @@ describe('pixabayVideo provider', () => { expect(r.rights.license).toBe('pixabay') expect(r.title).toBe('flowers, meadow') expect(r.tags).toEqual(['flowers', 'meadow']) - expect(r.preview?.url).toBe('https://cdn.pixabay.com/vimeo/125/large.mp4') + expect(r.preview).toEqual({ url: 'https://cdn.pixabay.com/vimeo/125/large.mp4', mediaType: 'video/mp4', width: 1920, height: 1080 }) expect(r.thumbnail?.url).toBe('https://cdn.pixabay.com/vimeo/125/large.jpg') - expect(r.visual).toEqual({ width: 1920, height: 1080 }) + // a rendition's size is not the original's: it stays on preview, visual is unset + expect(r.visual).toBeUndefined() expect(evaluateUse(r.rights, 'commercial-product').decision).toBe('allowed') // pixabay license is commercial-OK }) + const videoCtx = (hits: unknown[]): ProviderContext => ({ + fetch: (async () => new Response(JSON.stringify({ total: hits.length, totalHits: hits.length, hits }), { status: 200 })) as typeof fetch, + }) + const HIT = VIDEO_FIXTURE.hits[0] + + it('fills video.durationSec from the upstream duration; unset when null', async () => { + const [r] = await pixabayVideo({ key: 'k' }).search({ text: 'flowers', modalities: ['video'] }, videoCtx([HIT])) + expect(r.video).toEqual({ durationSec: 13 }) + const [noDuration] = await pixabayVideo({ key: 'k' }).search({ text: 'flowers', modalities: ['video'] }, videoCtx([{ ...HIT, duration: null }])) + expect(noDuration.video).toBeUndefined() + }) + + it('maps the upstream type (film / animation) to kind; any other value leaves kind unset', async () => { + const refs = await pixabayVideo({ key: 'k' }).search({ text: 'flowers', modalities: ['video'] }, videoCtx([ + HIT, + { ...HIT, id: 126, pageURL: 'https://pixabay.com/videos/id-126/', type: 'animation' }, + { ...HIT, id: 127, pageURL: 'https://pixabay.com/videos/id-127/', type: 'hologram' }, + { ...HIT, id: 128, pageURL: 'https://pixabay.com/videos/id-128/', type: undefined }, + ])) + expect(refs.map(r => r.kind)).toEqual(['film', 'animation', undefined, undefined]) + }) + + it('skips a rendition with an empty url or a size of 0 when choosing the preview', async () => { + // Pixabay docs: "If a large video version is not available, an empty URL + // value and a size of zero is returned." + const NO_LARGE = { + ...HIT, + videos: { + large: { url: '', width: 0, height: 0, size: 0, thumbnail: '' }, + medium: { url: 'https://cdn.pixabay.com/vimeo/125/medium.mp4', width: 1280, height: 720, size: 0, thumbnail: 'https://cdn.pixabay.com/vimeo/125/medium.jpg' }, + small: { url: 'https://cdn.pixabay.com/vimeo/125/small.mp4', width: 960, height: 540, size: 2000000, thumbnail: 'https://cdn.pixabay.com/vimeo/125/small.jpg' }, + }, + } + const [r] = await pixabayVideo({ key: 'k' }).search({ text: 'flowers', modalities: ['video'] }, videoCtx([NO_LARGE])) + expect(r.preview).toEqual({ url: 'https://cdn.pixabay.com/vimeo/125/small.mp4', mediaType: 'video/mp4', width: 960, height: 540 }) + expect(r.thumbnail?.url).toBe('https://cdn.pixabay.com/vimeo/125/small.jpg') + }) + + it('drops a hit whose every rendition is empty', async () => { + const EMPTY = { ...HIT, videos: { large: { url: '', width: 0, height: 0, size: 0 }, tiny: { url: '', width: 0, height: 0, size: 0 } } } + const refs = await pixabayVideo({ key: 'k' }).search({ text: 'flowers', modalities: ['video'] }, videoCtx([EMPTY])) + expect(refs).toEqual([]) + }) + + it('passes provider conformance (testkit) on the video fixture', async () => { + const refs = await searchConformant(pixabayVideo({ key: 'k' }), videoCtx([HIT]).fetch) + expect(refs).toHaveLength(1) + }) + it('forwards documented video search controls and Pixabay-specific options', async () => { let calledUrl = '' const ctx: ProviderContext = { diff --git a/packages/provider-pixabay/src/index.ts b/packages/provider-pixabay/src/index.ts index 2a9e216..3c65975 100644 --- a/packages/provider-pixabay/src/index.ts +++ b/packages/provider-pixabay/src/index.ts @@ -156,15 +156,27 @@ interface PixabayVideoSize { url: string; width: number; height: number; size: n interface PixabayVideoHit { id: number pageURL: string + /** 'film' | 'animation' */ + type?: string tags: string - duration: number + /** Seconds. */ + duration: number | null user: string videos: { large?: PixabayVideoSize; medium?: PixabayVideoSize; small?: PixabayVideoSize; tiny?: PixabayVideoSize } } interface PixabayVideoResponse { hits: PixabayVideoHit[] } +// Pixabay docs: "If a large video version is not available, an empty URL value +// and a size of zero is returned." Such a rendition is skipped. +const usableRendition = (r: PixabayVideoSize | undefined): r is PixabayVideoSize => + !!r && !!r.url && r.size !== 0 + +function pixabayVideoKind(t: string | undefined): string | undefined { + return t === 'film' || t === 'animation' ? t : undefined +} + function toVideoReference(h: PixabayVideoHit): EmittedReference | null { - const v = h.videos.large ?? h.videos.medium ?? h.videos.small ?? h.videos.tiny + const v = [h.videos.large, h.videos.medium, h.videos.small, h.videos.tiny].find(usableRendition) if (!v) return null // no usable rendition → skip rather than emit a preview-less reference const rights: RightsRecord = { license: 'pixabay', @@ -173,15 +185,21 @@ function toVideoReference(h: PixabayVideoHit): EmittedReference | null { raw: { sourceTerms: 'https://pixabay.com/service/license-summary/', sourceUrl: h.pageURL }, } const tags = splitTags(h.tags) + const kind = pixabayVideoKind(h.type) + const duration = h.duration + const hasDuration = typeof duration === 'number' && Number.isFinite(duration) && duration > 0 return { modality: 'video', + ...(kind ? { kind } : {}), title: h.tags || undefined, ...(tags.length > 0 ? { tags } : {}), sourceUrl: h.pageURL, rights, ...(v.thumbnail ? { thumbnail: { url: v.thumbnail } } : {}), + // A rendition's size, not the original's, so it stays on preview and + // `visual` is left unset. preview: { url: v.url, mediaType: 'video/mp4', width: v.width, height: v.height }, - visual: { width: v.width, height: v.height }, + ...(hasDuration ? { video: { durationSec: duration } } : {}), raw: h, } } diff --git a/packages/provider-polyhaven/README.md b/packages/provider-polyhaven/README.md index 20475ca..d9fe771 100644 --- a/packages/provider-polyhaven/README.md +++ b/packages/provider-polyhaven/README.md @@ -21,6 +21,15 @@ const refkit = createRefkit({ providers: [polyhaven(/* config */)] }) const refs = await refkit.search({ query: 'asphalt', modalities: ['image'] }) ``` +### Textures and HDRIs are separate sources + +`polyhaven()` searches textures under the source id `polyhaven`. `polyhaven({ assetType: 'hdris' })` searches HDRIs under its own id, `polyhaven-hdri`, so both can be configured side by side (refkit rejects two sources with the same id). Use that id in `sources` and `sourceOptions`; reference ids and cache keys for HDRIs carry it too. + +```ts +const refkit = createRefkit({ providers: [polyhaven(), polyhaven({ assetType: 'hdris' })] }) +const hdris = await refkit.search({ query: 'sunset', modalities: ['image'], sources: ['polyhaven-hdri'] }) +``` + ### ambientCG sibling factory `ambientcg()` lives in the same package and returns the same CC0-normalized image references, hitting ambientCG's API instead: diff --git a/packages/provider-polyhaven/src/__tests__/live.test.ts b/packages/provider-polyhaven/src/__tests__/live.test.ts index 73eed17..9d401ff 100644 --- a/packages/provider-polyhaven/src/__tests__/live.test.ts +++ b/packages/provider-polyhaven/src/__tests__/live.test.ts @@ -2,4 +2,5 @@ import { liveSmoke } from '@refkit/provider-testkit/live' import { polyhaven, ambientcg } from '../index' liveSmoke('polyhaven', () => polyhaven()) +liveSmoke('polyhaven-hdri', () => polyhaven({ assetType: 'hdris' }), { query: 'sunset' }) liveSmoke('ambientcg', () => ambientcg()) diff --git a/packages/provider-polyhaven/src/__tests__/polyhaven.test.ts b/packages/provider-polyhaven/src/__tests__/polyhaven.test.ts index 3dad28e..3721286 100644 --- a/packages/provider-polyhaven/src/__tests__/polyhaven.test.ts +++ b/packages/provider-polyhaven/src/__tests__/polyhaven.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from 'vitest' -import { evaluateUse, type ProviderContext } from '@refkit/core' +import { createRefkit, evaluateUse, type ProviderContext } from '@refkit/core' import { polyhaven } from '../index' // Poly Haven: /assets returns id→asset (no URLs); /files/ returns the download tree. @@ -83,4 +83,13 @@ describe('polyhaven provider', () => { expect(polyhaven().kinds).toEqual(['texture']) expect(polyhaven({ assetType: 'hdris' }).kinds).toEqual(['hdri']) }) + + it('serves HDRIs as their own source, polyhaven-hdri, so textures and HDRIs can coexist', () => { + expect(polyhaven().id).toBe('polyhaven') + expect(polyhaven({ assetType: 'textures' }).id).toBe('polyhaven') + expect(polyhaven({ assetType: 'hdris' }).id).toBe('polyhaven-hdri') + // createRefkit throws on duplicate source ids + const refkit = createRefkit({ providers: [polyhaven(), polyhaven({ assetType: 'hdris' })] }) + expect(refkit.providers.map(p => p.id)).toEqual(['polyhaven', 'polyhaven-hdri']) + }) }) diff --git a/packages/provider-polyhaven/src/index.ts b/packages/provider-polyhaven/src/index.ts index 19101c0..d2077dd 100644 --- a/packages/provider-polyhaven/src/index.ts +++ b/packages/provider-polyhaven/src/index.ts @@ -8,7 +8,9 @@ const PH_BASE = 'https://api.polyhaven.com' const PH_TERMS = 'https://polyhaven.com/license' export interface PolyHavenConfig { - /** texture vs HDRI listing. Default 'textures'. */ + /** texture vs HDRI listing. Default 'textures'. Each is its own source: the + * textures listing has the id `polyhaven`, the HDRI listing `polyhaven-hdri`, + * so both can be configured side by side. */ assetType?: 'textures' | 'hdris' /** Max assets resolved per search; each costs one /files/ call (N+1). Default 12. */ maxAssets?: number @@ -81,7 +83,7 @@ function toReference(id: string, asset: PolyHavenAsset, imageUrl: string, kind: export function polyhaven(config: PolyHavenConfig = {}) { const assetType = config.assetType ?? 'textures' return defineProvider({ - id: 'polyhaven', + id: assetType === 'hdris' ? 'polyhaven-hdri' : 'polyhaven', modalities: ['image'], kinds: assetType === 'hdris' ? ['hdri'] : ['texture'], description: assetType === 'hdris' From 1fc28f15f8a78b792d70fa3253cc1f09346e6dad Mon Sep 17 00:00:00 2001 From: lizhixuan Date: Tue, 13 Oct 2026 00:22:50 +0800 Subject: [PATCH 07/12] docs(spec): non-positive metadata is unset; openverse audio media types follow AUDIO_MIME --- .../2026-10-12-modality-union-and-source-naming-design.md | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/docs/superpowers/specs/2026-10-12-modality-union-and-source-naming-design.md b/docs/superpowers/specs/2026-10-12-modality-union-and-source-naming-design.md index 643ebce..295a54e 100644 --- a/docs/superpowers/specs/2026-10-12-modality-union-and-source-naming-design.md +++ b/docs/superpowers/specs/2026-10-12-modality-union-and-source-naming-design.md @@ -98,7 +98,9 @@ Positional parameters named `providerId` (e.g. `completeReference`, `referenceId | pexels-video | `video.durationSec` | `duration` | | pixabay-video | `video.durationSec` | `duration` | -Null or non-finite upstream values leave the field unset. Internet Archive's `runtime` is skipped (sparse, ambiguous forms). +Null, non-finite or non-positive upstream values leave the field unset (Openverse reports `duration: 0` for MIDI files it never measured; a real 0 is never meaningful for a duration, bitrate or sample rate). Internet Archive's `runtime` is skipped (sparse, ambiguous forms). + +openverse-audio's `preview.mediaType` comes from the same `AUDIO_MIME` table as `format`: Wikimedia's `ogx` (an Ogg Vorbis stream) is normalised to `ogg` like `mp31`/`mp32` to `mp3`, and `mid`/`midi` (`audio/midi`) and `webm` (`audio/webm`) are added, so no item falls back to `audio/mpeg` while its `format` says otherwise. ### D6 — Defects fixed in the same release From e4c740a410f2fddbf987837a13d4550b4128290c Mon Sep 17 00:00:00 2001 From: lizhixuan Date: Tue, 13 Oct 2026 00:27:14 +0800 Subject: [PATCH 08/12] fix(providers): openverse audio media types follow AUDIO_MIME; pin the non-positive and rendition guards --- .../src/__tests__/freesound.test.ts | 8 ++- .../src/__tests__/jamendo.test.ts | 11 +++- .../src/__tests__/openverse.test.ts | 63 +++++++++++++++++-- packages/provider-openverse/src/index.ts | 22 ++++--- .../src/__tests__/pexels.test.ts | 8 +-- .../src/__tests__/pixabay.test.ts | 21 ++++++- 6 files changed, 108 insertions(+), 25 deletions(-) diff --git a/packages/provider-freesound/src/__tests__/freesound.test.ts b/packages/provider-freesound/src/__tests__/freesound.test.ts index 4888c54..dfdb526 100644 --- a/packages/provider-freesound/src/__tests__/freesound.test.ts +++ b/packages/provider-freesound/src/__tests__/freesound.test.ts @@ -78,16 +78,18 @@ describe('freesound provider', () => { expect(refs.map(r => r.audio)).toEqual([{ durationSec: 2.5 }, { durationSec: 5 }, { durationSec: 1 }, { durationSec: 3 }]) }) - it('leaves audio unset when duration is missing or null', async () => { + it('leaves audio unset when duration is missing, null, zero or negative', async () => { const NO_DURATION = { - count: 2, next: null, previous: null, + count: 4, next: null, previous: null, results: [ { ...RESULTS.results[0], duration: undefined }, { ...RESULTS.results[1], duration: null }, + { ...RESULTS.results[2], duration: 0 }, + { ...RESULTS.results[3], duration: -2.5 }, ], } const refs = await freesound({ apiKey: 'k' }).search({ text: 'door', modalities: ['audio'] }, ctxJson(NO_DURATION)) - expect(refs.map(r => r.audio)).toEqual([undefined, undefined]) + expect(refs.map(r => r.audio)).toEqual([undefined, undefined, undefined, undefined]) }) it('passes provider conformance (testkit) on the fixture', async () => { diff --git a/packages/provider-jamendo/src/__tests__/jamendo.test.ts b/packages/provider-jamendo/src/__tests__/jamendo.test.ts index bddacd6..9d7216d 100644 --- a/packages/provider-jamendo/src/__tests__/jamendo.test.ts +++ b/packages/provider-jamendo/src/__tests__/jamendo.test.ts @@ -140,11 +140,16 @@ describe('jamendo provider', () => { expect(r.audio).toEqual({ durationSec: 272 }) }) - it('leaves audio unset when the duration is missing or null', async () => { + it('leaves audio unset when the duration is missing, null, zero or negative', async () => { const { duration: _omit, ...noDuration } = TRACK_BY - const { ctx } = ctxCapturing(envelope([noDuration, { ...TRACK_NC, duration: null }])) + const { ctx } = ctxCapturing(envelope([ + noDuration, + { ...TRACK_NC, duration: null }, + { ...TRACK_NO_LICENSE, duration: 0 }, + { ...TRACK_BY, shareurl: 'https://www.jamendo.com/track/5000004', duration: -272 }, + ])) const refs = await jamendo({ clientId: 'cid' }).search({ text: 'x', modalities: ['audio'] }, ctx) - expect(refs.map(r => r.audio)).toEqual([undefined, undefined]) + expect(refs.map(r => r.audio)).toEqual([undefined, undefined, undefined, undefined]) }) it('passes provider conformance (testkit) on the fixture', async () => { diff --git a/packages/provider-openverse/src/__tests__/openverse.test.ts b/packages/provider-openverse/src/__tests__/openverse.test.ts index 0eface1..05ff309 100644 --- a/packages/provider-openverse/src/__tests__/openverse.test.ts +++ b/packages/provider-openverse/src/__tests__/openverse.test.ts @@ -264,8 +264,10 @@ describe('openverseAudio provider', () => { // sample_rate null and filetype mp3; Jamendo-sourced items carry category // "music", album art at /thumb/ (image/jpeg), bit_rate null and Jamendo's own // format code (mp32); Wikimedia-sourced items carry a sample rate, sometimes - // category "pronunciation" and filetypes such as ogx. `duration` is in ms and - // `waveform` (on every item) is JSON peak data, never an image. + // category "pronunciation" and filetypes such as ogx (an Ogg Vorbis stream whose + // URL ends in .ogg), mid (with `duration: 0`: never measured), webm and "OGG". + // `duration` is in ms and `waveform` (on every item) is JSON peak data, never an + // image. const FREESOUND_SOURCED = { id: 'a1', title: 'Piano Melody', creator: 'benpm', foreign_landing_url: 'https://freesound.org/people/benpm/sounds/186942', @@ -289,7 +291,7 @@ describe('openverseAudio provider', () => { const WIKIMEDIA_SOURCED = { id: 'a3', title: 'De-Hallo.ogg', creator: 'Someone', foreign_landing_url: 'https://commons.wikimedia.org/w/index.php?curid=1', - url: 'https://upload.wikimedia.org/wikipedia/commons/1/1a/De-Hallo.ogx', + url: 'https://upload.wikimedia.org/wikipedia/commons/1/1a/De-Hallo.ogg', thumbnail: null, license: 'by-sa', license_version: '4.0', license_url: 'https://creativecommons.org/licenses/by-sa/4.0/', provider: 'wikimedia_audio', source: 'wikimedia_audio', category: 'pronunciation', genres: null, @@ -324,9 +326,60 @@ describe('openverseAudio provider', () => { expect(mp31.audio?.format).toBe('mp3') }) - it('keeps a sample rate when present; a filetype that is not a known audio format sets no format', async () => { + it('keeps a sample rate when present', async () => { const [, , wikimediaItem] = await openverseAudio().search({ text: 'hallo', modalities: ['audio'] }, ctxWith(AUDIO)) - expect(wikimediaItem.audio).toEqual({ durationSec: 1.234, bitrateKbps: 112, sampleRateHz: 44100 }) + expect(wikimediaItem.audio).toEqual({ durationSec: 1.234, bitrateKbps: 112, sampleRateHz: 44100, format: 'ogg' }) + }) + + // filetype → [format, preview.mediaType]: one table decides both, so they never + // disagree. Aliases: Jamendo's mp31/mp32 → mp3, Wikimedia's ogx → ogg; case is + // ignored (live data has "OGG"). + const FILETYPES: [string, string, string][] = [ + ['mp3', 'mp3', 'audio/mpeg'], + ['mp31', 'mp3', 'audio/mpeg'], + ['mp32', 'mp3', 'audio/mpeg'], + ['ogg', 'ogg', 'audio/ogg'], + ['ogx', 'ogg', 'audio/ogg'], + ['OGG', 'ogg', 'audio/ogg'], + ['oga', 'oga', 'audio/ogg'], + ['opus', 'opus', 'audio/opus'], + ['wav', 'wav', 'audio/wav'], + ['flac', 'flac', 'audio/flac'], + ['mid', 'mid', 'audio/midi'], + ['midi', 'midi', 'audio/midi'], + ['webm', 'webm', 'audio/webm'], + ] + + it.each(FILETYPES)('filetype %s → format %s, preview %s', async (filetype, format, mediaType) => { + const ONE = { results: [{ ...WIKIMEDIA_SOURCED, filetype }] } + const [r] = await openverseAudio().search({ text: 'x', modalities: ['audio'] }, ctxWith(ONE)) + expect(r.audio?.format).toBe(format) + expect(r.preview).toEqual({ url: WIKIMEDIA_SOURCED.url, mediaType }) + }) + + it('every filetype in the table passes conformance (the audio/ prefix check included)', async () => { + const ALL = { results: FILETYPES.map(([filetype], i) => ({ ...WIKIMEDIA_SOURCED, id: `f${i}`, foreign_landing_url: `https://commons.wikimedia.org/w/index.php?curid=${i}`, filetype })) } + const refs = await searchConformant(openverseAudio(), ctxWith(ALL).fetch) + expect(refs.map(r => r.preview?.mediaType)).toEqual(FILETYPES.map(([, , mediaType]) => mediaType)) + }) + + it('a filetype outside the table sets neither a format nor a guessed preview type', async () => { + const UNKNOWN = { results: [{ ...WIKIMEDIA_SOURCED, filetype: 'xyz' }, { ...WIKIMEDIA_SOURCED, filetype: null }] } + const refs = await openverseAudio().search({ text: 'x', modalities: ['audio'] }, ctxWith(UNKNOWN)) + expect(refs.map(r => r.audio?.format)).toEqual([undefined, undefined]) + expect(refs.map(r => r.preview)).toEqual([undefined, undefined]) + }) + + it('leaves a zero or negative duration, bit rate or sample rate unset (live MIDI items report duration 0)', async () => { + const MIDI = { results: [{ ...WIKIMEDIA_SOURCED, filetype: 'mid', duration: 0, bit_rate: null, sample_rate: null }] } + const [midi] = await openverseAudio().search({ text: 'x', modalities: ['audio'] }, ctxWith(MIDI)) + expect(midi.audio).toEqual({ format: 'mid' }) + const ZERO = { results: [{ ...WIKIMEDIA_SOURCED, duration: 0, bit_rate: 0, sample_rate: 0 }] } + const [zero] = await openverseAudio().search({ text: 'x', modalities: ['audio'] }, ctxWith(ZERO)) + expect(zero.audio).toEqual({ format: 'ogg' }) + const NEGATIVE = { results: [{ ...WIKIMEDIA_SOURCED, duration: -1000, bit_rate: -128000, sample_rate: -44100 }] } + const [negative] = await openverseAudio().search({ text: 'x', modalities: ['audio'] }, ctxWith(NEGATIVE)) + expect(negative.audio).toEqual({ format: 'ogg' }) }) it('leaves audio unset when every upstream field is null', async () => { diff --git a/packages/provider-openverse/src/index.ts b/packages/provider-openverse/src/index.ts index 25ad011..773fdf7 100644 --- a/packages/provider-openverse/src/index.ts +++ b/packages/provider-openverse/src/index.ts @@ -182,13 +182,18 @@ export function openverse(config: OpenverseConfig = {}) { }) } +// The one table that decides both `audio.format` and `preview.mediaType`, so the +// two never disagree. Every type has the `audio/` prefix the audio variant's schema +// requires (an audio-only WebM is `audio/webm`, not `video/webm`). const AUDIO_MIME: Record = { mp3: 'audio/mpeg', wav: 'audio/wav', ogg: 'audio/ogg', oga: 'audio/ogg', flac: 'audio/flac', opus: 'audio/opus', + mid: 'audio/midi', midi: 'audio/midi', webm: 'audio/webm', } -// Jamendo's own format codes (mp31 = 96 kbps mp3, mp32 = VBR mp3), which Openverse -// passes through as `filetype` for Jamendo-sourced items. -const JAMENDO_MP3_CODES = new Set(['mp31', 'mp32']) +// Source-specific `filetype` codes that name a format in AUDIO_MIME: Jamendo's +// mp31 (96 kbps mp3) and mp32 (VBR mp3), and Wikimedia's ogx (an Ogg Vorbis +// stream, served as application/ogg from a .ogg/.oga/.opus URL). +const FILETYPE_ALIASES: Record = { mp31: 'mp3', mp32: 'mp3', ogx: 'ogg' } interface OpenverseAudioResult { id: string @@ -217,11 +222,11 @@ interface OpenverseAudioResponse { results: OpenverseAudioResult[] } const positive = (v: number | null | undefined): v is number => typeof v === 'number' && Number.isFinite(v) && v > 0 -/** `filetype` as a file format: Jamendo's mp31/mp32 codes normalised to mp3; a - * value that is not a known audio format yields undefined. */ +/** `filetype` as a file format, case-insensitive, with the aliases above + * resolved; a value that is not a key of AUDIO_MIME yields undefined. */ function audioFormat(filetype: string | null | undefined): string | undefined { const t = (filetype ?? '').toLowerCase() - const format = JAMENDO_MP3_CODES.has(t) ? 'mp3' : t + const format = Object.hasOwn(FILETYPE_ALIASES, t) ? FILETYPE_ALIASES[t] : t return Object.hasOwn(AUDIO_MIME, format) ? format : undefined } @@ -264,7 +269,10 @@ function toAudioReference(r: OpenverseAudioResult): EmittedReference { // The album art. The `waveform` URL is JSON peak data, not an image, so it is // never a thumbnail. ...(r.thumbnail ? { thumbnail: { url: r.thumbnail } } : {}), - preview: { url: r.url, mediaType: format ? AUDIO_MIME[format] : 'audio/mpeg' }, + // A filetype outside AUDIO_MIME gets no preview rather than a guessed type: a + // host may re-serve this cache-allowed file under preview.mediaType. (Every + // filetype seen live in 2026-10 is in the table.) + ...(format ? { preview: { url: r.url, mediaType: AUDIO_MIME[format] } } : {}), ...(audio ? { audio } : {}), raw: r, } diff --git a/packages/provider-pexels/src/__tests__/pexels.test.ts b/packages/provider-pexels/src/__tests__/pexels.test.ts index be4184f..09d96be 100644 --- a/packages/provider-pexels/src/__tests__/pexels.test.ts +++ b/packages/provider-pexels/src/__tests__/pexels.test.ts @@ -108,10 +108,10 @@ describe('pexelsVideo provider', () => { expect(r.video).toEqual({ durationSec: 12 }) }) - it('leaves video unset when the duration is null', async () => { - const NO_DURATION = { videos: [{ ...VIDEO_FIXTURE.videos[0], duration: null }] } - const [r] = await pexelsVideo({ apiKey: 'k' }).search({ text: 'cat', modalities: ['video'] }, ctxWith(NO_DURATION)) - expect(r.video).toBeUndefined() + it('leaves video unset when the duration is null, zero or negative', async () => { + const NO_DURATION = { videos: [null, 0, -12].map(duration => ({ ...VIDEO_FIXTURE.videos[0], duration })) } + const refs = await pexelsVideo({ apiKey: 'k' }).search({ text: 'cat', modalities: ['video'] }, ctxWith(NO_DURATION)) + expect(refs.map(r => r.video)).toEqual([undefined, undefined, undefined]) }) it('passes provider conformance (testkit) on the video fixture', async () => { diff --git a/packages/provider-pixabay/src/__tests__/pixabay.test.ts b/packages/provider-pixabay/src/__tests__/pixabay.test.ts index 5b432a4..e88f41b 100644 --- a/packages/provider-pixabay/src/__tests__/pixabay.test.ts +++ b/packages/provider-pixabay/src/__tests__/pixabay.test.ts @@ -142,11 +142,14 @@ describe('pixabayVideo provider', () => { }) const HIT = VIDEO_FIXTURE.hits[0] - it('fills video.durationSec from the upstream duration; unset when null', async () => { + it('fills video.durationSec from the upstream duration; unset when null, zero or negative', async () => { const [r] = await pixabayVideo({ key: 'k' }).search({ text: 'flowers', modalities: ['video'] }, videoCtx([HIT])) expect(r.video).toEqual({ durationSec: 13 }) - const [noDuration] = await pixabayVideo({ key: 'k' }).search({ text: 'flowers', modalities: ['video'] }, videoCtx([{ ...HIT, duration: null }])) - expect(noDuration.video).toBeUndefined() + const unset = await pixabayVideo({ key: 'k' }).search( + { text: 'flowers', modalities: ['video'] }, + videoCtx([null, 0, -13].map((duration, i) => ({ ...HIT, id: 200 + i, pageURL: `https://pixabay.com/videos/id-${200 + i}/`, duration }))), + ) + expect(unset.map(x => x.video)).toEqual([undefined, undefined, undefined]) }) it('maps the upstream type (film / animation) to kind; any other value leaves kind unset', async () => { @@ -175,6 +178,18 @@ describe('pixabayVideo provider', () => { expect(r.thumbnail?.url).toBe('https://cdn.pixabay.com/vimeo/125/small.jpg') }) + it('skips a rendition whose url is empty even when its size is not 0', async () => { + const EMPTY_URL = { + ...HIT, + videos: { + large: { url: '', width: 3840, height: 2160, size: 5000000, thumbnail: 'https://cdn.pixabay.com/vimeo/125/large.jpg' }, + medium: { url: 'https://cdn.pixabay.com/vimeo/125/medium.mp4', width: 1920, height: 1080, size: 3000000, thumbnail: 'https://cdn.pixabay.com/vimeo/125/medium.jpg' }, + }, + } + const [r] = await pixabayVideo({ key: 'k' }).search({ text: 'flowers', modalities: ['video'] }, videoCtx([EMPTY_URL])) + expect(r.preview).toEqual({ url: 'https://cdn.pixabay.com/vimeo/125/medium.mp4', mediaType: 'video/mp4', width: 1920, height: 1080 }) + }) + it('drops a hit whose every rendition is empty', async () => { const EMPTY = { ...HIT, videos: { large: { url: '', width: 0, height: 0, size: 0 }, tiny: { url: '', width: 0, height: 0, size: 0 } } } const refs = await pixabayVideo({ key: 'k' }).search({ text: 'flowers', modalities: ['video'] }, videoCtx([EMPTY])) From 6f05cfa71ca9f58924495fac01e989de55edf1a0 Mon Sep 17 00:00:00 2001 From: lizhixuan Date: Tue, 13 Oct 2026 00:43:19 +0800 Subject: [PATCH 09/12] feat(mcp): duration and dimensions for agents; docs and changeset for the source/modality release --- .../modality-union-and-source-naming.md | 201 ++++++++++++++++++ README.md | 46 +++- packages/core/README.md | 67 +++++- packages/core/src/__tests__/select.test.ts | 73 ++++++- packages/core/src/index.ts | 7 +- packages/core/src/select.ts | 25 ++- packages/mcp/README.md | 6 +- packages/mcp/src/__tests__/mcp.test.ts | 113 +++++++++- packages/mcp/src/index.ts | 20 +- packages/provider-flickr/README.md | 2 + packages/provider-freesound/README.md | 2 + packages/provider-jamendo/README.md | 2 + packages/provider-openverse/README.md | 11 +- packages/provider-pexels/README.md | 4 +- packages/provider-pixabay/README.md | 6 +- 15 files changed, 560 insertions(+), 25 deletions(-) create mode 100644 .changeset/modality-union-and-source-naming.md diff --git a/.changeset/modality-union-and-source-naming.md b/.changeset/modality-union-and-source-naming.md new file mode 100644 index 0000000..c3aea6a --- /dev/null +++ b/.changeset/modality-union-and-source-naming.md @@ -0,0 +1,201 @@ +--- +'@refkit/core': minor +'@refkit/mcp': minor +'@refkit/provider-artic': minor +'@refkit/provider-brave': minor +'@refkit/provider-europeana': minor +'@refkit/provider-flickr': minor +'@refkit/provider-freesound': minor +'@refkit/provider-gutendex': minor +'@refkit/provider-internet-archive': minor +'@refkit/provider-jamendo': minor +'@refkit/provider-met': minor +'@refkit/provider-nailbook': minor +'@refkit/provider-openverse': minor +'@refkit/provider-pexels': minor +'@refkit/provider-pixabay': minor +'@refkit/provider-poetrydb': minor +'@refkit/provider-polyhaven': minor +'@refkit/provider-rijksmuseum': minor +'@refkit/provider-smithsonian': minor +'@refkit/provider-unsplash': minor +'@refkit/provider-wikimedia-commons': minor +--- + +References now have a shape per modality, every public surface calls the catalog a search queries a "source", and audio and video results carry their duration. This release renames and retypes public API; the migration notes below list every break. + +**Upgrade core, MCP and every provider together.** Each `@refkit/provider-*` package pins the exact `@refkit/core` version it was built with, so a host that upgrades core alone ends up with two copies of core. + +## What changed and why + +### `Reference` is a union keyed by `modality` + +Before, every result had one flat shape: any modality could carry `visual`, `text` or `preview`, and nothing said which fields belonged together. `EmittedReference` and `Reference` are now unions of four variants. Each variant is exported (`EmittedImageReference`, `EmittedVideoReference`, `EmittedAudioReference`, `EmittedTextReference`, and `ImageReference`, `VideoReference`, `AudioReference`, `TextReference`). + +| `modality` | Its own fields | +|---|---| +| `image` | `preview` (`mediaType` `image/*`), `visual`, `perceptualHash` | +| `video` | `preview` (`mediaType` `video/*`), `visual`, `video: VideoMeta` | +| `audio` | `preview` (`mediaType` `audio/*`), `audio: AudioMeta` | +| `text` | `text: TextMeta` | + +- **Common fields.** Every variant keeps `kind`, `title`, `description`, `tags`, `rights`, `thumbnail`, `sourceScore` and `raw`. An emitted item also has `sourceUrl` and an optional `canonicalUrl`; a complete reference has the fields core stamps (`id`, `source`, `canonicalUrl`, `verifiedAt`, `relevance`). +- **`thumbnail`** is always an image a UI can show: the picture itself, a video poster, audio cover art or a book cover. +- **`visual`** means the size of the original image or video. A source that only knows a rendition's size leaves `visual` unset and puts that size on `preview.width` / `preview.height`. +- **Types.** Each variant declares the other variants' fields as `?: never`. A provider that writes a field of another modality, such as `visual` on an audio item, gets a compile error. Code that reads the union still compiles without narrowing: `r.visual` is `VisualMeta | undefined` on any reference. +- **Runtime.** `emittedReferenceSchema` and `referenceSchema` are discriminated unions on `modality`. An item whose `preview.mediaType` does not start with its variant's prefix (`image/`, `video/`, `audio/`) is rejected. A field that belongs to another modality is stripped. + +### Audio and video metadata + +There are two new types. Units: + +- **`VideoMeta`**: `durationSec` (seconds, as a float) and `fps`. +- **`AudioMeta`**: `durationSec` (seconds, as a float), `bitrateKbps` (kilobits per second, bits/s ÷ 1000), `sampleRateHz` (hertz), `channels`, and `format` (a short name such as `mp3`). + +The bundled sources fill them from the responses they already fetch. No source makes an extra request. + +| Source | Fields | +|---|---| +| `openverse-audio` | `audio.durationSec` (Openverse's milliseconds ÷ 1000), `audio.bitrateKbps` (bits/s ÷ 1000; Jamendo-sourced items report none), `audio.sampleRateHz` (often missing), `audio.format` | +| `freesound` | `audio.durationSec` | +| `jamendo` | `audio.durationSec` | +| `pexels-video` | `video.durationSec` | +| `pixabay-video` | `video.durationSec` | + +- **Unset values.** A missing, non-finite, zero or negative duration, bitrate or sample rate leaves the field unset. Openverse reports a duration of 0 for MIDI files it never measured, and zero is never a real measurement for these fields. +- **Not filled yet.** No bundled source fills `fps` or `channels`. Internet Archive leaves durations unset. + +### One word for the catalog a search queries: "source" + +A *provider* is adapter code, such as a `ReferenceProvider` from a `@refkit/provider-*` package. Each provider instance serves one *source*. That source's id (`provider.id`, e.g. `openverse-audio`) is what `sources`, `sourceOptions`, `meta.sources` and each result's `source.id` speak. + +Before, the same id had several names: callers passed `sources`, but results reported `meta.providers[].providerId`, options went under `providerOptions`, errors went to `onProviderError`, and each reference named it `source.providerId`. The rename table is under "Breaking changes". + +### Fixes + +**openverse-audio** + +- **Thumbnail.** `thumbnail` is the item's album art, and is unset when there is none. It used to point at Openverse's waveform endpoint, which returns JSON peak data, not an image. +- **Media types.** `preview.mediaType` and `audio.format` now come from one table of known filetypes: + - Wikimedia's `ogx` files (Ogg streams) are `audio/ogg` with `format: 'ogg'`; + - MIDI files are `audio/midi`; + - WebM files are `audio/webm`. + + All of these used to be labelled `audio/mpeg`. Jamendo's `mp31` and `mp32` read as `mp3`. +- **Unknown filetypes.** An item whose filetype is outside the table has no `preview` and no `format`, instead of a guessed `audio/mpeg`. No such item was seen in live results. +- **Kind.** `kind` comes from Openverse's `category`: `music` becomes `music`, and `sound_effect` becomes `sound-effect`. Other categories leave it unset. In live results only Jamendo-sourced items carried a mapped category (`music`). Freesound-sourced items carry no category, so they have no `kind`. + +**pixabay-video** + +- **Kind.** `kind` comes from Pixabay's `type`: `film` or `animation`. This source and openverse-audio declared these kinds but never emitted them. +- **No `visual`.** It no longer sets `visual`, because it only knows a rendition's size. That size stays on `preview`. +- **Renditions.** When choosing the preview, it skips a rendition whose URL is empty or whose size is 0. Pixabay returns an unavailable `large` rendition that way. + +**Flickr** + +- It no longer sets `visual` from its ≤1024px rendition. The rendition's size stays on `preview`. + +**Internet Archive** + +- It asks only for the mediatypes the query wants: `movies` for video, `texts` for text. +- A text-only search used to get films: in a live check for "jazz", all 20 results on page one were films. +- When the query wants neither modality, it returns no results without making a request. + +**Poly Haven** + +- `polyhaven({ assetType: 'hdris' })` has its own source id, `polyhaven-hdri`, so textures (`polyhaven`) and HDRIs can be configured side by side. + +**Core** + +- **Modality leaks.** An item whose `modality` is outside what its source was asked for (the requested modalities that source declares) is rejected like a malformed item. It is dropped, counted in `meta.sources[].rejected`, and passed to `onSourceError`. +- **Duplicate ids.** `createRefkit` throws when two providers share a source id. Before, this passed silently: the two shared one `meta.sources` entry and one cache key, so one could be answered from the other's cache. +- **Unknown `sourceOptions` ids.** An id in `sourceOptions` that matches no configured source now adds `unknown sourceOptions id(s) ignored: …` to `meta.warnings`, as an unknown id in `sources` already did. +- **Typed selection error.** When a search has no source to fan out to, core throws a `SourceSelectionError`. A host can now branch on its fields instead of matching the message. See "Breaking changes". + +### `@refkit/mcp` + +- **New fields.** Each `search_references` result gains optional `durationSec`, `width` and `height`: + - `durationSec` is the audio or video length in seconds; + - `width` and `height` are in pixels: the original's size from `visual`, else the preview rendition's size (same aspect ratio). + + So agents can filter by length or resolution. The output schema describes all three. +- **Selection misses.** The "unknown sources" tool error is recognised with `isSourceSelectionError` and `code === 'no-matching-source'`, not by reading the message. It still ends with "Enabled source ids: …". + - `sources: []` now gets that hint too. + - So does a miss thrown by a client built with another copy of `@refkit/core`. + +## Breaking changes and how to migrate + +### Renames + +| Where | Old | New | +|---|---|---| +| search meta | `meta.providers` | `meta.sources` | +| search meta | `ProviderSearchStatus` (`providerId`), `providerSearchStatusSchema` | `SourceStatus` (`sourceId`), `sourceStatusSchema` | +| search meta | `meta.controls.appliedByProvider` / `ignoredByProvider` | `appliedBySource` / `ignoredBySource` | +| search meta | `meta.providerOptions` | `meta.sourceOptions` | +| skip reasons | `PROVIDER_SKIP_REASONS`, `ProviderSkipReason` | `SOURCE_SKIP_REASONS`, `SourceSkipReason` (values unchanged) | +| search input | `providerOptions`, `ProviderOptionsById` | `sourceOptions`, `SourceOptionsById` | +| search input | `onProviderError`, `ProviderError { providerId }` | `onSourceError`, `SourceError { sourceId }` | +| reference | `source: { providerId, sourceUrl }` | `source: { id, url }` | +| pipeline (exported) | `PassDeps.onProviderError`, `PassDeps.providerOptions`, `PassOutcome.statusByProvider`, `normalizeQuery`'s `providerOptions` input | `onSourceError`, `sourceOptions`, `statusBySource`, `sourceOptions` | +| messages | `no configured provider matches source id(s) …`, `no registered provider supports modalities …`, `all providers failed`, `n provider(s) failed` | `no configured source matches id(s) …`, `no configured source supports modalities …`, `all sources failed`, `n source(s) failed` | +| MCP input | `providerOptions` | `sourceOptions` | +| MCP output | `provider` on each reference | `source` | + +These names stay unchanged: + +- adapter-side names: `RefkitOptions.providers`, `ReferenceProvider`, `defineProvider`, `ProviderContext`, `ProviderOptions`, `selectProviders`, `runProviderSearch`, `providerCacheKey`; +- `SearchInput.sources` and the MCP `sources` parameter; +- **`NormalizedQuery.providerOptions`**: provider code keeps reading its own options from `q.providerOptions`. + +### `Reference` is a type alias of a union + +- **`Omit`.** `Omit` loses the link between `modality` and its fields. Use a distributive omit: + + ```ts + type DistributiveOmit = T extends unknown ? Omit : never + ``` +- **`extends`.** An interface can no longer `extend` `Reference` or `EmittedReference` (TS2312). Use a type alias with an intersection, or extend one variant. +- **Declaration merging.** You can no longer augment `Reference` by declaration merging. +- **Test fixtures.** A builder that spreads `Partial` or `Partial` over an image default no longer compiles. Type its parameter as `Partial` / `Partial`. + +### Custom providers + +- **Wrong-modality fields.** Writing a field of another modality is a compile error. At runtime the field is stripped. +- **Preview media type.** A `preview.mediaType` without the variant's prefix (`image/`, `video/`, `audio/`) rejects the item. +- **Undeclared modalities.** An item whose `modality` was not asked of the source is rejected. That covers a modality the source does not declare, and one the search did not request. + +### `SourceSelectionError` and `isSourceSelectionError` + +Core still throws when a search has no source to fan out to, but the error is now typed and its messages have changed. Stop matching the message and branch on the fields: + +- `code` is `'no-matching-source'` or `'no-source-for-query'`: + - `'no-matching-source'`: `sources` was given and none of it can serve the search (an unknown id, a known id lacking the modality or the kind, or `sources: []`); + - `'no-source-for-query'`: no `sources` was given, and no configured source supports the modalities (and kind). +- `requested` is the `sources` as passed. +- `unknown` lists the ids that match no configured source. +- `modalities` is the requested modalities. +- `kind` is set when the search narrowed by `controls.media.kind`. + +Recognise the error with `isSourceSelectionError(e)`, not `instanceof`: + +- it is true for this copy's class; +- it is also true for an error thrown by another copy of `@refkit/core` that has `name: 'SourceSelectionError'`, a known `code`, and `unknown` and `modalities` arrays. + +`SOURCE_SELECTION_ERROR_CODES` and the `SourceSelectionErrorCode` type are exported. + +### `runProviderSearch` rejects modality leaks + +- The exported `runProviderSearch` now rejects every live item whose `modality` is not in the `modalities` of the query it is given. +- A direct call with `modalities: []` therefore rejects every live item. +- Through `createRefkit` this cannot happen: a source is only searched when it declares one of the requested modalities, so its query's `modalities` is never empty. + +### Other breaks + +- **One-time cache miss.** The cache key prefix moved from `refkit:v3` to `refkit:v4`. Every entry cached by the previous version is missed once after the upgrade, instead of being parsed in the old shape. +- **Duplicate source ids.** `createRefkit` now throws on duplicate source ids. Configure each source once. +- **Poly Haven HDRI ids.** For HDRI users, `polyhaven({ assetType: 'hdris' })` changes its id from `polyhaven` to `polyhaven-hdri`. Reference ids, cache keys, and the `sources` and `sourceOptions` keys for HDRIs change with it. Replace `'polyhaven'` with `'polyhaven-hdri'` wherever you meant the HDRI source. +- **`visual` on Flickr and pixabay-video.** These results no longer carry `visual`. For their size or aspect ratio, fall back to `preview.width` / `preview.height`, as `@refkit/mcp` does. +- **MCP `providerOptions`.** The input is now `sourceOptions`. The MCP SDK silently strips unknown input keys, so a call that still sends `providerOptions` loses its options without an error. +- **MCP agent ref.** Each reference's `provider` is now `source`. +- **MCP errors with `sources` set.** Errors other than the source miss that are raised while `sources` is set (for example an invalid cursor) are now rethrown by the tool handler. Before, the handler returned its own `isError` result and appended a misleading "Enabled source ids" hint. The MCP SDK still reports a thrown error to the agent as an `isError` result with the error's message, now without the hint. diff --git a/README.md b/README.md index 1231146..adad89f 100644 --- a/README.md +++ b/README.md @@ -20,6 +20,8 @@ pnpm add @refkit/core @refkit/provider-openverse @refkit/provider-met `@refkit/core` is the brain; each source is a thin `@refkit/provider-*` satellite you add as needed. +**Providers and sources.** A *provider* is adapter code (a `ReferenceProvider` from a `@refkit/provider-*` package). Each provider instance serves one *source*, whose id (`provider.id`) is what `sources`, `sourceOptions` and `meta.sources` speak, and what each result names in `source.id`. + ## Quickstart ```ts @@ -57,7 +59,7 @@ const safe = await refkit.search({ query: 'forest', modalities: ['image'], gateF ## Search controls -Use provider-neutral `controls` for the main path. refkit routes each control only to providers that declare support, and `searchWithMeta()` explains which providers applied or ignored each control: +Use provider-neutral `controls` for the main path. refkit routes each control only to providers that declare support, and `searchWithMeta()` explains which sources applied or ignored each control (`meta.controls.appliedBySource` / `ignoredBySource`): ```ts await refkit.search({ @@ -92,6 +94,8 @@ await refkit.search({ }) ``` +An id in `sourceOptions` that matches no configured source is reported in `meta.warnings` (`unknown sourceOptions id(s) ignored: …`) instead of being dropped silently. + The provider package owns its native options surface, e.g. `UnsplashSearchOptions`, `FlickrSearchOptions`, `OpenverseImageSearchOptions`, `MetSearchOptions`, and `PoetryDbSearchOptions`. Response-format/debug parameters and auth-only knobs are intentionally omitted when they would break refkit's normalized `Reference` contract. ### Site-scoped discovery @@ -108,7 +112,7 @@ const notes = await refkit.search({ }) ``` -A `sources` list that matches nothing for the requested modalities throws (a source typo fails loudly instead of reading as "no results"); an id that resolves to nothing while others still match is reported in `meta.warnings`, and every excluded source appears in `meta.sources` with `reason: 'not-selected'`. +A `sources` list that matches nothing for the requested modalities throws a `SourceSelectionError` with `code: 'no-matching-source'` (a source typo fails loudly instead of reading as "no results"). The error carries `requested` (the `sources` as passed), `unknown` (the ids that match no configured source), `modalities` and, when the search narrowed by kind, `kind`. Recognise it with `isSourceSelectionError(e)`, which also works when the error comes from another copy of `@refkit/core`, and branch on `code`, not the message; a search that names no `sources` and that no configured source supports throws the same error with `code: 'no-source-for-query'`. An id that resolves to nothing while others still match is reported in `meta.warnings`, and every excluded source appears in `meta.sources` with `reason: 'not-selected'`. ### Source routing & acceptance @@ -286,7 +290,7 @@ createRefkit({ providers, resilience: false }) // raw fan-out, no timeout/retry await refkit.searchWithMeta({ query: 'forest', modalities: ['image'], deadlineMs: 3000 }) ``` -That is a hard bound while per-provider resilience is on (its deadline race abandons a provider that ignores the signal). With `resilience: false` there is no race left, so `deadlineMs` only binds providers that honour `ctx.signal` — refkit's own satellites all forward it into `fetch`. If the deadline fires while *every* provider is still in flight, the call rejects with an `AggregateError` (same as an all-providers-failed fan-out) rather than reporting an empty success. +That is a hard bound while per-provider resilience is on (its deadline race abandons a provider that ignores the signal). With `resilience: false` there is no race left, so `deadlineMs` only binds providers that honour `ctx.signal` — refkit's own satellites all forward it into `fetch`. If the deadline fires while *every* provider is still in flight, the call rejects with an `AggregateError` (the same error as a fan-out in which every source failed) rather than reporting an empty success. With many sources registered, bound the fan-out with `concurrency` — at most N provider searches run at once (a queued provider's timeout only starts when its slot starts): @@ -326,7 +330,7 @@ createRefkit({ providers, cache: myKvCache, cacheTtlMs: 60_000, cacheRaw: false ¹ gutendex's default host (`gutendex.com`) is the upstream maintainer's **test instance** — its docs say "You should run your own server", and its Cloudflare front blocks datacenter IPs. Desktop/local use works out of the box; for production or server-side traffic, [self-host Gutendex](https://github.com/garethbjohnson/gutendex) and pass `gutendex({ baseUrl: 'https://your-instance' })`. When blocked, the source degrades gracefully (a `failed` entry in `meta.sources`; other sources still return). -Audio/video are extra factories on existing packages: `openverseAudio()`, `pexelsVideo()`, `pixabayVideo()`. Modality routing is automatic — an `['audio']` search only hits audio-capable providers. +Audio/video are extra factories on existing packages: `openverseAudio()` (source id `openverse-audio`), `pexelsVideo()` (`pexels-video`), `pixabayVideo()` (`pixabay-video`). Modality routing is automatic — an `['audio']` search only hits audio-capable providers. Poly Haven's HDRIs are their own source too: `polyhaven({ assetType: 'hdris' })` has the id `polyhaven-hdri`, next to the textures' `polyhaven`. Every source id in one client must be unique — `createRefkit` throws on a duplicate. ## Architecture @@ -350,7 +354,37 @@ One search pass is a fixed pipeline: **select** (modality → `sources` → `kin ### Providers describe, core completes -A provider's `search` returns `EmittedReference[]` — only what the **source** knows: `modality, kind?, title?, description?, tags?, sourceUrl, canonicalUrl?, rights, thumbnail?, preview?, perceptualHash?, visual?, text?, sourceScore?, raw?`. Core validates each item against `emittedReferenceSchema` at the boundary and then stamps everything that is its own concern — `id` (content-addressed from the source id + sourceUrl), `source` (`{ id, url }`), `canonicalUrl` (defaults to `sourceUrl`), `verifiedAt`, `relevance` — and applies the per-provider `limit`. Providers never compute ids, never write provenance or scores, and never post-truncate; a satellite is a mapper plus a rights mapping. +A provider's `search` returns `EmittedReference[]` — only what the **source** knows, each item as the variant of its modality (see [Reference variants](#reference-variants)). Core validates each item against `emittedReferenceSchema` at the boundary and then stamps everything that is its own concern — `id` (content-addressed from the source id + sourceUrl), `source` (`{ id, url }`), `canonicalUrl` (defaults to `sourceUrl`), `verifiedAt`, `relevance` — and applies the per-provider `limit`. Providers never compute ids, never write provenance or scores, and never post-truncate; a satellite is a mapper plus a rights mapping. + +An item that fails the schema, or whose modality is outside what that source was asked for (the requested modalities it declares), is dropped, counted in `meta.sources[].rejected` and passed to `onSourceError` (`{ sourceId, error }`); the rest of the batch survives. + +### Reference variants + +`EmittedReference` and `Reference` are unions keyed by `modality`. Every variant carries the common fields — `modality`, `kind?`, `title?`, `description?`, `tags?`, `rights`, `thumbnail?`, `sourceScore?`, `raw?` — plus `sourceUrl` and `canonicalUrl?` when emitted, or the core-stamped `id`, `source` (`{ id, url }`), `canonicalUrl`, `verifiedAt` and `relevance` when complete. `thumbnail` is always an image a UI can show: the picture itself, a video poster, audio cover art or a book cover. The other fields belong to one modality: + +| `modality` | Its own fields | +|---|---| +| `image` | `preview?` (`mediaType` `image/*`), `visual?`, `perceptualHash?` | +| `video` | `preview?` (`mediaType` `video/*`), `visual?`, `video?: VideoMeta` | +| `audio` | `preview?` (`mediaType` `audio/*`), `audio?: AudioMeta` | +| `text` | `text?: TextMeta` (`excerpt`, `excerptKind`, `locator?`) | + +- **`visual`** (`VisualMeta`: `width`, `height` in pixels, `dominantColors?`) is the size of the original image or video. A source that only knows a rendition's size leaves `visual` unset and puts that size on `preview.width` / `preview.height`. +- **`VideoMeta`**: `durationSec?` (seconds, a float) and `fps?`. +- **`AudioMeta`**: `durationSec?` (seconds, a float), `bitrateKbps?` (kilobits per second, bits/s ÷ 1000), `sampleRateHz?` (hertz), `channels?`, and `format?` (a short container/codec name such as `mp3` or `flac`). + +The bundled sources fill the audio and video metadata from what their search responses already carry: + +| Source | Fills | +|---|---| +| `openverse-audio` | `audio.durationSec`, `audio.bitrateKbps` and `audio.sampleRateHz` when Openverse reports them; `audio.format` when the filetype is a known audio format (`mp3`, `ogg`, `oga`, `opus`, `wav`, `flac`, `mid`, `midi`, `webm`) | +| `freesound`, `jamendo` | `audio.durationSec` | +| `pexels-video` | `video.durationSec`, and `visual` (the source video's size) | +| `pixabay-video` | `video.durationSec`; only a rendition's size is known, so it stays on `preview` | + +A missing, non-finite, zero or negative upstream duration, bitrate or sample rate leaves that field unset. No bundled source fills `fps` or `channels` yet. + +Each variant declares the other variants' fields as `?: never`, so a provider that writes, say, `visual` on an audio item gets a compile error, while code reading the union still sees `r.visual` as `VisualMeta | undefined` without narrowing first. At runtime `emittedReferenceSchema` rejects an item whose `preview.mediaType` lacks its variant's prefix and strips any field that belongs to another modality. Because `Reference` is a type alias of a union, `Omit` loses the link between `modality` and its fields (use a distributive omit), and an interface cannot `extend` it. ## Core invariants (enforced by tests in `@refkit/core`) @@ -378,7 +412,7 @@ Agents can use refkit in three ways: npx -y @refkit/mcp ``` -It boots with the keyless sources (Met, Art Institute, Wikimedia Commons, Openverse + audio, Project Gutenberg, PoetryDB, Rijksmuseum, Poly Haven, ambientCG, Internet Archive, Nailbook) and auto-adds any BYOK source whose key is in the environment (`REFKIT_UNSPLASH_KEY`, `REFKIT_PEXELS_KEY`, `REFKIT_BRAVE_KEY`, … — legacy names like `UNSPLASH_KEY`, `PEXELS_KEY`, `BRAVE_TOKEN` still work as fallbacks). Pass `intent` to annotate each result with a use-verdict (may I use this, is attribution required); `gateFor` to return only allowed results, with `gateContext: { userJurisdiction }` when the caller's jurisdiction matters; `minRelevance` to drop weak matches (calibrated against the reranker's blend — see [Relevance threshold](#relevance-threshold)); `rerank: false` for raw cross-source fusion order (reranking is **on by default**); `deadlineMs` to bound the whole call; `cursor` (from the previous result's top-level `nextCursor` — always returned, no `explain` needed) to page through results without repeats; `REFKIT_MAX_CURSOR_SEEN` shrinks the cursor for hosts that clamp tool-output strings. BYOK provider packages are `optionalDependencies` of `@refkit/mcp` — installed by default (zero-config `npx` keeps working), but an install with `--omit=optional` skips them, and a key whose package is missing just logs a stderr warning instead of crashing the server. Beyond search, `evaluate_use` and `build_attribution` expose the same license-verdict and attribution logic as standalone stateless tools, for when an agent already has a license id and just needs a verdict or a credit line. Or wire your own providers/keys via `serveStdio(createRefkit({ … }))` — see [`@refkit/mcp`](https://www.npmjs.com/package/@refkit/mcp). +It boots with the keyless sources (Met, Art Institute, Wikimedia Commons, Openverse + audio, Project Gutenberg, PoetryDB, Rijksmuseum, Poly Haven, ambientCG, Internet Archive, Nailbook) and auto-adds any BYOK source whose key is in the environment (`REFKIT_UNSPLASH_KEY`, `REFKIT_PEXELS_KEY`, `REFKIT_BRAVE_KEY`, … — legacy names like `UNSPLASH_KEY`, `PEXELS_KEY`, `BRAVE_TOKEN` still work as fallbacks). Pass `intent` to annotate each result with a use-verdict (may I use this, is attribution required); `gateFor` to return only allowed results, with `gateContext: { userJurisdiction }` when the caller's jurisdiction matters; `minRelevance` to drop weak matches (calibrated against the reranker's blend — see [Relevance threshold](#relevance-threshold)); `rerank: false` for raw cross-source fusion order (reranking is **on by default**); `deadlineMs` to bound the whole call; `cursor` (from the previous result's top-level `nextCursor` — always returned, no `explain` needed) to page through results without repeats; `REFKIT_MAX_CURSOR_SEEN` shrinks the cursor for hosts that clamp tool-output strings. BYOK provider packages are `optionalDependencies` of `@refkit/mcp` — installed by default (zero-config `npx` keeps working), but an install with `--omit=optional` skips them, and a key whose package is missing just logs a stderr warning instead of crashing the server. Each result is a flat projection sized for agent context — among other fields `source` (the source id), `license`, `rehostPolicy`, `relevance`, and `durationSec` (audio and video) and `width` / `height` (the original's size, else the preview rendition's) when known. Beyond search, `evaluate_use` and `build_attribution` expose the same license-verdict and attribution logic as standalone stateless tools, for when an agent already has a license id and just needs a verdict or a credit line. Or wire your own providers/keys via `serveStdio(createRefkit({ … }))` — see [`@refkit/mcp`](https://www.npmjs.com/package/@refkit/mcp). ## Agent plugin (Claude Code, Codex) diff --git a/packages/core/README.md b/packages/core/README.md index 0047229..ddd4bb2 100644 --- a/packages/core/README.md +++ b/packages/core/README.md @@ -74,6 +74,8 @@ await refkit.search({ }) ``` +An id in `sourceOptions` that matches no configured source is reported in `meta.warnings` (`unknown sourceOptions id(s) ignored: …`), worded like an unknown id in `sources`. + `sourceOptions` is not a raw upstream passthrough. Each provider package exports its own `*SearchOptions` interface and keeps response-format/debug/auth-only parameters out when they would conflict with normalized references or provider credentials. Currently supported unified controls: @@ -89,7 +91,7 @@ Currently supported unified controls: | `openverse`, `openverse-audio` | `license.commercial`, `license.modification`, `license.allowUnknown`, `page` | | `gutendex` | `language`, `text.copyright`, `page` | | `brave` | `safety` | -| `artic`, `ambientcg`, `europeana`, `freesound`, `internet-archive`, `jamendo`, `met`, `polyhaven`, `smithsonian`, `wikimedia-commons` | `page` | +| `artic`, `ambientcg`, `europeana`, `freesound`, `internet-archive`, `jamendo`, `met`, `polyhaven`, `polyhaven-hdri`, `smithsonian`, `wikimedia-commons` | `page` | | `nailbook`, `poetrydb`, `rijksmuseum` | none — their endpoints expose no stateless control refkit can translate | A control reaches a provider only if that provider declares it in `capabilities.controls`; everything else is reported as ignored instead of silently dropped. @@ -111,9 +113,33 @@ meta.gate // before/after/dropped counts when gateFor is used meta.threshold // { minRelevance, dropped } when minRelevance is set meta.passes // fan-out passes this call ran (> 1 only when a cursor advanced pages) meta.nextCursor // opaque "load more" token; absent = exhausted -meta.warnings // partial-result, rights-conflict and gate/threshold-drop notes +meta.warnings // unknown sources / sourceOptions ids, partial-result, rights-conflict and gate/threshold-drop notes +``` + +## Errors a host can branch on + +- **Duplicate source ids.** `createRefkit` throws when two providers serve the same source id (`createRefkit: duplicate source id(s) [polyhaven] — …`). Configure each source once; Poly Haven's textures (`polyhaven()`) and HDRIs (`polyhaven({ assetType: 'hdris' })`, id `polyhaven-hdri`) are separate sources and can sit side by side. +- **No source to search.** `search` / `searchWithMeta` throw a `SourceSelectionError` when nothing is left to fan out to. Branch on its fields, never its message: + - `code: 'no-matching-source'` — `sources` was given and none of it can serve the search: an unknown id, a known id lacking the modality or the kind, or `sources: []`. A caller mistake, to report back with the valid ids. + - `code: 'no-source-for-query'` — no `sources`, and no configured source supports the modalities (and kind) asked for. + - `requested` (the `sources` as passed; absent on an unscoped search), `unknown` (ids that match no configured source), `modalities`, and `kind` (when the search narrowed by `controls.media.kind`). + + When nothing is left but at least one source *declined* the query through its own `accepts` predicate, the search returns an empty result instead of throwing: that source judged the query outside its domain, which is not a caller mistake. + +```ts +import { isSourceSelectionError } from '@refkit/core' + +try { + await refkit.search({ query: 'lion', modalities: ['image'], sources: ['unsplsh'] }) +} catch (e) { + if (isSourceSelectionError(e) && e.code === 'no-matching-source') { + console.warn(`unknown source id(s): ${e.unknown.join(', ')}`) + } else throw e +} ``` +`isSourceSelectionError(e)` is true for an instance of this copy's class, and also for one thrown by another copy of `@refkit/core` (a client built against a different install, or core bundled twice), where `instanceof` fails: it then checks `name === 'SourceSelectionError'`, a `code` from `SOURCE_SELECTION_ERROR_CODES`, and the `unknown` and `modalities` arrays. `SourceSelectionErrorCode` is the type of `code`. + ## Ranking & rerank Results are fused across sources with **Reciprocal Rank Fusion**, weighted per source by how well that source's batch matched the query, and then reranked for the query. Both are defaults: @@ -179,10 +205,45 @@ export const example = () => defineProvider({ }) ``` -Core validates each item against `emittedReferenceSchema` at the boundary, then `completeReference` stamps the fields that are core's concern — `id` (content-addressed from the source id + `sourceUrl`), `source` (`{ id, url }`), `canonicalUrl` (defaults to `sourceUrl`), `verifiedAt`, and a `relevance` of zero until the merge scores it — and truncates to the requested `limit`. A provider never computes an id, never writes provenance or a score, and never post-truncates. A single malformed item is reported and dropped (`meta.sources[].rejected`), not fatal. +Core validates each item against `emittedReferenceSchema` at the boundary, then `completeReference` stamps the fields that are core's concern — `id` (content-addressed from the source id + `sourceUrl`), `source` (`{ id, url }`), `canonicalUrl` (defaults to `sourceUrl`), `verifiedAt`, and a `relevance` of zero until the merge scores it — and truncates to the requested `limit`. A provider never computes an id, never writes provenance or a score, and never post-truncates. A single malformed item — or one whose `modality` is outside what this source was asked for (the requested modalities it declares) — is dropped, counted in `meta.sources[].rejected` and passed to `onSourceError`; it is not fatal to the batch. (`runProviderSearch`, which this builds on, is exported and applies the same rule to the `modalities` of the query it is given.) `LicenseId` is an **open** string. Known ids carry a row in `LICENSE_FACTS`; anything else resolves to the `unknown` row, which grants nothing. A source whose terms are narrower than the label it declares ships its own `rights.facts` row, and every consumer — the use-gate, the merge's conflict resolution, attribution, the reranker's license boost — reads facts via `factsOf(rights)`, never the id string. +## Reference variants + +`EmittedReference` and `Reference` are unions keyed by `modality`; each variant is exported (`EmittedImageReference` … `EmittedTextReference`, `ImageReference` … `TextReference`). Every variant carries the common fields — `modality`, `kind?`, `title?`, `description?`, `tags?`, `rights`, `thumbnail?`, `sourceScore?`, `raw?` — plus `sourceUrl` and `canonicalUrl?` when emitted, or the core-stamped `id`, `source` (`{ id, url }`), `canonicalUrl`, `verifiedAt` and `relevance` when complete. `thumbnail` is always an image a UI can show: the picture itself, a video poster, audio cover art or a book cover, never a landing page, JSON or an audio file. The other fields belong to one modality: + +| `modality` | Its own fields | +|---|---| +| `image` | `preview?` (`mediaType` `image/*`), `visual?`, `perceptualHash?` | +| `video` | `preview?` (`mediaType` `video/*`), `visual?`, `video?: VideoMeta` | +| `audio` | `preview?` (`mediaType` `audio/*`), `audio?: AudioMeta` | +| `text` | `text?: TextMeta` (`excerpt`, `excerptKind`, `locator?`) | + +- **`visual`** (`VisualMeta`: `width`, `height` in pixels, `dominantColors?`) is the size of the original image or video. A provider that only knows a rendition's size leaves `visual` unset and puts that size on `preview.width` / `preview.height`. +- **`VideoMeta`**: `durationSec?` (seconds, a float) and `fps?`. +- **`AudioMeta`**: `durationSec?` (seconds, a float), `bitrateKbps?` (kilobits per second, bits/s ÷ 1000), `sampleRateHz?` (hertz), `channels?`, and `format?` (a short container/codec name such as `mp3` or `flac`). + +The bundled sources fill the audio and video metadata from what their search responses already carry (no extra requests): + +| Source | Fills | +|---|---| +| `openverse-audio` | `audio.durationSec`, `audio.bitrateKbps` and `audio.sampleRateHz` when Openverse reports them; `audio.format` when the filetype is a known audio format | +| `freesound`, `jamendo` | `audio.durationSec` | +| `pexels-video` | `video.durationSec`, and `visual` (the source video's size) | +| `pixabay-video` | `video.durationSec`; only a rendition's size is known, so it stays on `preview` | + +A missing, non-finite, zero or negative upstream duration, bitrate or sample rate leaves that field unset. No bundled source fills `fps` or `channels` yet. + +Each variant declares the other variants' fields as `?: never`: a provider that writes, say, `visual` on an audio item gets a compile error, while code reading the union still sees `r.visual` as `VisualMeta | undefined` without narrowing first. At runtime `emittedReferenceSchema` (a discriminated union on `modality`) rejects an item whose `preview.mediaType` lacks its variant's prefix and strips any field that belongs to another modality. + +`Reference` is a type alias of a union, so it cannot be augmented by declaration merging, an interface cannot `extend` it, and `Omit` loses the link between `modality` and its fields. Use a distributive omit: + +```ts +type DistributiveOmit = T extends unknown ? Omit : never +type Tile = DistributiveOmit +``` + ## Dedupe hooks Core dedupes exact canonical URLs by default and can dedupe equal-length perceptual hashes when `merge.hashThreshold` is set. Hosts that compute their own fingerprints or embeddings can add a sync duplicate predicate: diff --git a/packages/core/src/__tests__/select.test.ts b/packages/core/src/__tests__/select.test.ts index 7b50568..1fd212c 100644 --- a/packages/core/src/__tests__/select.test.ts +++ b/packages/core/src/__tests__/select.test.ts @@ -1,6 +1,10 @@ -import { describe, expect, it } from 'vitest' +import { describe, expect, expectTypeOf, it } from 'vitest' import { defineProvider } from '../provider' import { selectProviders, SourceSelectionError } from '../select' +import { + isSourceSelectionError, SOURCE_SELECTION_ERROR_CODES, + SourceSelectionError as ExportedSourceSelectionError, type SourceSelectionErrorCode, +} from '../index' const p = (id: string, modalities: Array<'image' | 'text'>, kinds?: string[]) => defineProvider({ id, modalities, ...(kinds ? { kinds } : {}), search: async () => [] }) @@ -105,6 +109,73 @@ describe('selectProviders', () => { }) }) + describe('isSourceSelectionError (exported from the package)', () => { + // What "another copy of @refkit/core" throws: the same fields on an Error + // whose class is not this copy's, so `instanceof` cannot see it. + const lookAlike = (fields: Record = {}) => Object.assign( + new Error('refkit.search: no configured source matches id(s) [zzz] for modalities [image]'), + { name: 'SourceSelectionError', code: 'no-matching-source', requested: ['zzz'], unknown: ['zzz'], modalities: ['image'], ...fields }, + ) + + it('lists the codes, and the class field is typed by them', () => { + expect(SOURCE_SELECTION_ERROR_CODES).toEqual(['no-matching-source', 'no-source-for-query']) + expectTypeOf().toEqualTypeOf<'no-matching-source' | 'no-source-for-query'>() + expectTypeOf().toEqualTypeOf() + expect(ExportedSourceSelectionError).toBe(SourceSelectionError) + }) + + it('is true for a thrown instance (the instanceof path), with either code', () => { + let thrown: unknown + try { + selectProviders(providers, { modalities: ['image'], sources: ['zzz'], text: 'x' }) + } catch (e) { + thrown = e + } + expect(isSourceSelectionError(thrown)).toBe(true) + expect(isSourceSelectionError(new SourceSelectionError({ code: 'no-source-for-query', unknown: [], modalities: ['video'] }))).toBe(true) + // instanceof alone decides for this copy's class: a host subclass that + // renames itself is still recognised. + class HostSelectionError extends SourceSelectionError { + override name = 'HostSelectionError' + } + expect(isSourceSelectionError(new HostSelectionError({ code: 'no-matching-source', unknown: [], modalities: ['image'] }))).toBe(true) + }) + + it('is true for a structural clone thrown by another copy of core, and narrows the type', () => { + const e: unknown = lookAlike() + expect(e).not.toBeInstanceOf(SourceSelectionError) + expect(isSourceSelectionError(e)).toBe(true) + expect(isSourceSelectionError(lookAlike({ code: 'no-source-for-query', requested: undefined, unknown: [] }))).toBe(true) + // A subclass in the other copy, the shape a real second copy produces. + class OtherCopySourceSelectionError extends Error { + override readonly name = 'SourceSelectionError' + readonly code = 'no-matching-source' + readonly unknown: string[] = [] + readonly modalities = ['audio'] + } + expect(isSourceSelectionError(new OtherCopySourceSelectionError('m'))).toBe(true) + if (isSourceSelectionError(e)) { + expectTypeOf(e).toEqualTypeOf() + expectTypeOf(e.code).toEqualTypeOf() + expect(e.unknown).toEqual(['zzz']) + } + }) + + it('is false for any other shape', () => { + expect(isSourceSelectionError(lookAlike({ name: 'Error' }))).toBe(false) + expect(isSourceSelectionError(lookAlike({ code: 'other' }))).toBe(false) + expect(isSourceSelectionError(lookAlike({ code: undefined }))).toBe(false) + expect(isSourceSelectionError(lookAlike({ unknown: undefined }))).toBe(false) + expect(isSourceSelectionError(lookAlike({ unknown: 'zzz' }))).toBe(false) + expect(isSourceSelectionError(lookAlike({ modalities: undefined }))).toBe(false) + expect(isSourceSelectionError(lookAlike({ modalities: 'image' }))).toBe(false) + expect(isSourceSelectionError({ name: 'SourceSelectionError' })).toBe(false) + expect(isSourceSelectionError(new Error('refkit.search: no configured source matches id(s) [zzz] for modalities [image]'))).toBe(false) + expect(isSourceSelectionError(new AggregateError([], 'all sources failed'))).toBe(false) + for (const v of [null, undefined, 'SourceSelectionError', 0, true]) expect(isSourceSelectionError(v)).toBe(false) + }) + }) + it('accepts=false skips with reason declined; an explicit sources entry bypasses it', () => { const picky = defineProvider({ id: 'picky', modalities: ['image'], accepts: ({ text }) => /nail/i.test(text), search: async () => [] }) const open = defineProvider({ id: 'open', modalities: ['image'], search: async () => [] }) diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 7c50c21..1c3c2d1 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -77,8 +77,11 @@ export { export { normalizeQuery } from './query' export { runProviderSearch, providerCacheKey, stableStringify } from './provider-run' export type { ProviderRun, ProviderRunDeps } from './provider-run' -export { selectProviders, SOURCE_SKIP_REASONS, SourceSelectionError } from './select' -export type { SourceSkipReason, ProviderSelection } from './select' +export { + selectProviders, SOURCE_SKIP_REASONS, + SourceSelectionError, isSourceSelectionError, SOURCE_SELECTION_ERROR_CODES, +} from './select' +export type { SourceSkipReason, SourceSelectionErrorCode, ProviderSelection } from './select' export { runPass } from './pipeline' export type { PassDeps, PassOutcome } from './pipeline' export { createRefkit } from './client' diff --git a/packages/core/src/select.ts b/packages/core/src/select.ts index b758bd2..62141ba 100644 --- a/packages/core/src/select.ts +++ b/packages/core/src/select.ts @@ -10,15 +10,20 @@ import type { ReferenceProvider } from './provider' export const SOURCE_SKIP_REASONS = ['unsupported-modality', 'not-selected', 'unsupported-kind', 'declined'] as const export type SourceSkipReason = (typeof SOURCE_SKIP_REASONS)[number] +/** The `code` values of a {@link SourceSelectionError}. */ +export const SOURCE_SELECTION_ERROR_CODES = ['no-matching-source', 'no-source-for-query'] as const +export type SourceSelectionErrorCode = (typeof SOURCE_SELECTION_ERROR_CODES)[number] + /** Thrown when a search has no source to fan out to. A host branches on `code` - * and the fields, never on the message: + * and the fields, never on the message, and recognises the error with + * {@link isSourceSelectionError} rather than `instanceof`: * - `'no-matching-source'`: `sources` was given and none of it can serve this * search (an unknown id, a known id lacking the modality or the kind, or * `sources: []`) — a caller mistake to report back with the valid ids; * - `'no-source-for-query'`: no `sources`, and no configured source supports * the modalities (and kind) asked for. */ export class SourceSelectionError extends Error { - readonly code: 'no-matching-source' | 'no-source-for-query' + readonly code: SourceSelectionErrorCode /** The `sources` as passed; absent when the search was unscoped. */ readonly requested?: string[] /** Ids in `sources` that match no configured source. */ @@ -48,6 +53,22 @@ export class SourceSelectionError extends Error { } } +/** True for a {@link SourceSelectionError} thrown by any copy of `@refkit/core`. + * `instanceof` only recognises this copy's class, and a host can load two (a + * client built with its own core handed to `@refkit/mcp`, or core bundled + * twice). So past `instanceof` this reads the stable fields — `name`, a known + * `code`, and the `unknown` and `modalities` arrays — and never the message. */ +export function isSourceSelectionError(e: unknown): e is SourceSelectionError { + if (e instanceof SourceSelectionError) return true + if (typeof e !== 'object' || e === null) return false + const x = e as Record + return x.name === 'SourceSelectionError' + && typeof x.code === 'string' + && (SOURCE_SELECTION_ERROR_CODES as readonly string[]).includes(x.code) + && Array.isArray(x.unknown) + && Array.isArray(x.modalities) +} + export interface ProviderSelection { chosen: ReferenceProvider[] /** Why each excluded provider sat this search out. */ diff --git a/packages/mcp/README.md b/packages/mcp/README.md index 9292b1f..fa0aa5c 100644 --- a/packages/mcp/README.md +++ b/packages/mcp/README.md @@ -48,7 +48,7 @@ await serveStdio(createRefkit({ Input: `{ query, modalities?, sources?, controls?, sourceOptions?, limit?, cursor?, rerank?, minRelevance?, deadlineMs?, intent?, gateFor?, gateContext?, explain? }`. - `controls` — provider-neutral search controls such as `{ orientation, color, language, sort, safety, license, media, page }`; providers translate supported controls and report ignored controls when `explain: true`. -- `sources` — restrict the search to specific source ids (the ids are listed in the tool description under "Configured sources"). Naming a source also **bypasses** that source's own query-acceptance predicate, so a narrow source like Nailbook — which declines non-nail queries on an unscoped search and then appears as `skipped` with `reason: 'declined'` — still answers when asked for by name. +- `sources` — restrict the search to specific source ids (the ids are listed in the tool description under "Configured sources"). Naming a source also **bypasses** that source's own query-acceptance predicate, so a narrow source like Nailbook — which declines non-nail queries on an unscoped search and then appears as `skipped` with `reason: 'declined'` — still answers when asked for by name. A `sources` list that no configured source can serve for the requested modalities (or kind) — including `sources: []` — returns a tool error that ends with the enabled source ids, so the agent can retry with a valid one. - `rerank` — **default true**: results are re-ranked for the query (term coverage incl. CJK over title/description/tags/excerpt, the fused cross-source relevance, resolution, source and near-duplicate diversity). Pass `false` for raw cross-source rank fusion order. `true` cannot re-enable reranking when the host built the client with `rerank: false`. - `minRelevance` — drop results the ranker scored below this (0…1), reported as `meta.threshold { minRelevance, dropped }`. The bar is graded against the reranker's *blended* score: under the stock weights a result matching no query term still lands near `0.3` and a full match near `0.95`, so `0.5` is the practical "real matches only" line. It does **not** transfer to `rerank: false`, where max-normalised fusion puts the top result at exactly `1`. - `deadlineMs` — bound the whole call (cursor page advances included). Sources still in flight are reported as `failed` and the rest are returned. It is a hard bound while the host leaves per-source resilience on (the default); with resilience disabled it binds only sources that honour the abort signal. @@ -57,7 +57,7 @@ Input: `{ query, modalities?, sources?, controls?, sourceOptions?, limit?, curso - `gateContext` — `{ userJurisdiction }` for that gate, matching `evaluate_use`'s jurisdiction input: a source-declared jurisdiction that differs from the caller's defaults to `needs-review`, which `gateFor` then drops. - `cursor` — the previous result's top-level `nextCursor` (always returned, no `explain` needed); fetches the next batch, deduped against earlier ones. - `explain` — include per-source status (incl. `confidence`, `latencyMs`, skip `reason`), applied and ignored unified controls, warnings, and gate/threshold-drop metadata. -- `sourceOptions` — typed provider-specific whitelisted controls keyed by source id, for example: +- `sourceOptions` — typed provider-specific whitelisted controls keyed by source id (with `explain: true`, an id that matches no configured source is reported in `meta.warnings`), for example: ```json { @@ -72,7 +72,7 @@ Input: `{ query, modalities?, sources?, controls?, sourceOptions?, limit?, curso } ``` -Output: `{ references: [{ id, title?, description?, tags?, modality, kind?, source, canonicalUrl, license, author?, rehostPolicy, relevance, thumbnail?, excerpt?, useVerdict?, useExplanation?, attribution? }], nextCursor?, meta? }`. `description` is capped at 280 characters and `tags` at the first 8, to keep agent context small. `relevance` is the 0..1 cross-source score after fusion and rerank; the per-source `sourceScore` is left out because it is not comparable across sources. `rehostPolicy` tells the agent what it may do with the media before storing anything: `hotlink-required` (link only), `cache-allowed`, `thumbnail-only` or `no-store`. When `intent` (or `gateFor`) is set, each result carries `useVerdict { decision, reason, confidence }`, a plain `useExplanation`, and — if the license requires it — a ready-to-use `attribution` credit line. When `explain: true`, `meta` includes per-source `fulfilled` / `failed` / `skipped` status (`meta.sources`), applied/ignored control details, warnings, and gate/threshold-drop counts. The `meta` shape comes from core's own `searchMetaSchema` — this server keeps no copy of the vocabulary. +Output: `{ references: [{ id, title?, description?, tags?, modality, kind?, source, canonicalUrl, license, author?, rehostPolicy, relevance, thumbnail?, excerpt?, durationSec?, width?, height?, useVerdict?, useExplanation?, attribution? }], nextCursor?, meta? }`. `source` is the id of the source the result came from — the ids `sources` and `sourceOptions` take. `durationSec` is an audio or video result's length in seconds, when the source reports it. `width` and `height` are in pixels: the original's size when the source reports it, else the size of the preview rendition (same aspect ratio). `description` is capped at 280 characters and `tags` at the first 8, to keep agent context small. `relevance` is the 0..1 cross-source score after fusion and rerank; the per-source `sourceScore` is left out because it is not comparable across sources. `rehostPolicy` tells the agent what it may do with the media before storing anything: `hotlink-required` (link only), `cache-allowed`, `thumbnail-only` or `no-store`. When `intent` (or `gateFor`) is set, each result carries `useVerdict { decision, reason, confidence }`, a plain `useExplanation`, and — if the license requires it — a ready-to-use `attribution` credit line. When `explain: true`, `meta` includes per-source `fulfilled` / `failed` / `skipped` status (`meta.sources`), applied/ignored control details, warnings, and gate/threshold-drop counts. The `meta` shape comes from core's own `searchMetaSchema` — this server keeps no copy of the vocabulary. > Results are references with a license id + source link — **not rights clearance, not legal advice**. `unknown` / `needs-review` results require the caller to verify the source's terms. diff --git a/packages/mcp/src/__tests__/mcp.test.ts b/packages/mcp/src/__tests__/mcp.test.ts index 188fbbc..37fe92a 100644 --- a/packages/mcp/src/__tests__/mcp.test.ts +++ b/packages/mcp/src/__tests__/mcp.test.ts @@ -1,7 +1,7 @@ import { describe, expect, it, vi } from 'vitest' import { Client } from '@modelcontextprotocol/sdk/client/index.js' import { InMemoryTransport } from '@modelcontextprotocol/sdk/inMemory.js' -import { createRefkit, defineProvider, SOURCE_SKIP_REASONS, SEARCH_CONTROL_KEYS } from '@refkit/core' +import { createRefkit, defineProvider, SOURCE_SKIP_REASONS, SEARCH_CONTROL_KEYS, type RefkitClient } from '@refkit/core' import { openverse } from '@refkit/provider-openverse' import { readFileSync } from 'node:fs' import { createRefkitMcpServer } from '../index' @@ -259,6 +259,52 @@ describe('@refkit/mcp', () => { await client.close() }) + it('does not dress up a no-source-for-query miss (no sources given) with the configured ids', async () => { + // Every image source declares a kind other than the one asked for, and no + // `sources` was passed: core throws code 'no-source-for-query'. + const tex = defineProvider({ id: 'tex', modalities: ['image'], kinds: ['texture'], search: async () => [] }) + const server = createRefkitMcpServer(createRefkit({ providers: [tex] })) + const [clientT, serverT] = InMemoryTransport.createLinkedPair() + const client = new Client({ name: 'test', version: '1.0.0' }) + await Promise.all([client.connect(clientT), server.connect(serverT)]) + const res = await client.callTool({ name: 'search_references', arguments: { query: 'x', modalities: ['image'], controls: { media: { kind: 'photo' } } } }) + expect(res.isError).toBe(true) + const text = (res.content as Array<{ type: string; text: string }>).map(c => c.text).join('\n') + expect(text).toContain('no configured source supports modalities [image] with kind "photo"') + expect(text).not.toContain('Enabled source ids') + await client.close() + }) + + it('recognises a selection miss thrown by another copy of @refkit/core (structurally, not by instanceof)', async () => { + // A host can hand this server a client built with its own copy of core; that + // copy's SourceSelectionError is a different class with the same fields. + const lookAlike = (code: string) => Object.assign( + new Error(code === 'no-matching-source' + ? 'refkit.search: no configured source matches id(s) [nope] for modalities [image]' + : 'refkit.search: no configured source supports modalities [image]'), + { name: 'SourceSelectionError', code, ...(code === 'no-matching-source' ? { requested: ['nope'], unknown: ['nope'] } : { unknown: [] }), modalities: ['image'] }, + ) + const callWith = async (thrown: Error) => { + const real = createRefkit({ providers: [openverse()], fetch: (async () => new Response('{}')) as typeof fetch }) + const otherCopy: RefkitClient = { ...real, searchWithMeta: async () => { throw thrown } } + const server = createRefkitMcpServer(otherCopy) + const [clientT, serverT] = InMemoryTransport.createLinkedPair() + const client = new Client({ name: 'test', version: '1.0.0' }) + await Promise.all([client.connect(clientT), server.connect(serverT)]) + const res = await client.callTool({ name: 'search_references', arguments: { query: 'x', modalities: ['image'], sources: ['nope'] } }) + await client.close() + return { isError: res.isError, text: (res.content as Array<{ type: string; text: string }>).map(c => c.text).join('\n') } + } + const miss = await callWith(lookAlike('no-matching-source')) + expect(miss.isError).toBe(true) + expect(miss.text).toContain('[nope]') + expect(miss.text).toContain('Enabled source ids: openverse.') + // The other code from the other copy still gets no hint. + const none = await callWith(lookAlike('no-source-for-query')) + expect(none.isError).toBe(true) + expect(none.text).not.toContain('Enabled source ids') + }) + it('does not dress up other errors raised while sources is set as a source miss', async () => { const client = await connectedClient() // openverse-only server // `sources` resolves; the cursor is what is wrong. @@ -320,6 +366,71 @@ describe('@refkit/mcp', () => { await client.close() }) + it('projects durationSec from audio/video and width/height from visual, else from the preview', async () => { + const rights = (url: string) => ({ license: 'CC0-1.0', rehostPolicy: 'cache-allowed' as const, raw: { sourceTerms: 'terms', sourceUrl: url } }) + const provider = defineProvider({ + id: 'p', + modalities: ['image', 'video', 'audio', 'text'], + search: async () => [ + { modality: 'audio' as const, title: 'rain audio', sourceUrl: 'https://p/a', rights: rights('https://p/a'), + preview: { url: 'https://p/a.mp3', mediaType: 'audio/mpeg' }, audio: { durationSec: 60.116, bitrateKbps: 128 } }, + // The original's size is known: it wins over the rendition's. + { modality: 'video' as const, title: 'rain video original', sourceUrl: 'https://p/v1', rights: rights('https://p/v1'), + preview: { url: 'https://p/v1.mp4', mediaType: 'video/mp4', width: 640, height: 360 }, + visual: { width: 1920, height: 1080 }, video: { durationSec: 12 } }, + // Only a rendition's size (pixabay-video): the preview supplies it. + { modality: 'video' as const, title: 'rain video rendition', sourceUrl: 'https://p/v2', rights: rights('https://p/v2'), + preview: { url: 'https://p/v2.mp4', mediaType: 'video/mp4', width: 960, height: 540 }, video: { durationSec: 13 } }, + { modality: 'image' as const, title: 'rain image original', sourceUrl: 'https://p/i1', rights: rights('https://p/i1'), + visual: { width: 4000, height: 3000 } }, + // Only a rendition's size (Flickr): the preview supplies it. + { modality: 'image' as const, title: 'rain image rendition', sourceUrl: 'https://p/i2', rights: rights('https://p/i2'), + preview: { url: 'https://p/i2.jpg', mediaType: 'image/jpeg', width: 1024, height: 683 } }, + { modality: 'image' as const, title: 'rain image bare', sourceUrl: 'https://p/i3', rights: rights('https://p/i3') }, + { modality: 'text' as const, title: 'rain text', sourceUrl: 'https://p/t', rights: rights('https://p/t'), + text: { excerpt: 'Rain fell.', excerptKind: 'passage' as const } }, + ], + }) + const server = createRefkitMcpServer(createRefkit({ providers: [provider], resilience: false })) + const [clientT, serverT] = InMemoryTransport.createLinkedPair() + const client = new Client({ name: 'test', version: '1.0.0' }) + await Promise.all([client.connect(clientT), server.connect(serverT)]) + const res = await client.callTool({ name: 'search_references', arguments: { query: 'rain', modalities: ['image', 'video', 'audio', 'text'] } }) + expect(res.isError).toBeFalsy() + const refs = (res.structuredContent as { references: Array> }).references + const pick = (title: string) => { + const r = refs.find(x => x.title === title)! + return { durationSec: r.durationSec, width: r.width, height: r.height } + } + expect(pick('rain audio')).toEqual({ durationSec: 60.116, width: undefined, height: undefined }) + expect(pick('rain video original')).toEqual({ durationSec: 12, width: 1920, height: 1080 }) + expect(pick('rain video rendition')).toEqual({ durationSec: 13, width: 960, height: 540 }) + expect(pick('rain image original')).toEqual({ durationSec: undefined, width: 4000, height: 3000 }) + expect(pick('rain image rendition')).toEqual({ durationSec: undefined, width: 1024, height: 683 }) + // (The in-memory transport keeps undefined-valued keys; JSON drops them.) + expect(pick('rain image bare')).toEqual({ durationSec: undefined, width: undefined, height: undefined }) + expect(pick('rain text')).toEqual({ durationSec: undefined, width: undefined, height: undefined }) + await client.close() + }) + + it('describes durationSec, width and height in the output schema, all optional', async () => { + const client = await connectedClient() + const { tools } = await client.listTools() + const ref = (tools.find(t => t.name === 'search_references')!.outputSchema as Record).properties.references.items + for (const key of ['durationSec', 'width', 'height']) { + expect(ref.properties[key].type).toBe('number') + expect(ref.required).not.toContain(key) + } + expect(ref.properties.durationSec.description).toMatch(/seconds/) + expect(ref.properties.durationSec.description).toMatch(/audio/) + expect(ref.properties.durationSec.description).toMatch(/video/) + for (const key of ['width', 'height']) { + expect(ref.properties[key].description).toMatch(/pixels/) + expect(ref.properties[key].description).toMatch(/preview/) + } + await client.close() + }) + it('tells agents what rehostPolicy means, in the tool description and the output schema', async () => { const client = await connectedClient() const { tools } = await client.listTools() diff --git a/packages/mcp/src/index.ts b/packages/mcp/src/index.ts index 7216a50..e9221e3 100644 --- a/packages/mcp/src/index.ts +++ b/packages/mcp/src/index.ts @@ -2,7 +2,7 @@ import { readFileSync } from 'node:fs' import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js' import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js' import { z } from 'zod' -import { LICENSE_IDS, INTENTS, REHOST_POLICIES, SourceSelectionError, evaluateUse, buildAttribution, ccVersionFor, buildSearchControlsSchema, searchMetaSchema } from '@refkit/core' +import { LICENSE_IDS, INTENTS, REHOST_POLICIES, isSourceSelectionError, evaluateUse, buildAttribution, ccVersionFor, buildSearchControlsSchema, searchMetaSchema } from '@refkit/core' import type { RefkitClient, Reference, Verdict, Attribution, SearchControls, SearchInput, SourceOptionsById, RightsRecord, Modality } from '@refkit/core' // Legacy media.kind control values — kept in the dynamic enum because they stay @@ -38,7 +38,11 @@ function capText(text: string | undefined, max: number): string | undefined { // sees *whether it may use* each result — not just a bare license id it ignores. // `sourceScore` stays out on purpose: it is in each source's own scale and only orders // results within one source; `relevance` is the cross-source score an agent can compare. +// It stays a flat projection of the Reference union: `durationSec` from the audio or +// video metadata, and `width`/`height` from `visual` (the original's size), else from +// the preview rendition's size (its aspect ratio is the original's). function toAgentRef(r: Reference, assessment?: { verdict: Verdict; attribution: Attribution }) { + const size = r.visual ?? r.preview const base = { id: r.id, title: r.title, @@ -54,6 +58,9 @@ function toAgentRef(r: Reference, assessment?: { verdict: Verdict; attribution: relevance: Math.round(r.relevance * 1000) / 1000, thumbnail: r.thumbnail?.url, excerpt: r.text?.excerpt, + durationSec: r.audio?.durationSec ?? r.video?.durationSec, + width: size?.width, + height: size?.height, } if (!assessment) return base const { verdict, attribution } = assessment @@ -86,6 +93,9 @@ const agentRefSchema = z.object({ relevance: z.number().describe('0..1 cross-source relevance after fusion/rerank; compare results by this'), thumbnail: z.string().optional(), excerpt: z.string().optional(), + durationSec: z.number().optional().describe('length in seconds (audio and video), when the source reports it'), + width: z.number().optional().describe('width in pixels: the original\'s when the source reports it, else the preview rendition\'s (same aspect ratio)'), + height: z.number().optional().describe('height in pixels: the original\'s when the source reports it, else the preview rendition\'s (same aspect ratio)'), useVerdict: z .object({ decision: z.string(), reason: z.string(), confidence: z.string() }) .optional() @@ -181,9 +191,11 @@ export function createRefkitMcpServer(refkit: RefkitClient): McpServer { // A source-selection miss (the requested ids resolve to no configured // source for the modality or kind) is a caller mistake, not an outage — // turn core's typed throw into an agent-actionable tool error listing the - // valid ids. Everything else (every source failing, an invalid cursor) - // propagates as it is. - if (err instanceof SourceSelectionError && err.code === 'no-matching-source') { + // valid ids. Recognised structurally, so a client built with another copy + // of @refkit/core still gets the hint. Everything else (every source + // failing, an invalid cursor, a search no source supports) propagates as + // it is. + if (isSourceSelectionError(err) && err.code === 'no-matching-source') { const available = enabledSourceIds.join(', ') || '(none)' return { isError: true, diff --git a/packages/provider-flickr/README.md b/packages/provider-flickr/README.md index 237395d..63582bf 100644 --- a/packages/provider-flickr/README.md +++ b/packages/provider-flickr/README.md @@ -18,4 +18,6 @@ const refs = await refkit.search({ query: 'lion', modalities: ['image'] }) // every result carries source + license + attribution + canonicalUrl ``` +Each result's `preview` is a Flickr rendition up to 1024px (`url_l`, else `url_m`) with that rendition's `width` / `height`. `visual` is left unset: it means the original's size, which the search response does not carry. + Gate by intended use with `refkit.evaluateUse(ref, 'commercial-product')`. See [`@refkit/core`](https://www.npmjs.com/package/@refkit/core) for the full API. diff --git a/packages/provider-freesound/README.md b/packages/provider-freesound/README.md index bbcd645..e6dbf96 100644 --- a/packages/provider-freesound/README.md +++ b/packages/provider-freesound/README.md @@ -17,4 +17,6 @@ const refkit = createRefkit({ providers: [freesound(/* config */)] }) const refs = await refkit.search({ query: 'cat', modalities: ['audio'] }) ``` +Each result carries `audio.durationSec` (seconds, a float, as Freesound reports it) when the duration is known and positive. + Gate by intended use with `refkit.evaluateUse(ref, 'commercial-product')`. See [`@refkit/core`](https://www.npmjs.com/package/@refkit/core) for the full API. diff --git a/packages/provider-jamendo/README.md b/packages/provider-jamendo/README.md index c9365a7..1c11bf4 100644 --- a/packages/provider-jamendo/README.md +++ b/packages/provider-jamendo/README.md @@ -17,4 +17,6 @@ const refkit = createRefkit({ providers: [jamendo(/* config */)] }) const refs = await refkit.search({ query: 'cat', modalities: ['audio'] }) ``` +Each result carries `audio.durationSec` (seconds) when Jamendo reports a positive duration, and the album art, when present, as its `thumbnail`. + Gate by intended use with `refkit.evaluateUse(ref, 'commercial-product')`. See [`@refkit/core`](https://www.npmjs.com/package/@refkit/core) for the full API. diff --git a/packages/provider-openverse/README.md b/packages/provider-openverse/README.md index a173445..d35b491 100644 --- a/packages/provider-openverse/README.md +++ b/packages/provider-openverse/README.md @@ -19,4 +19,13 @@ const audio = await refkit.search({ query: 'rain', modalities: ['audio'] }) // every result carries source + license + attribution + canonicalUrl ``` -`openverse()` serves the image leg; add `openverseAudio()` for audio. Gate by intended use with `refkit.evaluateUse(ref, 'commercial-product')`. See [`@refkit/core`](https://www.npmjs.com/package/@refkit/core) for the full API. +`openverse()` serves the image leg (source id `openverse`); add `openverseAudio()` for audio (source id `openverse-audio`). + +### What an audio result carries + +- `thumbnail` is the item's album art, and is unset when Openverse has none. It is never the waveform, which is JSON peak data. +- `kind` is `music` or `sound-effect` when Openverse's `category` says so (`music`, `sound_effect`); other categories (`pronunciation`, `podcast`, `audiobook`, `news`) and a missing one leave it unset. In live results (2026-10) only Jamendo-sourced items carried a mapped category (`music`); Freesound-sourced items carry none, so they have no `kind`. +- `audio.durationSec` (from milliseconds), `audio.bitrateKbps` (from bits per second) and `audio.sampleRateHz`, when Openverse reports them. A missing, non-finite, zero or negative value leaves the field unset (Openverse reports a duration of 0 for MIDI files it never measured). +- `audio.format` and `preview.mediaType` come from one table of known filetypes: `mp3`, `ogg`, `oga`, `opus`, `wav`, `flac`, `mid` / `midi` (`audio/midi`) and `webm` (`audio/webm`). Jamendo's `mp31` / `mp32` read as `mp3`, Wikimedia's `ogx` as `ogg`. An item whose filetype is outside the table has neither `audio.format` nor a `preview`, rather than a guessed media type. + +Gate by intended use with `refkit.evaluateUse(ref, 'commercial-product')`. See [`@refkit/core`](https://www.npmjs.com/package/@refkit/core) for the full API. diff --git a/packages/provider-pexels/README.md b/packages/provider-pexels/README.md index fb295c4..c39ed9f 100644 --- a/packages/provider-pexels/README.md +++ b/packages/provider-pexels/README.md @@ -20,4 +20,6 @@ const clips = await refkit.search({ query: 'forest', modalities: ['video'] }) // every result carries source + license + attribution + canonicalUrl ``` -`pexels()` serves the image leg; add `pexelsVideo()` (same key) for video. Gate by intended use with `refkit.evaluateUse(ref, 'commercial-product')`. See [`@refkit/core`](https://www.npmjs.com/package/@refkit/core) for the full API. +`pexels()` serves the image leg (source id `pexels`); add `pexelsVideo()` (same key) for video (source id `pexels-video`). A video result carries `video.durationSec` (seconds, when positive) and `visual`, the source video's size; its `preview` is an HD rendition when one exists. + +Gate by intended use with `refkit.evaluateUse(ref, 'commercial-product')`. See [`@refkit/core`](https://www.npmjs.com/package/@refkit/core) for the full API. diff --git a/packages/provider-pixabay/README.md b/packages/provider-pixabay/README.md index 6d08fac..6c2e387 100644 --- a/packages/provider-pixabay/README.md +++ b/packages/provider-pixabay/README.md @@ -20,4 +20,8 @@ const clips = await refkit.search({ query: 'forest', modalities: ['video'] }) // every result carries source + license + attribution + canonicalUrl ``` -`pixabay()` serves the image leg; add `pixabayVideo()` (same key) for video. Gate by intended use with `refkit.evaluateUse(ref, 'commercial-product')`. See [`@refkit/core`](https://www.npmjs.com/package/@refkit/core) for the full API. +`pixabay()` serves the image leg (source id `pixabay`); add `pixabayVideo()` (same key) for video (source id `pixabay-video`). + +A video result carries `video.durationSec` (seconds, when positive) and `kind` from Pixabay's `type` (`film` or `animation`). Its `preview` is the first usable rendition of large → medium → small → tiny (one with a non-empty URL and a non-zero size), with that rendition's `width` / `height`; `visual` is left unset, because it means the original's size, which the response does not give. A hit with no usable rendition is skipped. + +Gate by intended use with `refkit.evaluateUse(ref, 'commercial-product')`. See [`@refkit/core`](https://www.npmjs.com/package/@refkit/core) for the full API. From e6a86d1f5a63a6bf6dc006d0065ea5ad88ee8003 Mon Sep 17 00:00:00 2001 From: lizhixuan Date: Tue, 13 Oct 2026 00:45:53 +0800 Subject: [PATCH 10/12] docs(spec): isSourceSelectionError guard; MCP rethrown errors still surface as isError --- .../2026-10-12-modality-union-and-source-naming-design.md | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/docs/superpowers/specs/2026-10-12-modality-union-and-source-naming-design.md b/docs/superpowers/specs/2026-10-12-modality-union-and-source-naming-design.md index 295a54e..b4f4d0a 100644 --- a/docs/superpowers/specs/2026-10-12-modality-union-and-source-naming-design.md +++ b/docs/superpowers/specs/2026-10-12-modality-union-and-source-naming-design.md @@ -112,7 +112,7 @@ openverse-audio's `preview.mediaType` comes from the same `AUDIO_MIME` table as 3. **Duplicate source ids pass silently.** - `createRefkit` throws when two providers share an id. - `polyhaven({ assetType: 'hdris' })` gets the id `polyhaven-hdri`, so textures and HDRIs can coexist. This is breaking for HDRI users (reference ids, cache keys, `sources`/`sourceOptions` keys change) and the changeset says so. -4. **Typed selection error.** Export `SourceSelectionError` for both selection throws: `code: 'no-matching-source'` when `sources` was given and nothing was chosen (unknown id, known id lacking the modality or kind, or `sources: []`), `'no-source-for-query'` otherwise; fields `requested?: string[]` (the `sources` as passed), `unknown: string[]` (ids that match no configured source), `modalities: Modality[]`, `kind?: string`. The dsh plugin stops string-matching core's message, which this release changes anyway. +4. **Typed selection error.** Export `SourceSelectionError` for both selection throws: `code: 'no-matching-source'` when `sources` was given and nothing was chosen (unknown id, known id lacking the modality or kind, or `sources: []`), `'no-source-for-query'` otherwise; fields `requested?: string[]` (the `sources` as passed), `unknown: string[]` (ids that match no configured source), `modalities: Modality[]`, `kind?: string`. Core also exports a structural guard, `isSourceSelectionError(e)` (true on `instanceof`, else on `name` + a known `code` + array `unknown`/`modalities`), with `SOURCE_SELECTION_ERROR_CODES` and `SourceSelectionErrorCode`, because a host can load two copies of core and `instanceof` then fails. The dsh plugin stops string-matching core's message, which this release changes anyway. 5. **Declared kinds that are never emitted.** - pixabay-video: `kind` from the upstream `type` (`film` / `animation`). - openverse-audio: `kind` from the upstream `category` when it maps to `music` or `sound-effect`. The implementer verifies the field and its values live (keyless). Expect only `music` (Jamendo-sourced items); Freesound-sourced items carry `category: null`. @@ -124,7 +124,7 @@ openverse-audio's `preview.mediaType` comes from the same `AUDIO_MIME` table as - Renames per D4: `sourceOptions` input, agent ref `source`. No alias for `providerOptions` (no shims; nobody depends on it yet) — see Risks. - The agent ref gains optional `durationSec` (from `audio`/`video`) and `width`/`height` (from `visual`, else from `preview.width/height`: aspect ratio is rendition-independent), so agents can filter by length or resolution. It stays a flat projection; the output schema does not become a union. -- The "unknown sources" tool error is detected through `SourceSelectionError` with `code === 'no-matching-source'`, not a message heuristic. Other errors raised while `sources` is set (e.g. an invalid cursor) now throw instead of becoming an `isError` result. +- The "unknown sources" tool error is detected with `isSourceSelectionError(err) && err.code === 'no-matching-source'`, not a message heuristic. Other errors raised while `sources` is set (e.g. an invalid cursor) are rethrown by the handler; the MCP SDK still returns them as an `isError` result, now without the configured-ids hint. ### D8 — `@refkit/dsh-plugin` 0.3.0 (separate repo, after the refkit packages are on npm) @@ -133,7 +133,7 @@ openverse-audio's `preview.mediaType` comes from the same `AUDIO_MIME` table as - dsh already exports its own `SourceStatus` (a different shape); alias the core import. Renaming the public `PROVIDER_IDS` export is breaking for the plugin's API and goes in its changelog. - Adapt to D4: - read `meta.sources`, `sourceId` and `onSourceError`; - - detect the selection miss with `SourceSelectionError`. + - detect the selection miss with `isSourceSelectionError(err) && err.code === 'no-matching-source'`. - Naming inside the plugin: - the host's `PROVIDER_IDS` becomes `SOURCE_IDS` and `RETIRED_IDS` becomes `RETIRED_SOURCE_IDS`, matching the client mirror; - copy that says "provider ids" says "source ids" (dsh uses "provider" for LLM providers); From 8043c05ba50d8f56dbe70bb48b53b3447a160db3 Mon Sep 17 00:00:00 2001 From: lizhixuan Date: Tue, 13 Oct 2026 01:05:41 +0800 Subject: [PATCH 11/12] =?UTF-8?q?fix:=20final-review=20minors=20=E2=80=94?= =?UTF-8?q?=20guard=20requires=20a=20string=20message,=20changelog=20headi?= =?UTF-8?q?ng=20levels,=20rerank=20and=20alias=20notes?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../modality-union-and-source-naming.md | 28 +++++++++---------- README.md | 2 +- ...modality-union-and-source-naming-design.md | 1 + packages/core/src/__tests__/select.test.ts | 2 ++ packages/core/src/select.ts | 6 ++-- 5 files changed, 22 insertions(+), 17 deletions(-) diff --git a/.changeset/modality-union-and-source-naming.md b/.changeset/modality-union-and-source-naming.md index c3aea6a..9e89ef5 100644 --- a/.changeset/modality-union-and-source-naming.md +++ b/.changeset/modality-union-and-source-naming.md @@ -26,9 +26,9 @@ References now have a shape per modality, every public surface calls the catalog **Upgrade core, MCP and every provider together.** Each `@refkit/provider-*` package pins the exact `@refkit/core` version it was built with, so a host that upgrades core alone ends up with two copies of core. -## What changed and why +### What changed and why -### `Reference` is a union keyed by `modality` +#### `Reference` is a union keyed by `modality` Before, every result had one flat shape: any modality could carry `visual`, `text` or `preview`, and nothing said which fields belonged together. `EmittedReference` and `Reference` are now unions of four variants. Each variant is exported (`EmittedImageReference`, `EmittedVideoReference`, `EmittedAudioReference`, `EmittedTextReference`, and `ImageReference`, `VideoReference`, `AudioReference`, `TextReference`). @@ -45,7 +45,7 @@ Before, every result had one flat shape: any modality could carry `visual`, `tex - **Types.** Each variant declares the other variants' fields as `?: never`. A provider that writes a field of another modality, such as `visual` on an audio item, gets a compile error. Code that reads the union still compiles without narrowing: `r.visual` is `VisualMeta | undefined` on any reference. - **Runtime.** `emittedReferenceSchema` and `referenceSchema` are discriminated unions on `modality`. An item whose `preview.mediaType` does not start with its variant's prefix (`image/`, `video/`, `audio/`) is rejected. A field that belongs to another modality is stripped. -### Audio and video metadata +#### Audio and video metadata There are two new types. Units: @@ -65,13 +65,13 @@ The bundled sources fill them from the responses they already fetch. No source m - **Unset values.** A missing, non-finite, zero or negative duration, bitrate or sample rate leaves the field unset. Openverse reports a duration of 0 for MIDI files it never measured, and zero is never a real measurement for these fields. - **Not filled yet.** No bundled source fills `fps` or `channels`. Internet Archive leaves durations unset. -### One word for the catalog a search queries: "source" +#### One word for the catalog a search queries: "source" A *provider* is adapter code, such as a `ReferenceProvider` from a `@refkit/provider-*` package. Each provider instance serves one *source*. That source's id (`provider.id`, e.g. `openverse-audio`) is what `sources`, `sourceOptions`, `meta.sources` and each result's `source.id` speak. Before, the same id had several names: callers passed `sources`, but results reported `meta.providers[].providerId`, options went under `providerOptions`, errors went to `onProviderError`, and each reference named it `source.providerId`. The rename table is under "Breaking changes". -### Fixes +#### Fixes **openverse-audio** @@ -112,7 +112,7 @@ Before, the same id had several names: callers passed `sources`, but results rep - **Unknown `sourceOptions` ids.** An id in `sourceOptions` that matches no configured source now adds `unknown sourceOptions id(s) ignored: …` to `meta.warnings`, as an unknown id in `sources` already did. - **Typed selection error.** When a search has no source to fan out to, core throws a `SourceSelectionError`. A host can now branch on its fields instead of matching the message. See "Breaking changes". -### `@refkit/mcp` +#### `@refkit/mcp` - **New fields.** Each `search_references` result gains optional `durationSec`, `width` and `height`: - `durationSec` is the audio or video length in seconds; @@ -123,9 +123,9 @@ Before, the same id had several names: callers passed `sources`, but results rep - `sources: []` now gets that hint too. - So does a miss thrown by a client built with another copy of `@refkit/core`. -## Breaking changes and how to migrate +### Breaking changes and how to migrate -### Renames +#### Renames | Where | Old | New | |---|---|---| @@ -148,7 +148,7 @@ These names stay unchanged: - `SearchInput.sources` and the MCP `sources` parameter; - **`NormalizedQuery.providerOptions`**: provider code keeps reading its own options from `q.providerOptions`. -### `Reference` is a type alias of a union +#### `Reference` is a type alias of a union - **`Omit`.** `Omit` loses the link between `modality` and its fields. Use a distributive omit: @@ -159,13 +159,13 @@ These names stay unchanged: - **Declaration merging.** You can no longer augment `Reference` by declaration merging. - **Test fixtures.** A builder that spreads `Partial` or `Partial` over an image default no longer compiles. Type its parameter as `Partial` / `Partial`. -### Custom providers +#### Custom providers - **Wrong-modality fields.** Writing a field of another modality is a compile error. At runtime the field is stripped. - **Preview media type.** A `preview.mediaType` without the variant's prefix (`image/`, `video/`, `audio/`) rejects the item. - **Undeclared modalities.** An item whose `modality` was not asked of the source is rejected. That covers a modality the source does not declare, and one the search did not request. -### `SourceSelectionError` and `isSourceSelectionError` +#### `SourceSelectionError` and `isSourceSelectionError` Core still throws when a search has no source to fan out to, but the error is now typed and its messages have changed. Stop matching the message and branch on the fields: @@ -184,18 +184,18 @@ Recognise the error with `isSourceSelectionError(e)`, not `instanceof`: `SOURCE_SELECTION_ERROR_CODES` and the `SourceSelectionErrorCode` type are exported. -### `runProviderSearch` rejects modality leaks +#### `runProviderSearch` rejects modality leaks - The exported `runProviderSearch` now rejects every live item whose `modality` is not in the `modalities` of the query it is given. - A direct call with `modalities: []` therefore rejects every live item. - Through `createRefkit` this cannot happen: a source is only searched when it declares one of the requested modalities, so its query's `modalities` is never empty. -### Other breaks +#### Other breaks - **One-time cache miss.** The cache key prefix moved from `refkit:v3` to `refkit:v4`. Every entry cached by the previous version is missed once after the upgrade, instead of being parsed in the old shape. - **Duplicate source ids.** `createRefkit` now throws on duplicate source ids. Configure each source once. - **Poly Haven HDRI ids.** For HDRI users, `polyhaven({ assetType: 'hdris' })` changes its id from `polyhaven` to `polyhaven-hdri`. Reference ids, cache keys, and the `sources` and `sourceOptions` keys for HDRIs change with it. Replace `'polyhaven'` with `'polyhaven-hdri'` wherever you meant the HDRI source. -- **`visual` on Flickr and pixabay-video.** These results no longer carry `visual`. For their size or aspect ratio, fall back to `preview.width` / `preview.height`, as `@refkit/mcp` does. +- **`visual` on Flickr and pixabay-video.** These results no longer carry `visual`. For their size or aspect ratio, fall back to `preview.width` / `preview.height`, as `@refkit/mcp` does. The reranker's resolution term now treats these results as unknown (neutral) instead of scoring the rendition's size, so they can rank slightly higher in a mixed batch. - **MCP `providerOptions`.** The input is now `sourceOptions`. The MCP SDK silently strips unknown input keys, so a call that still sends `providerOptions` loses its options without an error. - **MCP agent ref.** Each reference's `provider` is now `source`. - **MCP errors with `sources` set.** Errors other than the source miss that are raised while `sources` is set (for example an invalid cursor) are now rethrown by the tool handler. Before, the handler returned its own `isError` result and appended a misleading "Enabled source ids" hint. The MCP SDK still reports a thrown error to the agent as an `isError` result with the error's message, now without the hint. diff --git a/README.md b/README.md index adad89f..b886644 100644 --- a/README.md +++ b/README.md @@ -377,7 +377,7 @@ The bundled sources fill the audio and video metadata from what their search res | Source | Fills | |---|---| -| `openverse-audio` | `audio.durationSec`, `audio.bitrateKbps` and `audio.sampleRateHz` when Openverse reports them; `audio.format` when the filetype is a known audio format (`mp3`, `ogg`, `oga`, `opus`, `wav`, `flac`, `mid`, `midi`, `webm`) | +| `openverse-audio` | `audio.durationSec`, `audio.bitrateKbps` and `audio.sampleRateHz` when Openverse reports them; `audio.format` when the filetype is a known audio format (`mp3`, `ogg`, `oga`, `opus`, `wav`, `flac`, `mid`, `midi`, `webm`; Jamendo's `mp31`/`mp32` read as `mp3` and Wikimedia's `ogx` as `ogg`) | | `freesound`, `jamendo` | `audio.durationSec` | | `pexels-video` | `video.durationSec`, and `visual` (the source video's size) | | `pixabay-video` | `video.durationSec`; only a rendition's size is known, so it stays on `preview` | diff --git a/docs/superpowers/specs/2026-10-12-modality-union-and-source-naming-design.md b/docs/superpowers/specs/2026-10-12-modality-union-and-source-naming-design.md index b4f4d0a..ddd22cb 100644 --- a/docs/superpowers/specs/2026-10-12-modality-union-and-source-naming-design.md +++ b/docs/superpowers/specs/2026-10-12-modality-union-and-source-naming-design.md @@ -141,6 +141,7 @@ openverse-audio's `preview.mediaType` comes from the same `AUDIO_MIME` table as - The tile's `provider` key becomes `source`. `narrowTile` accepts either key for one release, so cards persisted in old conversations still render. - The tile gains `durationSec`, and its `width`/`height` fall back to `preview.width/height` when `visual` is unset. Audio and video tiles show the duration as a small `m:ss` badge. - Video tiles render their poster (`thumbnail`) with a ▶ overlay instead of a bare glyph. +- Audio tiles render their `thumbnail` (album art) when present, keeping ♪ as the fallback. - The `refkit_search` tool output schema follows (additive `durationSec`; `provider` → `source`). ## Testing diff --git a/packages/core/src/__tests__/select.test.ts b/packages/core/src/__tests__/select.test.ts index 1fd212c..1de0ca0 100644 --- a/packages/core/src/__tests__/select.test.ts +++ b/packages/core/src/__tests__/select.test.ts @@ -170,6 +170,8 @@ describe('selectProviders', () => { expect(isSourceSelectionError(lookAlike({ modalities: undefined }))).toBe(false) expect(isSourceSelectionError(lookAlike({ modalities: 'image' }))).toBe(false) expect(isSourceSelectionError({ name: 'SourceSelectionError' })).toBe(false) + // every field but the message: callers interpolate e.message, so it must be a string + expect(isSourceSelectionError({ name: 'SourceSelectionError', code: 'no-matching-source', unknown: [], modalities: [] })).toBe(false) expect(isSourceSelectionError(new Error('refkit.search: no configured source matches id(s) [zzz] for modalities [image]'))).toBe(false) expect(isSourceSelectionError(new AggregateError([], 'all sources failed'))).toBe(false) for (const v of [null, undefined, 'SourceSelectionError', 0, true]) expect(isSourceSelectionError(v)).toBe(false) diff --git a/packages/core/src/select.ts b/packages/core/src/select.ts index 62141ba..fd712dd 100644 --- a/packages/core/src/select.ts +++ b/packages/core/src/select.ts @@ -56,13 +56,15 @@ export class SourceSelectionError extends Error { /** True for a {@link SourceSelectionError} thrown by any copy of `@refkit/core`. * `instanceof` only recognises this copy's class, and a host can load two (a * client built with its own core handed to `@refkit/mcp`, or core bundled - * twice). So past `instanceof` this reads the stable fields — `name`, a known - * `code`, and the `unknown` and `modalities` arrays — and never the message. */ + * twice). So past `instanceof` this reads the stable fields — `name`, a string + * `message` (callers interpolate it), a known `code`, and the `unknown` and + * `modalities` arrays — and never parses the message. */ export function isSourceSelectionError(e: unknown): e is SourceSelectionError { if (e instanceof SourceSelectionError) return true if (typeof e !== 'object' || e === null) return false const x = e as Record return x.name === 'SourceSelectionError' + && typeof x.message === 'string' && typeof x.code === 'string' && (SOURCE_SELECTION_ERROR_CODES as readonly string[]).includes(x.code) && Array.isArray(x.unknown) From cf90b4b1589333e067c84dec049e5bb955241613 Mon Sep 17 00:00:00 2001 From: lizhixuan Date: Tue, 13 Oct 2026 01:06:51 +0800 Subject: [PATCH 12/12] docs(plugin): find-references names the agent ref's source field --- plugins/refkit/skills/find-references/SKILL.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/plugins/refkit/skills/find-references/SKILL.md b/plugins/refkit/skills/find-references/SKILL.md index 0af3e26..32c6b06 100644 --- a/plugins/refkit/skills/find-references/SKILL.md +++ b/plugins/refkit/skills/find-references/SKILL.md @@ -39,7 +39,7 @@ Pass one of the two on every search the user will act on. A bare license id with For each result worth showing, give: -- the title and the source (`provider`) +- the title and the source (`source`) - the license id - the verdict (`useVerdict.decision`) and its reason, in plain words - the `attribution` line when present (the user must reproduce it wherever the work appears)