/*
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"]);
}