/* RFC-0954: shared types and pure helpers for the agent semantic search capability. Defines the search manifest shape (build-time output), query/response contracts (runtime API), and pinned embedding model constants. Pure, deterministic — no I/O, no timestamps in the hash payload. Do not read files or embed text — callers (kernel commands) load content and call buildSearchManifest with already-extracted chunks. Do not call Workers AI or Vectorize — runtime logic lives in agent-gate/astro.ts. RFC-0954: initial search manifest contract, query/response types, and pure builder. RFC-1097: step 6 — compass.migrate codemod run Mechanical v1 to v2 header migration across the workspace: 942 files rewritten — CHANGE_SUMMARY windows collapsed into history, forbidden v1 blocks stripped, KEY_DECISIONS seeded from @ai-invariant comments (5 files) or TODO placeholders (103 files), blocks reordered to canonical order. RFC-1097: sweep — werkstatt-engine clean Sweep batch 4: 73 Compass headers on headerless engine files (certification, component-runtime, isolation, evolution, testing), real KEY_DECISIONS on 75 files (kernel, cache, dht, swim, gitmesh, runtime), ~80 purpose expansions (CONTRACT-02/PURPOSE-02), non-goals on 13 CONTRACT-03 files, CS-07 history literal fix repo-wide (253 files). Policy: .template.ts/.template.astro excludedPaths. werkstatt-engine now 0 diagnostics. RFC-1115: SearchChunk gains citation fields (title, canonicalUrl, updatedAt); SearchResult flattened to { title, canonicalUrl, updatedAt, lang, type, score, text }; SEARCH_MANIFEST_SCHEMA_VERSION "1" → "2". */ import { computeSignedContentHash } from "./manifest.ts"; // --------------------------------------------------------------------------- // Pinned embedding model constants (RFC-0954 §Embedding model) // --------------------------------------------------------------------------- /** Cloudflare Workers AI embedding model for semantic search (multilingual, 1024 dims). */ export const SEARCH_EMBEDDING_MODEL = "@cf/baai/bge-m3"; /** Vector dimensions for the pinned embedding model. */ export const SEARCH_EMBEDDING_DIMENSIONS = 1024; /** Maximum number of results per query. */ export const SEARCH_MAX_TOP_K = 20; /** Default number of results per query. */ export const SEARCH_DEFAULT_TOP_K = 5; /** Maximum character length for a chunk's `text` field (truncated before embedding). */ export const SEARCH_CHUNK_TEXT_MAX_LENGTH = 2000; /** Schema version for the search manifest. RFC-1115: "2" adds citation fields to chunks. */ export const SEARCH_MANIFEST_SCHEMA_VERSION = "2"; /** Static asset path for the generated search manifest. */ export const SEARCH_MANIFEST_PATH = "public/api/agent/v1/search-manifest.json"; /** * Files that live in the knowledge directory (`public/api/agent/v1/`) but are NOT * knowledge envelopes. Used by `agent.knowledge.validate`, `agent.manifest.generate`, * `agent.surface.validate`, and `agent.search.manifest.generate` to skip non-knowledge * artifacts when iterating knowledge domain files. */ export const NON_KNOWLEDGE_FILES = ["search-manifest.json"] as const; /** Returns true if `filename` is a non-knowledge file that lives in the knowledge directory. */ export function isNonKnowledgeFile(filename: string): boolean { return (NON_KNOWLEDGE_FILES as readonly string[]).includes(filename); } // --------------------------------------------------------------------------- // Search manifest (build-time output) // --------------------------------------------------------------------------- /** Content type of a search chunk. */ export type SearchChunkType = "page" | "knowledge" | "faq" | "prose"; /** A single searchable content unit — one per block, knowledge entry, or FAQ entry. */ export interface SearchChunk { /** Deterministic ID: "{lang}:{urlPath}:{blockId}" or "{lang}:knowledge:{domain}" etc. */ id: string; /** Full URL path to the page or resource (relative to site root). */ url: string; /** ISO 639-1 language code (e.g. "de", "uk"). */ lang: string; /** Content type — determines metadata filtering. */ type: SearchChunkType; /** Block ID within the page (null for knowledge/faq chunks). */ blockId: string | null; /** Heading text extracted from the block (optional). */ heading: string | null; /** Body text extracted from the block, truncated to SEARCH_CHUNK_TEXT_MAX_LENGTH. */ text: string; /** RFC-1115: display title for citations (from heading; null when absent). */ title: string | null; /** RFC-1115: absolute canonical URL (site origin + url) — citable without a follow-up fetch. */ canonicalUrl: string; /** RFC-1115: source-backed update stamp YYYY-MM-DD (RFC-0317); null when unknown. */ updatedAt: string | null; } /** Static search manifest — build-time output written to public/api/agent/v1/search-manifest.json. */ export interface SearchManifest { schema: string; site: string; model: string; dimensions: number; /** sha256 hex over sorted-key JSON of this document minus contentHash + generatedAt. */ contentHash: string; /** ISO timestamp — present in output but excluded from contentHash. */ generatedAt: string; chunks: SearchChunk[]; } // --------------------------------------------------------------------------- // Query / response (runtime API contracts) // --------------------------------------------------------------------------- /** Query parameters for the search endpoint. */ export interface SearchQuery { /** Natural language search query (max 1000 chars). */ q: string; /** Filter by language (optional). */ lang?: string; /** Filter by content type (optional). */ type?: SearchChunkType; /** Number of results to return (default 5, max 20). */ topK?: number; } /** * RFC-1115: a single search result — flattened citation shape. * Carries everything an agent needs to cite without a follow-up fetch. */ export interface SearchResult { /** Display title (from chunk heading; null when absent). */ title: string | null; /** Absolute canonical URL of the chunk's page or resource. */ canonicalUrl: string; /** Source-backed update stamp YYYY-MM-DD (RFC-0317); null when unknown. */ updatedAt: string | null; /** ISO 639-1 language code. */ lang: string; /** Content type of the underlying chunk. */ type: SearchChunkType; /** Cosine similarity score (0..1, higher = more similar). */ score: number; /** Body text of the chunk. */ text: string; } /** Response from the search endpoint. */ export interface SearchResponse { query: string; results: SearchResult[]; /** Server-side processing time in milliseconds. */ tookMs: number; } // --------------------------------------------------------------------------- // Pure helpers // --------------------------------------------------------------------------- /** * Pure: assemble + hash the search manifest. Deterministic — sorts chunks by id, * excludes generatedAt from the content hash. Two runs on unchanged input are * byte-identical (excluding the timestamp). */ export function buildSearchManifest(input: { site: string; model: string; dimensions: number; generatedAt: string; chunks: SearchChunk[]; }): SearchManifest { const sortedChunks = [...input.chunks].sort((a, b) => a.id.localeCompare(b.id)); const base: Omit = { schema: SEARCH_MANIFEST_SCHEMA_VERSION, site: input.site, model: input.model, dimensions: input.dimensions, generatedAt: input.generatedAt, chunks: sortedChunks, }; const contentHash = computeSearchManifestContentHash(base); return { ...base, contentHash }; } /** sha256 hex over the canonical JSON of the manifest minus contentHash + generatedAt + proof. */ export function computeSearchManifestContentHash( doc: Omit | Record, ): string { return computeSignedContentHash(doc as Record, ["generatedAt"]); }