@noir-ai/context 1.2.0-beta.2 → 1.3.0-beta.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/index.d.ts +27 -1
- package/dist/index.js +35 -0
- package/dist/index.js.map +1 -1
- package/package.json +3 -3
package/dist/index.d.ts
CHANGED
|
@@ -640,6 +640,32 @@ declare class ContextEngine {
|
|
|
640
640
|
status(): ContextStatus;
|
|
641
641
|
}
|
|
642
642
|
|
|
643
|
+
interface DupCandidate {
|
|
644
|
+
/** Path label used only for reporting (repo-relative or absolute). */
|
|
645
|
+
path: string;
|
|
646
|
+
/** Full text content to embed. Empty/whitespace-only files are skipped. */
|
|
647
|
+
text: string;
|
|
648
|
+
}
|
|
649
|
+
interface DupPair {
|
|
650
|
+
/** The two near-duplicate paths, ordered `a ≤ b` (each unordered pair once). */
|
|
651
|
+
a: string;
|
|
652
|
+
b: string;
|
|
653
|
+
/** Cosine similarity in [−1, 1] (≥ the threshold). */
|
|
654
|
+
similarity: number;
|
|
655
|
+
}
|
|
656
|
+
/** Default near-duplicate threshold (cosine). High → few false positives. */
|
|
657
|
+
declare const DEFAULT_DUP_THRESHOLD = 0.9;
|
|
658
|
+
/**
|
|
659
|
+
* Find near-duplicate file pairs by cosine similarity over embedded contents.
|
|
660
|
+
* `embed` is expected to return an L2-normalized vector (the local/remote
|
|
661
|
+
* embedders do); vectors are re-normalized defensively so cosine = dot product.
|
|
662
|
+
* Complexity is O(n²) in the file count — fine for the small candidate set a
|
|
663
|
+
* dedup scan feeds (host context files + `.noir/` docs). Empty-text files are
|
|
664
|
+
* skipped. Returns pairs with similarity ≥ `threshold`, sorted desc, each
|
|
665
|
+
* unordered pair once.
|
|
666
|
+
*/
|
|
667
|
+
declare function findSemanticDuplicates(files: readonly DupCandidate[], embed: EmbedFn, threshold?: number): Promise<DupPair[]>;
|
|
668
|
+
|
|
643
669
|
/**
|
|
644
670
|
* Build a deterministic embedder. The returned function maps each input string
|
|
645
671
|
* to a stable, unit-norm `EMBED_DIM`-wide `Float32Array` derived from a SHA-256
|
|
@@ -793,4 +819,4 @@ interface RrfResult {
|
|
|
793
819
|
*/
|
|
794
820
|
declare function fuseRrf(bm25: ReadonlyArray<FtsHit>, knn: ReadonlyArray<VecHit>, opts?: FuseRrfOptions): RrfResult[];
|
|
795
821
|
|
|
796
|
-
export { CTX_EMBEDDER_KEY, CTX_FILE_PREFIX, CTX_REGISTRY_KEY, type Chunk, type ChunkDoc, type ChunkMeta, type ChunkOptions, ContextEngine, type ContextEngineOptions, type ContextStatus, type ContextUserConfig, DEFAULT_BUDGET_TOKENS, DEFAULT_CHUNK_MAX_TOKENS, DEFAULT_CHUNK_OVERLAP, DEFAULT_LOCAL_MODEL, DEFAULT_RRF_K, DEFAULT_RRF_WEIGHTS, DEFAULT_SEARCH_LIMIT, DEFAULT_SNIPPET_WINDOW_TOKENS, EMBED_DIM, type EmbedderConfig, type EmbedderInfo, type EmbedderKind, type FileRecord, type ForgetResult, type FuseRrfOptions, type IndexPathOptions, type IndexResult, type Indexer, type IndexerOptions, type LocalEmbedder, type LocalEmbedderConfig, type LocalEmbedderOptions, MODELS_DIR, type NoneEmbedderConfig, type OllamaEmbedderConfig, type OllamaEmbedderOptions, type RRFWeights, type RemoteEmbedderConfig, type RemoteEmbedderOptions, type ResolvedEmbedder, type Retriever, type RetrieverDeps, type RetrieverHit, type RetrieverMeta, type RetrieverOptions, type RrfResult, SKIP_DIRS, SOURCES, type SearchMode, type SearchOptions, type SearchResult, type SourceKind, TOKEN_ESTIMATE_FACTOR, chunkFile, createEmbedFn, createIndexer, createRetriever, ctxFileKey, estimateTokens, explodeIdentifiers, fakeEmbedFn, fuseRrf, inferLanguage, isBinaryExt, isSensitive, l2normalize, localEmbedder, ollamaEmbedder, remoteEmbedder, resolveEmbedderConfig, windowSnippet, withIdentifierExplosion };
|
|
822
|
+
export { CTX_EMBEDDER_KEY, CTX_FILE_PREFIX, CTX_REGISTRY_KEY, type Chunk, type ChunkDoc, type ChunkMeta, type ChunkOptions, ContextEngine, type ContextEngineOptions, type ContextStatus, type ContextUserConfig, DEFAULT_BUDGET_TOKENS, DEFAULT_CHUNK_MAX_TOKENS, DEFAULT_CHUNK_OVERLAP, DEFAULT_DUP_THRESHOLD, DEFAULT_LOCAL_MODEL, DEFAULT_RRF_K, DEFAULT_RRF_WEIGHTS, DEFAULT_SEARCH_LIMIT, DEFAULT_SNIPPET_WINDOW_TOKENS, type DupCandidate, type DupPair, EMBED_DIM, type EmbedderConfig, type EmbedderInfo, type EmbedderKind, type FileRecord, type ForgetResult, type FuseRrfOptions, type IndexPathOptions, type IndexResult, type Indexer, type IndexerOptions, type LocalEmbedder, type LocalEmbedderConfig, type LocalEmbedderOptions, MODELS_DIR, type NoneEmbedderConfig, type OllamaEmbedderConfig, type OllamaEmbedderOptions, type RRFWeights, type RemoteEmbedderConfig, type RemoteEmbedderOptions, type ResolvedEmbedder, type Retriever, type RetrieverDeps, type RetrieverHit, type RetrieverMeta, type RetrieverOptions, type RrfResult, SKIP_DIRS, SOURCES, type SearchMode, type SearchOptions, type SearchResult, type SourceKind, TOKEN_ESTIMATE_FACTOR, chunkFile, createEmbedFn, createIndexer, createRetriever, ctxFileKey, estimateTokens, explodeIdentifiers, fakeEmbedFn, findSemanticDuplicates, fuseRrf, inferLanguage, isBinaryExt, isSensitive, l2normalize, localEmbedder, ollamaEmbedder, remoteEmbedder, resolveEmbedderConfig, windowSnippet, withIdentifierExplosion };
|
package/dist/index.js
CHANGED
|
@@ -1169,6 +1169,39 @@ var ContextEngine = class {
|
|
|
1169
1169
|
}
|
|
1170
1170
|
};
|
|
1171
1171
|
|
|
1172
|
+
// src/dedup.ts
|
|
1173
|
+
var DEFAULT_DUP_THRESHOLD = 0.9;
|
|
1174
|
+
async function findSemanticDuplicates(files, embed, threshold = DEFAULT_DUP_THRESHOLD) {
|
|
1175
|
+
const embedded = await Promise.all(
|
|
1176
|
+
files.map(async (f) => {
|
|
1177
|
+
if (f.text.trim().length === 0) return { path: f.path, vec: null };
|
|
1178
|
+
return { path: f.path, vec: l2normalize(await embed(f.text)) };
|
|
1179
|
+
})
|
|
1180
|
+
);
|
|
1181
|
+
const pairs = [];
|
|
1182
|
+
for (let i = 0; i < embedded.length; i++) {
|
|
1183
|
+
const vi = embedded[i];
|
|
1184
|
+
if (!vi?.vec) continue;
|
|
1185
|
+
for (let j = i + 1; j < embedded.length; j++) {
|
|
1186
|
+
const vj = embedded[j];
|
|
1187
|
+
if (!vj?.vec) continue;
|
|
1188
|
+
const sim = dot(vi.vec, vj.vec);
|
|
1189
|
+
if (sim >= threshold) {
|
|
1190
|
+
const [a, b] = vi.path <= vj.path ? [vi.path, vj.path] : [vj.path, vi.path];
|
|
1191
|
+
pairs.push({ a, b, similarity: sim });
|
|
1192
|
+
}
|
|
1193
|
+
}
|
|
1194
|
+
}
|
|
1195
|
+
pairs.sort((x, y) => y.similarity - x.similarity);
|
|
1196
|
+
return pairs;
|
|
1197
|
+
}
|
|
1198
|
+
function dot(a, b) {
|
|
1199
|
+
const n = Math.min(a.length, b.length);
|
|
1200
|
+
let s = 0;
|
|
1201
|
+
for (let i = 0; i < n; i++) s += (a[i] ?? 0) * (b[i] ?? 0);
|
|
1202
|
+
return s;
|
|
1203
|
+
}
|
|
1204
|
+
|
|
1172
1205
|
// src/types.ts
|
|
1173
1206
|
var SOURCES = ["codebase", "docs", "spec", "memory"];
|
|
1174
1207
|
export {
|
|
@@ -1179,6 +1212,7 @@ export {
|
|
|
1179
1212
|
DEFAULT_BUDGET_TOKENS,
|
|
1180
1213
|
DEFAULT_CHUNK_MAX_TOKENS,
|
|
1181
1214
|
DEFAULT_CHUNK_OVERLAP,
|
|
1215
|
+
DEFAULT_DUP_THRESHOLD,
|
|
1182
1216
|
DEFAULT_LOCAL_MODEL,
|
|
1183
1217
|
DEFAULT_RRF_K,
|
|
1184
1218
|
DEFAULT_RRF_WEIGHTS,
|
|
@@ -1197,6 +1231,7 @@ export {
|
|
|
1197
1231
|
estimateTokens,
|
|
1198
1232
|
explodeIdentifiers,
|
|
1199
1233
|
fakeEmbedFn,
|
|
1234
|
+
findSemanticDuplicates,
|
|
1200
1235
|
fuseRrf,
|
|
1201
1236
|
inferLanguage,
|
|
1202
1237
|
isBinaryExt,
|
package/dist/index.js.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"sources":["../src/hash.ts","../src/chunker.ts","../src/config.ts","../src/embedders/local.ts","../src/embedders/normalize.ts","../src/embedders/ollama.ts","../src/embedders/remote.ts","../src/embedders/fake.ts","../src/embedders/index.ts","../src/indexer.ts","../src/rrf.ts","../src/retriever.ts","../src/contextEngine.ts","../src/types.ts"],"sourcesContent":["// Shared SHA-256 helper for @noir-ai/context.\n//\n// Both the chunker (parentDocId / chunkId keys) and the indexer\n// (file-content skip key) hash with the same UTF-8 SHA-256 hex digest;\n// keep ONE definition here so the two cannot drift.\n\nimport { createHash } from 'node:crypto';\n\n/** UTF-8 SHA-256 hex digest. */\nexport function sha256Hex(text: string): string {\n return createHash('sha256').update(text, 'utf8').digest('hex');\n}\n","// Chunker for @noir-ai/context (slice S6, task t4).\n//\n// Splits a file's content into embeddable/indexable `Chunk`s:\n// • Markdown (`.md`/`.mdx`) → one chunk per ATX-heading-delimited section\n// (code-fence-aware, so `#`-comment lines inside ``` blocks are NOT treated\n// as headings). The heading line is kept with its body so the heading text\n// carries a BM25 signal (spec DS-5).\n// • Everything else → line/token-bounded windows (~512 tokens, 64-token\n// overlap by default) via a cheap whitespace token estimate. Windows align\n// to line boundaries (never split mid-line); consecutive windows overlap by\n// roughly `overlap` tokens of trailing lines so retrieval has continuity.\n//\n// `id = \\`${sha256(path)}#chunk-${n}\\`` and `meta.parentDocId = sha256(path)`\n// are stable across re-indexing — same path + content always yields the same\n// chunk ids, which is what makes the indexer's content-hash skip/delete exact\n// (spec DS-4, §7).\n//\n// `explodeIdentifiers` (DS-7) is exported here and used to derive\n// `meta.sha256` (post-identifier-explosion, per the ChunkMeta contract): the\n// indexer appends the same explosion stream to chunk content before\n// `indexDoc`/`upsertVec` so camelCase/snake_case identifier queries get a BM25\n// signal under the existing `porter unicode61` tokenizer — with NO schema\n// migration (trigram is deferred, OQ-6). `withIdentifierExplosion` is the\n// single canonical form of that append, so the chunker's hash and the\n// indexer's stored content stay byte-identical.\n\nimport { sha256Hex } from './hash.js';\nimport type { Chunk, SourceKind } from './types.js';\n\n// ---------------------------------------------------------------------------\n// Tunable defaults (mirror the `context.chunk` config block, task t10)\n// ---------------------------------------------------------------------------\n\n/** Default maximum estimated tokens per non-markdown chunk (spec DS-5). */\nexport const DEFAULT_CHUNK_MAX_TOKENS = 512;\n\n/** Default token overlap between consecutive non-markdown chunks (spec DS-5). */\nexport const DEFAULT_CHUNK_OVERLAP = 64;\n\n/**\n * Cheap token estimate factor: ~1.3 tokens per whitespace-separated word (the\n * canonical \"1 word ≈ 1.3 tokens\" heuristic). Good enough for windowing/budget\n * packing without pulling in a tokenizer dep.\n */\nexport const TOKEN_ESTIMATE_FACTOR = 1.3;\n\n// ---------------------------------------------------------------------------\n// Options\n// ---------------------------------------------------------------------------\n\n/**\n * Input to {@link chunkFile}. `path` + `content` are required; everything else\n * has a sensible default. `maxTokens`/`overlap` flow from the resolved\n * `context.chunk` config (task t10); `language`/`source` are inferred from the\n * path when omitted so a bare `chunkFile({path, content})` call just works.\n */\nexport interface ChunkOptions {\n /** Repo-relative or absolute path of the source file. */\n path: string;\n /** Full text content of the file (the caller reads it; the chunker never does I/O). */\n content: string;\n /**\n * Language hint (e.g. `'typescript'`, `'markdown'`). Inferred from the path\n * extension when absent (see {@link inferLanguage}).\n */\n language?: string;\n /**\n * Override the inferred {@link SourceKind}. Defaults to `'docs'` for\n * markdown and `'codebase'` otherwise; pass `'spec'`/`'memory'` when indexing\n * those trees explicitly.\n */\n source?: SourceKind;\n /** Max estimated tokens per code chunk (default {@link DEFAULT_CHUNK_MAX_TOKENS}). */\n maxTokens?: number;\n /** Token overlap between consecutive code chunks (default {@link DEFAULT_CHUNK_OVERLAP}). */\n overlap?: number;\n}\n\n// ---------------------------------------------------------------------------\n// Identifier explosion (DS-7)\n// ---------------------------------------------------------------------------\n\n/**\n * Split the identifiers in `text` into lowercase tokens, expanding\n * camelCase / PascalCase / snake_case / kebab-case / digit boundaries.\n *\n * Examples:\n * `contextEngine` → `context engine`\n * `ContextEngine` → `context engine`\n * `XMLHttpRequest` → `xml http request`\n * `myHTTPSConnection` → `my https connection`\n * `snake_case`/`kebab-case` → `snake case` / `kebab case`\n * `ctx:file:<path>` → `ctx file path`\n *\n * The stream is APPENDED to chunk content at index time (see\n * {@link withIdentifierExplosion}) so identifier queries match under\n * `porter unicode61` without a tokenizer migration. Pure/deterministic.\n */\nexport function explodeIdentifiers(text: string): string {\n // Match runs of [A-Za-z0-9]. '-' and '_' are intentionally excluded so\n // kebab/snake identifiers split at their separators for free; camelCase /\n // PascalCase boundaries are then split with two case-transition regexes.\n const words = text.match(/[A-Za-z0-9]+/g);\n if (!words) return '';\n const tokens: string[] = [];\n for (const word of words) {\n const spaced = word\n // lowercase|digit → uppercase: myVar -> my Var | HttpContext -> Http Context\n .replace(/([a-z0-9])([A-Z])/g, '$1 $2')\n // uppercase-run → uppercase+lowercase: XMLHttp -> XML Http | HTTPSConn -> HTTPS Conn\n .replace(/([A-Z]+)([A-Z][a-z])/g, '$1 $2');\n for (const piece of spaced.split(/\\s+/)) {\n if (piece.length > 0) tokens.push(piece.toLowerCase());\n }\n }\n return tokens.join(' ');\n}\n\n/**\n * The canonical index-time content for a chunk: its clean text followed by the\n * identifier-exploded token stream on a trailing line. Both the FTS row and\n * the embedding are produced from this exact string (so identifier queries get\n * a BM25 AND a semantic signal), and {@link chunkSha256} hashes this form to\n * match the `ChunkMeta.sha256` contract (\"post-identifier-explosion\").\n *\n * `chunk.content` itself stays CLEAN (no explosion) so FTS5 window-extracted\n * snippets read naturally — explosion is index-time-only (spec §7, DS-7).\n */\nexport function withIdentifierExplosion(content: string): string {\n const exploded = explodeIdentifiers(content);\n return exploded.length > 0 ? `${content}\\n${exploded}` : content;\n}\n\n// ---------------------------------------------------------------------------\n// Token estimation (shared with the retriever's budget packer)\n// ---------------------------------------------------------------------------\n\n/**\n * Cheap token-count proxy: ~1.3 tokens per whitespace-separated word (see\n * {@link TOKEN_ESTIMATE_FACTOR}). Empty/whitespace-only text → 0. Used both\n * for chunk windowing and (exported) for the retriever's budget packing.\n */\nexport function estimateTokens(text: string): number {\n const trimmed = text.trim();\n if (trimmed.length === 0) return 0;\n const words = trimmed.split(/\\s+/).filter((w) => w.length > 0);\n return Math.ceil(words.length * TOKEN_ESTIMATE_FACTOR);\n}\n\n// ---------------------------------------------------------------------------\n// Language + source inference\n// ---------------------------------------------------------------------------\n\nconst EXT_TO_LANGUAGE: Readonly<Record<string, string>> = {\n ts: 'typescript',\n tsx: 'tsx',\n mts: 'typescript',\n cts: 'typescript',\n js: 'javascript',\n jsx: 'jsx',\n mjs: 'javascript',\n cjs: 'javascript',\n md: 'markdown',\n mdx: 'markdown',\n py: 'python',\n rb: 'ruby',\n rs: 'rust',\n go: 'go',\n java: 'java',\n kt: 'kotlin',\n json: 'json',\n yml: 'yaml',\n yaml: 'yaml',\n toml: 'toml',\n sh: 'shell',\n bash: 'shell',\n zsh: 'shell',\n sql: 'sql',\n html: 'html',\n htm: 'html',\n css: 'css',\n scss: 'scss',\n xml: 'xml',\n txt: 'text',\n};\n\n/**\n * Best-effort language tag from the path extension (e.g. `.ts` → `'typescript'`).\n * Unknown extensions fall back to `'text'`. Used as the `language` default and\n * recorded per-file in the indexer's KV.\n */\nexport function inferLanguage(path: string): string {\n const ext = path.toLowerCase().match(/\\.([a-z0-9]+)$/)?.[1];\n if (!ext) return 'text';\n return EXT_TO_LANGUAGE[ext] ?? 'text';\n}\n\n/** Markdown is detected by extension OR an explicit `language: 'markdown'`. */\nfunction isMarkdown(path: string, language?: string): boolean {\n if (language === 'markdown') return true;\n return /\\.(md|mdx)$/i.test(path);\n}\n\n/**\n * Default {@link SourceKind}: markdown → `'docs'`, everything else →\n * `'codebase'`. Callers indexing the spec tree or (S7) memory override via\n * {@link ChunkOptions.source}.\n */\nfunction defaultSource(path: string, language?: string): SourceKind {\n return isMarkdown(path, language) ? 'docs' : 'codebase';\n}\n\n// ---------------------------------------------------------------------------\n// Hashing\n// ---------------------------------------------------------------------------\n\n/** `parentDocId` + `chunkId` root — `sha256(path)`, stable across re-index. */\nfunction parentDocIdOf(path: string): string {\n return sha256Hex(path);\n}\n\n/**\n * SHA-256 over a chunk's POST-identifier-explosion content (the exact string\n * the indexer stores/embeds), per the `ChunkMeta.sha256` contract. Reproducible\n * via {@link withIdentifierExplosion}.\n */\nfunction chunkSha256(content: string): string {\n return sha256Hex(withIdentifierExplosion(content));\n}\n\n// ---------------------------------------------------------------------------\n// Markdown sectioning (code-fence-aware ATX-heading split)\n// ---------------------------------------------------------------------------\n\nconst ATX_HEADING = /^(#{1,6})\\s/;\nconst FENCE_OPEN = /^\\s*(`{3,}|~{3,})/;\n\n/**\n * Split markdown into one section per ATX heading. Each section's body is the\n * heading line + its non-heading lines. `#`-comment lines inside a `` ` `` or\n * `~~~` code fence are NOT treated as headings (common in docs with code\n * samples — guards against false splits). A non-empty preamble before the\n * first heading becomes its own section.\n */\nfunction markdownSections(content: string): string[] {\n const lines = content.split('\\n');\n const sections: string[] = [];\n let current: string[] = [];\n let inFence = false;\n let fenceMarker = '';\n\n const flush = (): void => {\n if (current.length === 0) return;\n const body = current.join('\\n').replace(/\\s+$/, '');\n if (body.length > 0) sections.push(body);\n current = [];\n };\n\n for (const line of lines) {\n // Track code fences so `#` lines inside them don't read as headings.\n const fence = FENCE_OPEN.exec(line);\n if (fence?.[1]) {\n const marker = fence[1].charAt(0);\n if (!inFence) {\n inFence = true;\n fenceMarker = marker;\n } else if (marker === fenceMarker) {\n inFence = false;\n fenceMarker = '';\n }\n current.push(line);\n continue;\n }\n if (!inFence && ATX_HEADING.test(line)) {\n flush();\n current = [line];\n } else {\n current.push(line);\n }\n }\n flush();\n return sections;\n}\n\n// ---------------------------------------------------------------------------\n// Code windowing (line-bounded, token-budgeted, overlapping)\n// ---------------------------------------------------------------------------\n\n/**\n * Split code/general text into line-bounded windows of ~`maxTokens` estimated\n * tokens with ~`overlap` tokens of trailing-line overlap between consecutive\n * windows.\n *\n * Properties:\n * • Windows never split a line mid-line — a single line that alone exceeds\n * `maxTokens` is emitted as its own chunk (rather than being dropped).\n * • Consecutive multi-line windows always overlap by at least one line, so a\n * retrieval hit near a boundary isn't lost (and the overlap is observable).\n * • Pure/deterministic: identical input → identical windows. Forward progress\n * is guaranteed (each iteration advances `start` by ≥ 1).\n */\nfunction codeWindows(content: string, maxTokens: number, overlap: number): string[] {\n const lines = content.split('\\n');\n const n = lines.length;\n if (n === 0) return [];\n\n const windows: string[] = [];\n let start = 0;\n // Safety guard against any logic error turning this into a spin (each\n // iteration advances `start`, so `n` iterations is the real bound).\n let safety = 2 * n + 8;\n\n while (start < n && safety > 0) {\n safety -= 1;\n // Extend [start, end): accumulate lines while estimated tokens ≤ maxTokens.\n // The first line is always admitted (even if it alone exceeds the budget).\n let end = start;\n let body = '';\n for (let i = start; i < n; i++) {\n const line = lines[i];\n if (line === undefined) break;\n const trial = body.length === 0 ? line : `${body}\\n${line}`;\n if (i > start && estimateTokens(trial) > maxTokens) break;\n body = trial;\n end = i + 1;\n }\n if (body.length > 0) windows.push(body);\n\n if (end >= n) break;\n // Single-line window or no-progress: advance past it (no overlap possible).\n if (end - 1 <= start) {\n start = end;\n continue;\n }\n // Multi-line window: step back from `end` toward `start` carrying lines\n // until their estimated tokens reach `overlap`. Clamp into (start, end-1]\n // for guaranteed overlap + forward progress.\n let carryStart = end - 1;\n let carried = 0;\n while (carryStart > start && carried < overlap) {\n const line = lines[carryStart];\n if (line === undefined) {\n carryStart -= 1;\n continue;\n }\n carried += estimateTokens(line);\n if (carried >= overlap) break;\n carryStart -= 1;\n }\n if (carryStart <= start) carryStart = start + 1;\n if (carryStart > end - 1) carryStart = end - 1;\n start = carryStart;\n }\n return windows;\n}\n\n// ---------------------------------------------------------------------------\n// Public entry point\n// ---------------------------------------------------------------------------\n\n/**\n * Split a file's content into {@link Chunk}s.\n *\n * Markdown → heading-delimited sections; everything else → overlapping\n * line/token windows. Each chunk's `id` is `<sha256(path)>#chunk-<n>` and\n * `meta.parentDocId` is `sha256(path)` (stable across re-indexing), with\n * `meta.sha256` over the post-identifier-explosion content (see\n * {@link withIdentifierExplosion}). Empty content yields no chunks.\n */\nexport function chunkFile(opts: ChunkOptions): Chunk[] {\n const { path, content } = opts;\n // Reject empty AND whitespace-only content (whitespace has length > 0 but\n // carries no chunk signal — would otherwise yield a spurious blank chunk).\n if (content.trim().length === 0) return [];\n\n const language = opts.language ?? inferLanguage(path);\n const source = opts.source ?? defaultSource(path, language);\n const maxTokens = opts.maxTokens ?? DEFAULT_CHUNK_MAX_TOKENS;\n const overlap = opts.overlap ?? DEFAULT_CHUNK_OVERLAP;\n const parentDocId = parentDocIdOf(path);\n\n const bodies = isMarkdown(path, language)\n ? markdownSections(content)\n : codeWindows(content, maxTokens, overlap);\n\n const chunks: Chunk[] = [];\n for (let i = 0; i < bodies.length; i++) {\n const body = bodies[i];\n if (body === undefined || body.length === 0) continue;\n chunks.push({\n id: `${parentDocId}#chunk-${i}`,\n source,\n content: body,\n meta: {\n path,\n parentDocId,\n chunkIndex: i,\n language,\n sha256: chunkSha256(body),\n },\n });\n }\n return chunks;\n}\n","// Embedder config resolver for @noir-ai/context (slice S6).\n//\n// The single bridge from @noir-ai/core's user-facing `context` zod schema to\n// this package's discriminated {@link EmbedderConfig} (the `createEmbedFn`\n// factory input). Lives HERE, in context, so @noir-ai/core never imports\n// @noir-ai/context (no core→context cycle): core owns the user-facing schema,\n// context owns the factory type + this mapper (blueprint / hard rule).\n//\n// Provider-explicit, NEVER silent remote (blueprint D6): `kind:'remote'` and\n// `kind:'ollama'` are only selected when the user explicitly sets them in\n// config. The default is `kind:'local'` — in-process, offline, free, private.\n// Remote API keys are read from environment variables ONLY (secrets stay out of\n// the config file, per the S8 model-layer convention); a missing key yields a\n// config that builds cleanly but throws from `embed()` so the engine degrades\n// to BM25-only (F8) rather than crashing at construction.\n\nimport type { EmbedderConfig } from './types.js';\n\n/**\n * User-facing context config shape — mirrors `NoirConfig['context']` (the zod\n * block @noir-ai/core ships, slice S6). Declared locally with every field\n * optional so this module type-checks WITHOUT a forward dependency on a core\n * type (core never imports context — no cycle), AND so a config with no\n * `context:` block (or a partial one) parses cleanly (AC-7 / NFR-6). The fully\n * resolved zod output is structurally assignable to this permissive shape, so\n * the mapper accepts `NoirConfig['context']` directly.\n */\nexport interface ContextUserConfig {\n embedder?: {\n /** Default `'local'` (in-process transformers.js — offline, free, private). */\n kind?: 'local' | 'remote' | 'ollama' | 'none';\n /** Local HF repo id / remote provider model id / Ollama model tag. */\n model?: string;\n /** Remote provider key, e.g. `'openai'` (only meaningful when `kind:'remote'`). */\n provider?: string;\n /** Ollama base URL, e.g. `http://localhost:11434` (only when `kind:'ollama'`). */\n baseURL?: string;\n /** Target dimensionality (must be 384 to match the existing vec0 table). */\n dim?: number;\n };\n /** Configured index roots (informational here; the daemon/indexer consume them). */\n roots?: string[];\n /** Default token budget for `search` (informational here; consumed by the retriever). */\n budgetTokens?: number;\n}\n\n/**\n * The environment variable that carries the API key for a remote provider, or\n * `undefined` for an unknown provider (no inference — provider-explicit, D6).\n */\nfunction apiKeyEnvVar(provider: string): string | undefined {\n switch (provider) {\n case 'openai':\n return process.env.OPENAI_API_KEY;\n case 'voyage':\n return process.env.VOYAGE_API_KEY;\n case 'cohere':\n return process.env.COHERE_API_KEY;\n default:\n return undefined;\n }\n}\n\n/**\n * Resolve a user-facing {@link ContextUserConfig} into the discriminated\n * {@link EmbedderConfig} the factory (`createEmbedFn`) consumes.\n *\n * - `undefined` / missing block ⇒ `{kind:'local'}` (the safe default — a config\n * with no `context:` block stays local-embedder-attempted, AC-7 / NFR-6).\n * - `kind:'local'` ⇒ in-process transformers.js (model optional; factory\n * defaults to `Xenova/all-MiniLM-L6-v2`).\n * - `kind:'none'` ⇒ vectors disabled; `search` degrades to BM25-only.\n * - `kind:'remote'` / `'ollama'` ⇒ provider-explicit; the API key (remote) /\n * base URL (ollama) are resolved from env when absent in config. A missing\n * key is NOT an error here — the built embedder throws from `embed()` so the\n * engine degrades to BM25-only (F8) instead of crashing at construction.\n *\n * NEVER returns a remote/ollama config unless `kind` is explicitly that value —\n * there is no path from the default to a silent paid call (blueprint D6).\n */\nexport function resolveEmbedderConfig(ctx?: ContextUserConfig): EmbedderConfig {\n const e = ctx?.embedder;\n switch (e?.kind) {\n case 'local': {\n const model = e?.model;\n return { kind: 'local', ...(model ? { model } : {}) };\n }\n case 'remote': {\n // `e?.kind === 'remote'` implies `e` is defined; `e?.` is defensive.\n const provider = e?.provider ?? 'openai';\n const model = e?.model ?? '';\n const dim = e?.dim ?? 384;\n const apiKey = apiKeyEnvVar(provider);\n return { kind: 'remote', provider, model, dim, ...(apiKey ? { apiKey } : {}) };\n }\n case 'ollama': {\n const baseURL = e?.baseURL ?? process.env.OLLAMA_BASE_URL ?? '';\n const model = e?.model ?? '';\n return { kind: 'ollama', baseURL, model };\n }\n case 'none':\n return { kind: 'none' };\n default:\n // undefined / unknown kind ⇒ safe local default. NEVER a silent remote call.\n return { kind: 'local' };\n }\n}\n","// Local in-process embedder: `@huggingface/transformers` + all-MiniLM-L6-v2.\n//\n// DESIGN (spec DS-2 / NFR-4):\n// - The `@huggingface/transformers` module is imported LAZILY via dynamic\n// `import()` on the FIRST `embed()` call, never at module top level. This\n// keeps CLI startup and every non-context code path offline and fast, and\n// means a missing/broken `onnxruntime-node` native binary degrades to\n// BM25-only at runtime instead of crashing import (F8).\n// - The ONNX pipeline is created once and memoized for the process lifetime\n// (the daemon owns a single ContextEngine → one pipeline per serve cycle).\n// A failed load resets the memo so the next call can retry (e.g. after a\n// transient first-run download failure).\n// - Model weights cache is pinned to `~/.noir/models/` (HOME-relative — keeps\n// the project `.noir/` dir portable across machines; spec OQ-7 resolved),\n// centralized as `modelsDir()` in @noir-ai/core (task t10). The local\n// `MODELS_DIR` const re-exports that value so this module's existing\n// imports keep resolving to the identical path.\n// - Output is mean-pooled then L2-normalized through the shared `l2normalize`\n// so every provider path funnels through one normalization (DS-8).\n\nimport { mkdirSync } from 'node:fs';\nimport { modelsDir } from '@noir-ai/core';\nimport type { EmbedFn } from '../types.js';\nimport { EMBED_DIM, l2normalize } from './normalize.js';\n\n/** Default HF repo id — 384-dim, matches the vec0 table with zero migration. */\nexport const DEFAULT_LOCAL_MODEL = 'Xenova/all-MiniLM-L6-v2';\n\n/**\n * `~/.noir/models/` — user-global cache for downloaded ONNX weights.\n * HOME-relative (NOT project `.noir/`) so projects stay portable. Centralized\n * via `@noir-ai/core`'s `modelsDir()`; re-exported here so existing imports of\n * `MODELS_DIR` keep resolving to the same path.\n */\nexport const MODELS_DIR = modelsDir();\n\n// Minimal structural view of the transformers.js v3 module surface this file\n// touches. Declared locally (rather than importing the package's deep types) so\n// the dynamic import stays version-robust and the package builds even when the\n// optional native runtime is absent from the install.\ninterface HFTransformers {\n readonly env: { cacheDir?: string };\n pipeline: (\n task: 'feature-extraction',\n model: string,\n options?: { quantized?: boolean },\n ) => Promise<FeatureExtractionPipeline>;\n}\n\ntype FeatureExtractionPipeline = (\n texts: string,\n options?: { pooling?: 'mean' | 'cls' | 'none'; normalize?: boolean },\n) => Promise<{ data: ArrayLike<number>; dims: number[] }>;\n\n/** Result of a lazy load: a per-text embedder backed by a cached pipeline. */\ntype LoadedEmbedder = (text: string) => Promise<Float32Array>;\n\n// Memoized load promises, keyed by model id. One in-flight load per model\n// (the daemon is the single writer / one ContextEngine per serve lifecycle,\n// so typically a single entry; keying by model avoids a silent collision if\n// two different models are ever constructed in the same process). A failed\n// load evicts its own entry so the next call may retry.\nconst pipelineCache = new Map<string, Promise<LoadedEmbedder>>();\n\nasync function loadPipeline(model: string): Promise<LoadedEmbedder> {\n try {\n const mod = (await import('@huggingface/transformers')) as unknown as HFTransformers;\n // Pin the download cache before constructing the pipeline.\n mkdirSync(MODELS_DIR, { recursive: true });\n mod.env.cacheDir = MODELS_DIR;\n const extractor = await mod.pipeline('feature-extraction', model);\n return async (text: string): Promise<Float32Array> => {\n // mean-pool the token embeddings; `l2normalize` (not normalize:true) is\n // the single normalization every provider funnels through (DS-8).\n const out = await extractor(text, { pooling: 'mean' });\n // transformers.js `Tensor.data` is a typed array; copy into a fresh\n // Float32Array of exactly the model's embedding width.\n const dim = out.dims.at(-1) ?? EMBED_DIM;\n const raw = new Float32Array(dim);\n for (let i = 0; i < dim; i++) raw[i] = out.data[i] ?? 0;\n return l2normalize(raw);\n };\n } catch (e) {\n const reason = e instanceof Error ? e.message : String(e);\n throw new Error(\n `local embedder: failed to load model \"${model}\" (is @huggingface/transformers installed and the onnxruntime native binary present?): ${reason}`,\n );\n }\n}\n\n/** Lazily resolve (and memoize) the cached embedder, resetting on failure. */\nfunction getEmbedder(model: string): Promise<LoadedEmbedder> {\n const cached = pipelineCache.get(model);\n if (cached) return cached;\n // Evict this model's entry on rejection so the next call may retry;\n // concurrent callers share the single in-flight load promise for that model.\n const promise = loadPipeline(model).catch((e) => {\n pipelineCache.delete(model);\n throw e;\n });\n pipelineCache.set(model, promise);\n return promise;\n}\n\nexport interface LocalEmbedderOptions {\n /** HF repo id (defaults to {@link DEFAULT_LOCAL_MODEL}). */\n model?: string;\n}\n\nexport interface LocalEmbedder {\n /** Embed function — loads the model lazily on first invocation. */\n embed: EmbedFn;\n /** Resolved model id. */\n model: string;\n}\n\n/**\n * Build a local in-process embedder. Construction is synchronous and never\n * touches the network or the native runtime — the dynamic `import()` happens\n * inside `embed()`, so `localEmbedder()` is safe to call at startup. Load\n * failures surface as rejections from `embed()`; callers (retriever/engine)\n * catch them and degrade to BM25-only (F8).\n */\nexport function localEmbedder(opts: LocalEmbedderOptions = {}): LocalEmbedder {\n const model = opts.model ?? DEFAULT_LOCAL_MODEL;\n return {\n model,\n embed: async (text: string): Promise<Float32Array> => {\n const run = await getEmbedder(model);\n return run(text);\n },\n };\n}\n","// Shared numeric helpers for every embedder in this package.\n//\n// All embedder implementations (local / remote / ollama / fake) funnel their\n// final vector through `l2normalize` so that:\n// - the existing S1 `vec0` table (created with the DEFAULT distance metric)\n// yields cosine-equivalent ranking when vectors are unit-norm (spec DS-8),\n// - remote/ollama vectors that were Matryoshka-truncated to `EMBED_DIM` are\n// re-normalized client-side (truncation shrinks the norm),\n// - the kNN path sees a single, consistent scale regardless of provider.\n//\n// `EMBED_DIM` is the fixed width of the vec0 table (384). It is the single\n// source of truth for the target dimensionality and matches the MiniLM-L6-v2\n// default model — changing it would require a store schema migration, which\n// S6 explicitly does NOT do.\n\n/**\n * Fixed embedding width. Matches the S1 `vec0(float[384])` virtual table and\n * the default `all-MiniLM-L6-v2` model. Remote/ollama vectors are truncated or\n * required to be >= this width (see `remote.ts` / `ollama.ts`).\n */\nexport const EMBED_DIM = 384;\n\n/**\n * Return a unit-norm copy of `vec` (L2 normalization). The input is never\n * mutated. Length is preserved: callers responsible for ensuring the vector is\n * already `EMBED_DIM`-wide (truncate BEFORE normalizing — Matryoshka order\n * matters; normalizing then truncating would distort directions).\n *\n * A zero-length or all-zero / non-finite input cannot be normalized; for those\n * degenerate cases a fresh zeroed vector of the same length is returned rather\n * than inventing directional signal or producing NaN. Real embedders never\n * emit such vectors; this guard exists purely for robustness.\n */\nexport function l2normalize(vec: Float32Array): Float32Array {\n const n = vec.length;\n if (n === 0) return new Float32Array(0);\n\n let sum = 0;\n for (let i = 0; i < n; i++) {\n const v = vec[i];\n if (v !== undefined) sum += v * v;\n }\n const norm = Math.sqrt(sum);\n // norm === 0 (all-zero input) or NaN/Infinity → return zeros, no signal invented.\n if (norm === 0 || !Number.isFinite(norm)) {\n return new Float32Array(n);\n }\n\n const out = new Float32Array(n);\n const inv = 1 / norm;\n for (let i = 0; i < n; i++) {\n const v = vec[i];\n if (v !== undefined) out[i] = v * inv;\n }\n return out;\n}\n","// Opt-in OLLAMA embeddings (local Ollama server).\n//\n// DESIGN (spec DS-2 / blueprint D6):\n// - Provider-explicit and NEVER the default. The caller selects `kind:'ollama'`\n// with a concrete `baseURL`; the blueprint rejects an always-on Ollama\n// sidecar, so this is the supported opt-in path for users who already run\n// Ollama locally.\n// - Like the remote embedder, a missing `baseURL` throws a clear \"not\n// configured\" error from `embed()` (not at construction) so the engine can\n// report status and degrade to BM25-only (F8).\n// - Ollama's `/api/embeddings` returns the model's native-width vector; we\n// Matryoshka-truncate to `EMBED_DIM` (384) and re-normalize via the shared\n// `l2normalize`. A vector shorter than 384 is a hard config error (the user\n// must pick a >= 384-dim Ollama model such as `nomic-embed-text`).\n//\n// Thin stub: direct `fetch`, no retry/batching (post-v0, plan §7).\n\nimport type { EmbedFn } from '../types.js';\nimport { EMBED_DIM, l2normalize } from './normalize.js';\n\nexport interface OllamaEmbedderOptions {\n /** Base URL of the Ollama server, e.g. `http://localhost:11434`. */\n baseURL: string;\n /** Ollama model tag, e.g. `nomic-embed-text`. */\n model: string;\n /** Target dimensionality (defaults to {@link EMBED_DIM}). */\n dim?: number;\n}\n\ninterface OllamaResponse {\n embedding?: number[];\n}\n\n/** Read up to a few hundred chars of the error body for diagnostics. */\nasync function readErrorBody(res: Response): Promise<string> {\n try {\n const text = await res.text();\n return text.slice(0, 500);\n } catch {\n return '<unreadable response body>';\n }\n}\n\n/**\n * Build an Ollama embedder. Construction is synchronous and makes no network\n * calls. Each `embed()` POSTs the source text to `<baseURL>/api/embeddings`.\n */\nexport function ollamaEmbedder(opts: OllamaEmbedderOptions): EmbedFn {\n const targetDim = opts.dim ?? EMBED_DIM;\n const base = opts.baseURL.replace(/\\/+$/, '');\n\n return async (text: string): Promise<Float32Array> => {\n if (!opts.baseURL) {\n throw new Error(\n 'ollama embedder is not configured: baseURL is required (set context.embedder.baseURL, e.g. http://localhost:11434)',\n );\n }\n\n const res = await fetch(`${base}/api/embeddings`, {\n method: 'POST',\n headers: { 'content-type': 'application/json' },\n body: JSON.stringify({ model: opts.model, prompt: text }),\n });\n\n if (!res.ok) {\n const detail = await readErrorBody(res);\n throw new Error(\n `ollama embedder request failed (${res.status} ${res.statusText}): ${detail}`,\n );\n }\n\n const json = (await res.json()) as OllamaResponse;\n const vec = json.embedding ?? [];\n\n if (vec.length < targetDim) {\n throw new Error(\n `ollama model \"${opts.model}\" returned a ${vec.length}-dim vector (shorter than the required ${targetDim}); choose a >= ${targetDim}-dim model (e.g. nomic-embed-text)`,\n );\n }\n\n // Matryoshka truncate to the vec0 width, then re-normalize (DS-8).\n const truncated =\n vec.length > targetDim ? Float32Array.from(vec.slice(0, targetDim)) : Float32Array.from(vec);\n return l2normalize(truncated);\n };\n}\n","// Opt-in REMOTE embeddings (OpenAI / Voyage / Cohere).\n//\n// DESIGN (spec DS-2 / blueprint D6 / NFR-1):\n// - Provider-explicit and NEVER the default. The caller selects `kind:'remote'`\n// with a concrete `provider` in config; there is no inference from env vars,\n// so source text is never silently sent to a cloud endpoint (D6 hard rule).\n// - `apiKey` absence does NOT throw at construction — the embedder builds\n// cleanly so `context_status` can report `kind:'remote'`, and `embed()`\n// throws a clear \"not configured\" error so the engine degrades to BM25-only\n// (F8) rather than crashing. This makes a misconfigured key observable\n// instead of fatal.\n// - Vectors are Matryoshka-truncated client-side to `dim` (default 384) and\n// re-normalized via the shared `l2normalize` (truncation shrinks the norm;\n// DS-8). A vector SHORTER than `dim` is a hard config error (wrong model).\n//\n// This is a deliberately thin stub: it fetches the provider's embeddings\n// endpoint directly with the global `fetch`. Full provider SDKs (streaming,\n// retries, batching, structured usage) are post-v0 (plan §7).\n\nimport type { EmbedFn } from '../types.js';\nimport { EMBED_DIM, l2normalize } from './normalize.js';\n\nexport interface RemoteEmbedderOptions {\n /** Provider key: `'openai'` | `'voyage'` | `'cohere'` (others fall back to the OpenAI shape). */\n provider: string;\n /** API key. If absent, `embed()` throws a clear \"not configured\" error. */\n apiKey?: string;\n /** Provider-specific model id. */\n model: string;\n /** Target dimensionality (must be 384 to match the vec0 table). */\n dim?: number;\n}\n\n/** OpenAI-compatible embeddings endpoint; also the fallback for unknown providers. */\nconst OPENAI_DEFAULT = 'https://api.openai.com/v1/embeddings';\n\n/** Known provider endpoints. Unknown providers reuse the OpenAI-compatible shape. */\nconst ENDPOINTS: Record<string, string> = {\n openai: OPENAI_DEFAULT,\n voyage: 'https://api.voyageai.com/v1/embeddings',\n cohere: 'https://api.cohere.com/v2/embed',\n};\n\ninterface EmbeddingsResponse {\n data?: Array<{ embedding?: number[] }>;\n embeddings?: { float?: number[][] };\n}\n\n/** Best-effort body for the provider's embeddings endpoint. */\nfunction buildRequestBody(provider: string, model: string, text: string): string {\n switch (provider) {\n case 'voyage':\n return JSON.stringify({ model, inputs: [text] });\n case 'cohere':\n return JSON.stringify({\n model,\n texts: [text],\n input_type: 'search_document',\n embedding_types: ['float'],\n });\n default:\n // OpenAI-compatible. We deliberately omit `dimensions` (only the\n // text-embedding-3-* family accepts it; others 400) and rely on\n // client-side Matryoshka truncation below for schema compatibility.\n return JSON.stringify({ model, input: text });\n }\n}\n\n/** Extract the first embedding vector from a provider response shape. */\nfunction extractVector(json: EmbeddingsResponse, provider: string): number[] {\n if (provider === 'cohere') {\n const vec = json.embeddings?.float?.[0];\n return vec ?? [];\n }\n // openai / voyage / openai-compatible\n return json.data?.[0]?.embedding ?? [];\n}\n\n/** Read up to a few hundred chars of the error body for diagnostics. */\nasync function readErrorBody(res: Response): Promise<string> {\n try {\n const text = await res.text();\n return text.slice(0, 500);\n } catch {\n return '<unreadable response body>';\n }\n}\n\n/**\n * Build a remote (cloud) embedder. Construction is synchronous and makes no\n * network calls. Each `embed()` call POSTs the source text to the provider.\n */\nexport function remoteEmbedder(opts: RemoteEmbedderOptions): EmbedFn {\n const targetDim = opts.dim ?? EMBED_DIM;\n // String-literal fallback (not a second Record lookup): under\n // noUncheckedIndexedAccess, indexing a Record yields `string | undefined`, so\n // `ENDPOINTS[opts.provider] ?? ENDPOINTS.openai` stays `string | undefined`\n // and fails typecheck at the fetch() below. `?? OPENAI_DEFAULT` resolves to `string`.\n const endpoint = ENDPOINTS[opts.provider] ?? OPENAI_DEFAULT;\n\n return async (text: string): Promise<Float32Array> => {\n if (!opts.apiKey) {\n throw new Error(\n `remote embedder \"${opts.provider}\" is not configured: apiKey is required (set context.embedder.apiKey or the matching provider env var)`,\n );\n }\n\n const res = await fetch(endpoint, {\n method: 'POST',\n headers: {\n 'content-type': 'application/json',\n authorization: `Bearer ${opts.apiKey}`,\n },\n body: buildRequestBody(opts.provider, opts.model, text),\n });\n\n if (!res.ok) {\n const detail = await readErrorBody(res);\n throw new Error(\n `remote embedder \"${opts.provider}\" request failed (${res.status} ${res.statusText}): ${detail}`,\n );\n }\n\n const json = (await res.json()) as EmbeddingsResponse;\n const vec = extractVector(json, opts.provider);\n\n if (vec.length < targetDim) {\n throw new Error(\n `remote embedder \"${opts.provider}\" returned a ${vec.length}-dim vector (shorter than the required ${targetDim}); choose a >= ${targetDim}-dim model`,\n );\n }\n\n // Matryoshka truncate to the vec0 width, then re-normalize (DS-8).\n const truncated =\n vec.length > targetDim ? Float32Array.from(vec.slice(0, targetDim)) : Float32Array.from(vec);\n return l2normalize(truncated);\n };\n}\n","// Deterministic fake embedder for tests + degraded fallback.\n//\n// DESIGN (spec §13 / NFR-2 / NFR-5):\n// - The full unit suite runs OFFLINE with no model download and no network.\n// `fakeEmbedFn` is the deterministic stand-in: same text → same vector,\n// different text → different direction, always `EMBED_DIM`-wide and\n// unit-norm. This makes chunker/rrf/retriever/indexer tests reproducible.\n// - Determinism comes from a SHA-256 of the text (no RNG, no clock) —\n// identical inputs produce bit-identical vectors (NFR-5).\n// - Output is L2-normalized through the shared helper so it is\n// indistinguishable from a real embedder at the store boundary (DS-8).\n\nimport { createHash } from 'node:crypto';\nimport type { EmbedFn } from '../types.js';\nimport { EMBED_DIM, l2normalize } from './normalize.js';\n\n/**\n * Build a deterministic embedder. The returned function maps each input string\n * to a stable, unit-norm `EMBED_DIM`-wide `Float32Array` derived from a SHA-256\n * hash of the text. Suitable for unit tests and as a BM25-companion fallback\n * where a real vector signal is unavailable but the shape must be valid.\n *\n * @param dim output width (defaults to {@link EMBED_DIM} = 384)\n */\nexport function fakeEmbedFn(dim: number = EMBED_DIM): EmbedFn {\n return (text: string): Promise<Float32Array> => {\n const hash = createHash('sha256').update(text, 'utf8').digest();\n const raw = new Float32Array(dim);\n // Spread the 32 hash bytes deterministically across the `dim` slots. Values\n // land in [-1, 1) (signed byte / 128); the direction is what matters and is\n // normalized away from the scale by `l2normalize`.\n for (let i = 0; i < dim; i++) {\n const byte = hash[i % hash.length];\n if (byte !== undefined) raw[i] = (byte - 128) / 128;\n }\n return Promise.resolve(l2normalize(raw));\n };\n}\n","// Embedder factory + provider implementations for @noir-ai/context.\n//\n// Every implementation returns an L2-normalized `EMBED_DIM`-wide\n// `Float32Array` via the shared `l2normalize`, fulfilling the `EmbedFn` seam\n// declared by @noir-ai/store but never implemented there. `createEmbedFn` is the\n// single factory the engine calls; it dispatches on the `EmbedderConfig`\n// discriminated union (from ../types) and returns `{ embed, info }`.\n//\n// Provider matrix (spec DS-2):\n// local — @huggingface/transformers + all-MiniLM-L6-v2 (DEFAULT; lazy load)\n// remote — OpenAI / Voyage / Cohere (OPT-IN, provider-explicit, paid)\n// ollama — local Ollama server (OPT-IN, provider-explicit)\n// none — disable vectors; search degrades to BM25-only (F8)\n\nimport type { EmbedderConfig, EmbedderInfo, EmbedFn } from '../types.js';\nimport { localEmbedder } from './local.js';\nimport { EMBED_DIM } from './normalize.js';\nimport { ollamaEmbedder } from './ollama.js';\nimport { remoteEmbedder } from './remote.js';\n\n/** Result of resolving an embedder config: a callable + describing metadata. */\nexport interface ResolvedEmbedder {\n /** The `EmbedFn` the engine/retriever/indexer call. May throw on load/config failure. */\n embed: EmbedFn;\n /** Surfaced by `context_status` and recorded in store KV (`ctx:embedder`). */\n info: EmbedderInfo;\n}\n\n/**\n * Build an `{ embed, info }` pair from a config. Construction never touches the\n * network or the native runtime — even `local` defers its dynamic import to the\n * first `embed()` call. `kind:'none'` (and any load failure the caller lets\n * propagate) yields BM25-only retrieval downstream (F8).\n */\nexport function createEmbedFn(cfg: EmbedderConfig): ResolvedEmbedder {\n switch (cfg.kind) {\n case 'local': {\n const { embed, model } = localEmbedder({ model: cfg.model });\n return { embed, info: { kind: 'local', model, dim: EMBED_DIM } };\n }\n case 'remote': {\n const embed = remoteEmbedder({\n provider: cfg.provider,\n apiKey: cfg.apiKey,\n model: cfg.model,\n dim: cfg.dim,\n });\n return { embed, info: { kind: 'remote', model: cfg.model, dim: cfg.dim } };\n }\n case 'ollama': {\n const embed = ollamaEmbedder({ baseURL: cfg.baseURL, model: cfg.model });\n return { embed, info: { kind: 'ollama', model: cfg.model, dim: EMBED_DIM } };\n }\n case 'none': {\n // Defer the error to embed() — but the retriever branches on\n // info.kind === 'none' first, so this stub never runs in the normal path.\n const embed: EmbedFn = async () => {\n throw new Error('embedder disabled (kind:\"none\"); search degrades to BM25-only');\n };\n return { embed, info: { kind: 'none', dim: 0 } };\n }\n }\n}\n\nexport { fakeEmbedFn } from './fake.js';\nexport type { LocalEmbedder, LocalEmbedderOptions } from './local.js';\nexport { DEFAULT_LOCAL_MODEL, localEmbedder, MODELS_DIR } from './local.js';\n// Re-export the building blocks so the engine, tests, and t10's\n// `resolveEmbedderConfig` mapper can import everything from one path.\nexport { EMBED_DIM, l2normalize } from './normalize.js';\nexport type { OllamaEmbedderOptions } from './ollama.js';\nexport { ollamaEmbedder } from './ollama.js';\nexport type { RemoteEmbedderOptions } from './remote.js';\nexport { remoteEmbedder } from './remote.js';\n","// Indexer for @noir-ai/context (slice S6, task t6).\n//\n// SHA-256 content-hash incremental indexer over the existing Store. The daemon\n// is the single writer; the indexer never opens a second connection — it walks\n// the given paths through the INJECTED store handle only (blueprint D6:\n// in-process, no sidecar, canonical ProjectId).\n//\n// Per file the indexer:\n// 1. chunks it (chunker.ts: markdown-heading | line/token windows, DS-5);\n// 2. appends the identifier-exploded token stream (DS-7) to form the CANONICAL\n// indexed content — the SAME string feeds `indexDoc` (FTS) and `upsertVec`\n// (embedding) so BM25 and kNN join on identical material under the SAME\n// chunk id;\n// 3. writes one `docs` row + one `vec0` row per chunk, keyed by the chunk's\n// own SourceKind (`'docs'` | `'codebase'` | …) so the retriever's\n// per-source filter stays meaningful.\n//\n// Incremental discipline (spec DS-4 / F1–F4): each file's UTF-8 SHA-256 is the\n// skip key. Unchanged files are skipped wholesale (their chunk count rolls into\n// `skipped`); changed files have their old chunks + vectors deleted, then\n// re-inserted; files removed since the last scan (and under a re-scanned root)\n// have their chunks + vectors deleted and are dropped from the registry. The\n// first call on a fresh store naturally seeds a full reindex (registry empty ⇒\n// everything is new).\n//\n// State lives in the store KV, namespaced `ctx:` to stay disjoint from\n// `workflow:*` and store meta:\n// ctx:registry → string[] indexed path keys (sorted)\n// ctx:file:<key> → FileRecord per-file {sha256, chunkIds, language}\n// ctx:embedder → EmbedderInfo recorded once; model swap ⇒ warn (never silent)\n//\n// Degraded path (spec F8, mirrored for indexing): when the embedder is\n// `kind:'none'` or `embed()` throws (native load failed / misconfigured remote),\n// the indexer disables embedding for the rest of the run, still indexes the\n// `docs` rows, and reports `degraded:true` — it never crashes on a bad embedder.\n\nimport type { Dirent } from 'node:fs';\nimport { readFileSync } from 'node:fs';\nimport { readdir, readFile, stat } from 'node:fs/promises';\nimport { join, relative, resolve, sep } from 'node:path';\nimport { chunkFile, inferLanguage, withIdentifierExplosion } from './chunker.js';\nimport { sha256Hex } from './hash.js';\nimport type { ChunkMeta, EmbedderInfo, EmbedFn, IndexResult, SourceKind, Store } from './types.js';\n\n// ---------------------------------------------------------------------------\n// KV schema (namespaced `ctx:` — disjoint from `workflow:*` / store meta)\n// ---------------------------------------------------------------------------\n\n/** KV key holding the sorted list of indexed path keys. */\nexport const CTX_REGISTRY_KEY = 'ctx:registry';\n/** KV key holding the recorded {@link EmbedderInfo} (model-swap detection). */\nexport const CTX_EMBEDDER_KEY = 'ctx:embedder';\n/** Per-file record key prefix; the value is a {@link FileRecord} (or `null` tombstone). */\nexport const CTX_FILE_PREFIX = 'ctx:file:';\n\n/**\n * Per-file tracking record stored under {@link ctxFileKey}. `sha256` is the\n * content-hash skip key; `chunkIds` lets a change/removal delete exactly the\n * rows that belong to this file; `language` mirrors the chunker's inference.\n */\nexport interface FileRecord {\n /** SHA-256 of the file's UTF-8 content — the content-hash skip key (DS-4). */\n sha256: string;\n /** Chunk ids belonging to this file (for exact delete on change/removal). */\n chunkIds: string[];\n /** Detected language tag (mirrors the chunker's inference). */\n language: string;\n}\n\n/** Build a `ctx:file:<key>` KV key. */\nexport function ctxFileKey(pathKey: string): string {\n return `${CTX_FILE_PREFIX}${pathKey}`;\n}\n\n// ---------------------------------------------------------------------------\n// Walk skips (VCS / Noir state / build artifacts / dependency trees)\n// ---------------------------------------------------------------------------\n\n/**\n * Directory names never descended into during a walk (spec F1). Covers the\n * common `.gitignore` entries without needing a gitignore parser — full\n * `.gitignore` intersection (and `git diff --name-only` for incremental scope)\n * is deferred v0 debt (grounds §10 indexing trigger).\n */\nexport const SKIP_DIRS = new Set([\n '.git',\n '.hg',\n '.svn',\n '.noir',\n 'node_modules',\n 'bower_components',\n 'dist',\n 'build',\n 'out',\n '.next',\n '.nuxt',\n '.turbo',\n '.cache',\n 'coverage',\n '.nyc_output',\n '.venv',\n 'venv',\n 'env',\n '__pycache__',\n '.mypy_cache',\n '.pytest_cache',\n '.idea',\n '.vscode',\n]);\n\n/**\n * Extensions treated as binary and therefore unindexable (counted as `failed`,\n * never read in full — the extension check is cheap and avoids slurping large\n * assets). The null-byte guard below catches mis-extensioned binaries.\n */\nconst BINARY_EXTS = new Set([\n // Images\n 'png',\n 'jpg',\n 'jpeg',\n 'gif',\n 'webp',\n 'ico',\n 'bmp',\n 'tif',\n 'tiff',\n 'svgz',\n 'heic',\n 'avif',\n // Audio / video\n 'mp3',\n 'mp4',\n 'mov',\n 'avi',\n 'mkv',\n 'flac',\n 'wav',\n 'ogg',\n 'webm',\n 'aac',\n 'm4a',\n // Archives\n 'zip',\n 'gz',\n 'tar',\n 'tgz',\n 'br',\n 'lz',\n 'lzma',\n '7z',\n 'rar',\n 'bz2',\n // Documents (binary)\n 'pdf',\n 'doc',\n 'docx',\n 'xls',\n 'xlsx',\n 'ppt',\n 'pptx',\n // Compiled / native\n 'wasm',\n 'exe',\n 'dll',\n 'so',\n 'dylib',\n 'a',\n 'o',\n 'obj',\n 'lib',\n 'class',\n 'jar',\n 'war',\n 'pyc',\n 'pyo',\n 'pyd',\n // ML / model artifacts\n 'onnx',\n 'pickle',\n 'pt',\n 'bin',\n // Databases / locks\n 'db',\n 'sqlite',\n 'sqlite3',\n 'db-shm',\n 'db-wal',\n 'lock',\n // Fonts\n 'ttf',\n 'otf',\n 'woff',\n 'woff2',\n 'eot',\n]);\n\n/** True if the path's extension is a known binary type. */\nexport function isBinaryExt(pathOrName: string): boolean {\n const m = pathOrName.toLowerCase().match(/\\.([a-z0-9]+)$/);\n if (!m?.[1]) return false;\n return BINARY_EXTS.has(m[1]);\n}\n\n// ---------------------------------------------------------------------------\n// Sensitive-file denylist (post-review hardening: prevent secret exposure via\n// context_search). These files are NEVER chunked/embedded/indexed even when\n// they are plain text — indexing a `.env` or `id_rsa` would leak its contents\n// into FTS + vector snippets. Covers env files, private keys, credential\n// stores, and OS junk. Path-aware: pass a basename or a `/`-separated rel path.\n// ---------------------------------------------------------------------------\n\n/** Exact basenames never indexed (case-insensitive). */\nconst SENSITIVE_NAMES = new Set([\n '.env',\n '.npmrc',\n '.pypirc',\n '.git-credentials',\n '.netrc',\n '.ds_store',\n 'thumbs.db',\n]);\n\n/**\n * Basename prefixes (covers `.env.local` / `.env.production`, `id_rsa` /\n * `id_rsa.pub`, `id_ed25519` / `id_ed25519.pub`). Matched against the basename\n * only so a path segment like `deploy/` does not false-negative.\n */\nconst SENSITIVE_PREFIXES = ['.env.', 'id_rsa', 'id_ed25519'];\n\n/** Basename suffixes (covers `*.pem`, `*.key`, `*.secret`, `*.p12`, `*.pfx`, `*.local`). */\nconst SENSITIVE_SUFFIXES = ['.pem', '.key', '.secret', '.p12', '.pfx', '.local'];\n\n/**\n * Relative-path patterns not caught by a basename check — `.aws/credentials`\n * has the generic basename `credentials`, so it is anchored on the path suffix\n * instead (a bare `credentials` basename is intentionally NOT flagged).\n */\nconst SENSITIVE_PATHS = ['.aws/credentials'];\n\n/**\n * True if `name` is a sensitive file that must NEVER be chunked, embedded, or\n * indexed. Accepts a bare basename (`id_rsa`, `.env`, `cert.pem`) OR a\n * `/`-separated relative path (`deploy/id_rsa`, `.aws/credentials`); the\n * basename is extracted internally so prefix/suffix checks anchor on the file\n * name rather than a path segment. Wired into the walk's skip decision\n * alongside {@link isBinaryExt} so secrets never reach `indexDoc` / `upsertVec`.\n */\nexport function isSensitive(name: string): boolean {\n const lower = name.toLowerCase();\n // Path-anchored first (basename `credentials` alone is too generic to flag).\n for (const p of SENSITIVE_PATHS) {\n if (lower === p || lower.endsWith(`/${p}`)) return true;\n }\n const base = lower.slice(lower.lastIndexOf('/') + 1);\n if (SENSITIVE_NAMES.has(base)) return true;\n for (const pf of SENSITIVE_PREFIXES) {\n if (base.startsWith(pf)) return true;\n }\n for (const sf of SENSITIVE_SUFFIXES) {\n if (base.endsWith(sf)) return true;\n }\n return false;\n}\n\n// ---------------------------------------------------------------------------\n// Small path helpers (stable, cross-platform keys)\n// ---------------------------------------------------------------------------\n\n/** Normalize OS separators to `/` so registry keys match across platforms. */\nfunction posix(p: string): string {\n return sep === '/' ? p : p.split(sep).join('/');\n}\n\n// ---------------------------------------------------------------------------\n// Options + return types\n// ---------------------------------------------------------------------------\n\n/** Construction options for {@link createIndexer}. */\nexport interface IndexerOptions {\n /** The daemon's store handle — the ONLY storage surface used (single writer). */\n store: Store;\n /** The resolved embedder. Failures here degrade the run (F8-style), never crash. */\n embed: EmbedFn;\n /** Active embedder description; recorded in KV for model-swap detection. */\n info: EmbedderInfo;\n /**\n * Optional repository root. When provided, paths passed to\n * {@link Indexer.indexPaths} are resolved against it AND stored repo-relative\n * (portable `meta.path`, stable across checkouts). When omitted, paths resolve\n * against `process.cwd()` and are stored absolute.\n */\n root?: string;\n}\n\n/** Per-call options for {@link Indexer.indexPaths}. */\nexport interface IndexPathOptions {\n /** Chunk size override (flows from `context.chunk.maxTokens`). */\n maxTokens?: number;\n /** Chunk overlap override (flows from `context.chunk.overlap`). */\n overlap?: number;\n /**\n * Override the source bucket for every file in this call (otherwise the\n * chunker infers `'docs'` for markdown and `'codebase'` for everything else).\n */\n source?: SourceKind;\n}\n\n/** Return value of {@link Indexer.forget}. */\nexport interface ForgetResult {\n /** Files removed from the index (their chunks + vectors were deleted). */\n deleted: number;\n /** Total chunks now tracked. */\n totalChunks: number;\n}\n\n/** The indexer surface returned by {@link createIndexer}. */\nexport interface Indexer {\n /**\n * Walk + incrementally index `paths` (files or directories). Removed files\n * under a re-scanned root are reconciled (deleted). Returns chunk/file counts\n * + the `degraded` flag (spec F1/F3/F4, AC-1).\n */\n indexPaths(paths: string[], opts?: IndexPathOptions): Promise<IndexResult>;\n /** Remove `paths` (files or dirs) from the index; deletes their chunks + vectors. */\n forget(paths: string[]): Promise<ForgetResult>;\n /** Drop every indexed chunk + vector, then re-index the registered roots from scratch. */\n reindex(): Promise<IndexResult>;\n /**\n * Read a single chunk's CLEAN content + meta by id, by re-reading the source\n * file from disk and re-chunking it (the indexer writes the post-identifier-\n * explosion form to the `docs` table for FTS — the CLEAN pre-explosion text\n * is what a kNN-only-hit snippet wants, since it is what humans read). This\n * is the read side of the indexer's own writer: it knows the registry layout\n * (`ctx:registry` + `ctx:file:<key>`) and the chunk-id format\n * (`<sha256(path)>#chunk-<n>`), so it is the natural place to look up a\n * chunk by id.\n *\n * Used by the engine to hydrate kNN-only retriever hits (C1) — wired as the\n * retriever's `readDoc`. Returns `null` when:\n * • the chunk id is not in any tracked FileRecord (deleted, or never\n * indexed — e.g. a vec row whose `docs` row came from a foreign source);\n * • the source file is missing/unreadable on disk (deleted/moved/perm);\n * • the file's content changed and the re-chunk no longer yields this id.\n *\n * In all miss cases the caller (retriever) emits an empty snippet and the\n * payload's `mode` honestly reflects `'knn'` — never crashes.\n */\n readChunkContent(id: string): { content: string; meta: ChunkMeta } | null;\n}\n\n// ---------------------------------------------------------------------------\n// Factory\n// ---------------------------------------------------------------------------\n\n/**\n * Build an {@link Indexer} bound to a single store handle. Construction does no\n * I/O and touches no KV — all work happens inside `indexPaths`/`forget`/`reindex`.\n */\nexport function createIndexer(opts: IndexerOptions): Indexer {\n const { store, embed, info } = opts;\n const base = opts.root ?? process.cwd();\n\n // Single-flight serialization of mutating ops (post-review: prevent registry\n // KV races). `indexPaths` / `forget` / `reindex` do read-modify-write on the\n // `ctx:registry` / `ctx:file:*` KV across `await readdir` / `readFile` /\n // `embed` suspension points; without serialization, two CONCURRENT calls each\n // load the same registry snapshot, mutate their own copy, and persist\n // last-write-wins — orphaning the loser's chunks + vectors in `docs`/`vec0`\n // with no registry entry to ever reconcile them away. This promise chain\n // forces ALL mutating ops to run strictly one at a time over the shared\n // handle. Reads (the retriever's `search`, the engine's `status`) bypass the\n // chain entirely and stay concurrent.\n let chain: Promise<unknown> = Promise.resolve();\n function serialized<T>(work: () => Promise<T>): Promise<T> {\n const result = chain.then(work);\n // Advance the chain regardless of whether `work` resolves or rejects: a\n // failed op must not poison the queue for the next caller. The caller\n // observes the real outcome via `result`; the chain only tracks readiness.\n chain = result.then(\n () => undefined,\n () => undefined,\n );\n return result;\n }\n\n // path helpers --------------------------------------------------------------\n const resolveAbs = (p: string): string => resolve(base, p);\n // When `root` is given, keys are repo-relative (portable); otherwise absolute.\n const toKey = (abs: string): string => (opts.root ? relative(opts.root, abs) : abs);\n const keyAbs = (key: string): string => posix(resolve(base, key));\n\n /**\n * Path confinement (post-review): resolve-then-confine. True only when `abs`\n * is the project root itself or lives beneath it. Applied to BOTH the\n * explicit `paths` arguments and the walk's yielded entries so an absolute\n * out-of-root path (`/etc/passwd`) or a `../sibling` traversal is skipped —\n * never ingested, never stored as a `../../...` or absolute `meta.path`.\n */\n function isWithinRoot(abs: string): boolean {\n const r = resolve(base);\n const a = resolve(abs);\n return a === r || a.startsWith(`${r}${sep}`);\n }\n\n // store helpers -------------------------------------------------------------\n function deleteChunks(ids: string[]): void {\n for (const id of ids) {\n store.deleteDoc(id);\n store.deleteVec(id);\n }\n }\n\n /** Load every per-file record currently in the registry into a Map. */\n function loadRecords(): Map<string, FileRecord> {\n const registry = store.getState<string[]>(CTX_REGISTRY_KEY) ?? [];\n const records = new Map<string, FileRecord>();\n for (const p of registry) {\n const rec = store.getState<FileRecord>(ctxFileKey(p));\n if (rec) records.set(p, rec);\n }\n return records;\n }\n\n /** Write back the registry + per-file records; tombstone the removed keys. */\n function persist(records: Map<string, FileRecord>, tombstones: string[]): void {\n store.setState(CTX_REGISTRY_KEY, [...records.keys()].sort());\n for (const [key, rec] of records) store.setState(ctxFileKey(key), rec);\n for (const key of tombstones) store.setState(ctxFileKey(key), null);\n }\n\n /**\n * Record the active embedder in KV. On a model swap (kind/model/dim change),\n * warn loudly — vectors may now be incompatible — but NEVER auto-reindex; the\n * caller decides via `reindex()` (spec §7: \"warn + offer reindex, not silent\").\n */\n function recordEmbedder(): void {\n const prev = store.getState<EmbedderInfo>(CTX_EMBEDDER_KEY);\n if (prev === null) {\n store.setState(CTX_EMBEDDER_KEY, info);\n return;\n }\n if (!sameEmbedder(prev, info)) {\n console.warn(\n `[noir-context] embedder changed (${describeEmbedder(prev)} → ${describeEmbedder(info)}); ` +\n 'existing vectors may be stale — call reindex() to refresh',\n );\n store.setState(CTX_EMBEDDER_KEY, info);\n }\n }\n\n // walk ----------------------------------------------------------------------\n /** Recursively collect files under `rootAbs`, skipping {@link SKIP_DIRS}. */\n async function walk(rootAbs: string): Promise<string[]> {\n const out: string[] = [];\n const stack: string[] = [rootAbs];\n while (stack.length > 0) {\n const dir = stack.pop();\n if (dir === undefined) break;\n let entries: Dirent[];\n try {\n entries = await readdir(dir, { withFileTypes: true });\n } catch {\n continue; // unreadable / restricted → skip silently (don't fail the run)\n }\n for (const ent of entries) {\n if (ent.isDirectory()) {\n if (SKIP_DIRS.has(ent.name)) continue;\n stack.push(join(dir, ent.name));\n } else if (ent.isFile()) {\n out.push(join(dir, ent.name));\n }\n }\n }\n return out;\n }\n\n /** Is `absKey` underneath (or equal to) any of the re-scanned `absRoots`? */\n function inScope(absKey: string, absRoots: string[]): boolean {\n for (const r of absRoots) {\n const rp = posix(r);\n if (absKey === rp || absKey.startsWith(`${rp}/`)) return true;\n }\n return false;\n }\n\n // --- indexPaths ------------------------------------------------------------\n async function indexPaths(inputPaths: string[], o?: IndexPathOptions): Promise<IndexResult> {\n recordEmbedder();\n const records = loadRecords();\n\n // Resolve inputs → absolute roots; walk dirs / admit files into `scanned`.\n const absRoots: string[] = [];\n const scanned = new Map<string, string>(); // key -> abs\n for (const input of inputPaths) {\n const abs = resolveAbs(input);\n // Path confinement: reject traversal / out-of-root ingestion. An absolute\n // path (`/etc/passwd`) or a `../sibling` resolves outside `base`; skip it\n // entirely — never stat, never walk, never store a `../` meta.path.\n if (!isWithinRoot(abs)) continue;\n const st = await stat(abs).catch(() => null);\n if (st === null) continue; // missing path — nothing to index, nothing to reconcile here\n if (st.isDirectory()) {\n absRoots.push(abs);\n for (const file of await walk(abs)) {\n // Defensive confinement on walk entries: the walk starts in-root, but\n // a symlink resolved by stat/readdir could point outside — re-check.\n if (!isWithinRoot(file)) continue;\n scanned.set(posix(toKey(file)), file);\n }\n } else if (st.isFile()) {\n absRoots.push(abs);\n scanned.set(posix(toKey(abs)), abs);\n }\n }\n\n let indexed = 0;\n let skipped = 0;\n let failed = 0;\n let deleted = 0;\n // `kind:'none'` ⇒ no vectors from the start; a thrown embed() flips this mid-run.\n let embedDisabled = info.kind === 'none';\n const tombstones: string[] = [];\n\n const tryEmbed = async (content: string): Promise<Float32Array | null> => {\n if (embedDisabled) return null;\n try {\n return await embed(content);\n } catch {\n // First failure (native load / config / network) ⇒ disable for the rest\n // of the run. Docs still get indexed; their vectors are skipped (F8).\n embedDisabled = true;\n return null;\n }\n };\n\n // 1. Reconcile removals: registry entries under a re-scanned root that are\n // no longer on disk get their chunks + vectors deleted. Files indexed\n // under OTHER roots are left untouched (incremental, scoped reconcile).\n for (const key of [...records.keys()]) {\n if (scanned.has(key)) continue;\n if (!inScope(keyAbs(key), absRoots)) continue;\n const rec = records.get(key);\n if (rec) deleteChunks(rec.chunkIds);\n records.delete(key);\n tombstones.push(key);\n deleted += 1;\n }\n\n // 2. Index scanned files (skip unchanged by content-hash; re-chunk changed).\n for (const [key, abs] of scanned) {\n // Binary by extension ⇒ unindexable (cheap check, no full read). Counts as\n // `failed` per the IndexResult contract (binary / IO / encoding).\n if (isBinaryExt(key)) {\n failed += 1;\n continue;\n }\n // Sensitive file (secret / key / credential) ⇒ NEVER chunked, embedded, or\n // indexed, even when plain text — prevents secret exposure via\n // `context_search` snippets. Counts as `failed` (deliberately excluded by\n // policy, like the binary guard above). Post-review hardening.\n if (isSensitive(key)) {\n failed += 1;\n continue;\n }\n let content: string;\n try {\n content = await readFile(abs, 'utf8');\n } catch {\n failed += 1;\n continue;\n }\n if (content.includes(String.fromCharCode(0))) {\n // Null byte ⇒ binary masquerading as text (extension check missed it).\n failed += 1;\n continue;\n }\n\n const fileHash = sha256Hex(content);\n const prev = records.get(key);\n if (prev !== undefined && prev.sha256 === fileHash) {\n // Content-hash hit: skip wholesale (AC-1). Roll the chunk count into `skipped`.\n skipped += prev.chunkIds.length;\n continue;\n }\n // Changed or new: delete the file's prior chunks before re-inserting.\n if (prev !== undefined) deleteChunks(prev.chunkIds);\n\n const chunks = chunkFile({\n path: key,\n content,\n source: o?.source,\n maxTokens: o?.maxTokens,\n overlap: o?.overlap,\n });\n const chunkIds: string[] = [];\n let language = inferLanguage(key);\n for (const chunk of chunks) {\n // Canonical indexed content: clean text + identifier explosion (DS-7).\n // Both FTS and the embedding consume this exact string; chunk.meta.sha256\n // (computed by the chunker) is the hash of this same form.\n const indexedContent = withIdentifierExplosion(chunk.content);\n store.indexDoc({\n id: chunk.id,\n source: chunk.source,\n content: indexedContent,\n meta: chunk.meta,\n });\n language = chunk.meta.language;\n const vec = await tryEmbed(indexedContent);\n if (vec !== null) {\n store.upsertVec(chunk.id, vec, { source: chunk.source });\n }\n chunkIds.push(chunk.id);\n indexed += 1;\n }\n records.set(key, { sha256: fileHash, chunkIds, language });\n }\n\n persist(records, tombstones);\n\n let totalChunks = 0;\n for (const rec of records.values()) totalChunks += rec.chunkIds.length;\n\n return {\n indexed,\n skipped,\n deleted,\n failed,\n totalChunks,\n // `degraded` is truthful about \"docs indexed without vectors\": only set\n // when embedding was off AND at least one doc went in without one.\n degraded: embedDisabled && indexed > 0,\n };\n }\n\n // --- forget ----------------------------------------------------------------\n async function forget(inputPaths: string[]): Promise<ForgetResult> {\n const records = loadRecords();\n const targets = inputPaths.map((p) => posix(resolveAbs(p)));\n const tombstones: string[] = [];\n for (const key of [...records.keys()]) {\n const abs = keyAbs(key);\n const hit = targets.some((t) => abs === t || abs.startsWith(`${t}/`));\n if (!hit) continue;\n const rec = records.get(key);\n if (rec) deleteChunks(rec.chunkIds);\n records.delete(key);\n tombstones.push(key);\n }\n persist(records, tombstones);\n let totalChunks = 0;\n for (const rec of records.values()) totalChunks += rec.chunkIds.length;\n return { deleted: tombstones.length, totalChunks };\n }\n\n // --- reindex ---------------------------------------------------------------\n async function reindex(): Promise<IndexResult> {\n // Wipe the registry + every per-file record, deleting all chunks + vectors,\n // then re-index the SAME roots from scratch (typically after a model swap).\n const registry = store.getState<string[]>(CTX_REGISTRY_KEY) ?? [];\n const tombstones: string[] = [];\n for (const key of registry) {\n const rec = store.getState<FileRecord>(ctxFileKey(key));\n if (rec) deleteChunks(rec.chunkIds);\n tombstones.push(key);\n }\n persist(new Map(), tombstones);\n // `registry` holds the prior path keys (relative-to-root or absolute); they\n // resolve back to absolute via `base` inside indexPaths.\n return indexPaths(registry);\n }\n\n // --- readChunkContent ------------------------------------------------------\n /**\n * Read-side companion to `indexPaths`/`forget`/`reindex`. Look up a chunk's\n * CLEAN content + meta by id, by re-reading the source file from disk and\n * re-chunking it (the CLEAN pre-explosion text is what a kNN-only snippet\n * wants — humans read it; the post-identifier-explosion form is FTS-only).\n *\n * The chunk-id format is `<parentDocId>#chunk-<n>` where `parentDocId =\n * sha256(path)`, but the indexer keys FileRecords by PATH KEY (which may be\n * repo-relative OR absolute depending on `opts.root`), NOT by sha256(path).\n * So the lookup walks the registry's FileRecords and picks the one whose\n * `chunkIds` contains `id`. Returns `null` on any miss (no entry, missing\n * file, content drift) so the retriever degrades honestly to `mode:'knn'`.\n *\n * Pure-read: no KV mutation, no `await` (synchronous file reads via\n * `readFileSync`). Bypasses the serialized mutation queue — reads can run\n * freely concurrent with each other AND with a concurrent `indexPaths`\n * (SQLite WAL handles read concurrency; the worst case is a transient\n * content-mismatch miss, which surfaces honestly as `mode:'knn'`).\n */\n function readChunkContent(id: string): { content: string; meta: ChunkMeta } | null {\n const records = loadRecords();\n // Find the FileRecord that owns this chunk id. Short-circuit: most kNN\n // queries hydrate 0–few hits, and the registry is small (one entry per\n // indexed file), so the linear scan is cheap.\n let ownerKey: string | null = null;\n for (const [key, rec] of records) {\n if (rec.chunkIds.includes(id)) {\n ownerKey = key;\n break;\n }\n }\n if (ownerKey === null) return null;\n // Resolve the path key back to an absolute path the same way indexPaths\n // does (`base` is the indexer's `opts.root ?? process.cwd()`). POSIX-form\n // the result to match the on-disk key separator.\n const abs = keyAbs(ownerKey);\n let content: string;\n try {\n content = readFileSync(abs, 'utf8');\n } catch {\n // Missing/unreadable file (deleted, moved, permissions). Surface as a\n // null miss — the retriever reports mode:'knn'.\n return null;\n }\n // Null-byte guard mirrors indexPaths: don't trust a file that turned binary\n // under us (would yield garbage chunks); treat as a miss.\n if (content.includes(String.fromCharCode(0))) return null;\n // Re-chunk and pick the chunk whose id matches. If the file's content\n // drifted (chunkIds no longer line up), this returns null honestly.\n const chunks = chunkFile({ path: ownerKey, content });\n const found = chunks.find((c) => c.id === id);\n if (!found) return null;\n return { content: found.content, meta: found.meta };\n }\n\n // Mutating ops are wrapped in `serialized` so they run strictly one at a time\n // over the shared store handle (see `chain` above). The inner closures call\n // the un-wrapped functions directly: `reindex` invokes `indexPaths` inline\n // within its own serialized slot, so there is no re-queue / deadlock.\n // `readChunkContent` is a pure read and bypasses the queue (see its JSDoc).\n return {\n indexPaths: (inputPaths: string[], o?: IndexPathOptions) =>\n serialized(() => indexPaths(inputPaths, o)),\n forget: (inputPaths: string[]) => serialized(() => forget(inputPaths)),\n reindex: () => serialized(reindex),\n readChunkContent,\n };\n}\n\n// ---------------------------------------------------------------------------\n// Embedder comparison (model-swap detection)\n// ---------------------------------------------------------------------------\n\nfunction sameEmbedder(a: EmbedderInfo, b: EmbedderInfo): boolean {\n return a.kind === b.kind && a.model === b.model && a.dim === b.dim;\n}\n\nfunction describeEmbedder(e: EmbedderInfo): string {\n return e.model ? `${e.kind}:${e.model}(${e.dim})` : `${e.kind}(${e.dim})`;\n}\n","// Reciprocal Rank Fusion (RRF) for hybrid retrieval — slice S6, task t5.\n//\n// Fuses two pre-ranked retrieval lists — BM25 (`Store.searchFt`) and cosine\n// kNN (`Store.knn`) — into one ranked list via the canonical Cormack SIGIR'09\n// rank-based formula. RRF is deliberately RANK-BASED: it never reads either\n// list's raw scores, so it sidesteps the BM25-vs-cosine scale mismatch\n// entirely (spec DS-3 / NFR-5). A document present in only one list contributes\n// only that list's term — no penalty, no normalization — and the raw BM25 and\n// cosine scores are NEVER summed.\n//\n// Reference: Cormack, Clarke, Büttcher — Reciprocal Rank Fusion (SIGIR 2009).\n// score(d) = Σ_i w_i / (k + rank_i(d)) (1-based ranks, default k = 60)\n\nimport type { FtsHit, VecHit } from './types.js';\n\n/**\n * Canonical RRF constant (Cormack SIGIR'09). Larger values dampen the\n * advantage of the top ranks.\n */\nexport const DEFAULT_RRF_K = 60;\n\n/** Default per-retriever weights `[bm25Weight, kNNWeight]` (DS-3). */\nexport const DEFAULT_RRF_WEIGHTS: readonly [number, number] = [0.5, 0.5];\n\n/** Options for {@link fuseRrf}. */\nexport interface FuseRrfOptions {\n /**\n * RRF constant `k`. Larger values flatten the top-rank advantage.\n * Defaults to {@link DEFAULT_RRF_K} (60 — the canonical value).\n */\n k?: number;\n /**\n * Per-retriever weights `[bm25Weight, kNNWeight]`. Need not sum to 1: RRF is\n * rank-based, not a normalized blend. Defaults to `[0.5, 0.5]`.\n */\n weights?: [number, number];\n}\n\n/** A fused result row from {@link fuseRrf}. */\nexport interface RrfResult {\n /** Chunk id (the same id was indexed into both `docs` and `vec0`). */\n id: string;\n /** Source bucket from the originating hit (bm25's, else kNN's). */\n source: string;\n /**\n * Fused RRF score `Σ w_i/(k+rank_i)`. Rank-based — NOT a normalized\n * similarity; only meaningful for ordering within a single `fuseRrf` call.\n */\n score: number;\n}\n\n/**\n * Build a 1-based rank map (`id → {rank, source}`) from a pre-ranked list,\n * taking the FIRST occurrence's position as the rank. Defends against a\n * duplicate id within one list (a well-formed ranking never emits one, but the\n * store does not strictly forbid it); later duplicates are ignored.\n */\nfunction rankMap(\n list: ReadonlyArray<{ id: string; source: string }>,\n): Map<string, { rank: number; source: string }> {\n const map = new Map<string, { rank: number; source: string }>();\n for (const [i, hit] of list.entries()) {\n if (map.has(hit.id)) continue; // first occurrence wins\n map.set(hit.id, { rank: i + 1, source: hit.source });\n }\n return map;\n}\n\n/**\n * Fuse two pre-ranked retrieval lists via Reciprocal Rank Fusion.\n *\n * The input lists MUST be ordered best-first (the store returns them that way:\n * `searchFt` ranks by BM25 relevance, `knn` by ascending distance). Position in\n * the array IS the rank — the hits' raw `score` fields are intentionally\n * ignored (DS-3: never sum raw BM25 + cosine).\n *\n * For each unique id: `score = w_bm25/(k + rank_bm25) + w_knn/(k + rank_knn)`,\n * dropping a term when the id is absent from that list (no penalty, no\n * normalization). Results sort by score descending; ties break by the doc's\n * best (minimum) rank across both lists, then by first-seen insertion order\n * (bm25 list first, then kNN-only docs) — all deterministic, so identical\n * inputs always yield identical ordering (NFR-5).\n *\n * Pure and side-effect-free.\n */\nexport function fuseRrf(\n bm25: ReadonlyArray<FtsHit>,\n knn: ReadonlyArray<VecHit>,\n opts?: FuseRrfOptions,\n): RrfResult[] {\n const k = opts?.k ?? DEFAULT_RRF_K;\n const [wBm25, wKnn] = opts?.weights ?? DEFAULT_RRF_WEIGHTS;\n\n const bm25Ranks = rankMap(bm25);\n const knnRanks = rankMap(knn);\n\n // Unique ids in first-seen order (bm25 list first, then kNN-only docs).\n // Captured up front so the final tie-break is deterministic without relying\n // on the host JS engine's Array.sort stability.\n const seen = new Set<string>();\n const orderedIds: string[] = [];\n for (const hit of bm25) {\n if (!seen.has(hit.id)) {\n seen.add(hit.id);\n orderedIds.push(hit.id);\n }\n }\n for (const hit of knn) {\n if (!seen.has(hit.id)) {\n seen.add(hit.id);\n orderedIds.push(hit.id);\n }\n }\n\n const entries = orderedIds.map((id, order) => {\n const bm = bm25Ranks.get(id);\n const kn = knnRanks.get(id);\n const rankBm25 = bm?.rank;\n const rankKnn = kn?.rank;\n const score =\n (rankBm25 != null ? wBm25 / (k + rankBm25) : 0) +\n (rankKnn != null ? wKnn / (k + rankKnn) : 0);\n const minRank = Math.min(\n rankBm25 ?? Number.POSITIVE_INFINITY,\n rankKnn ?? Number.POSITIVE_INFINITY,\n );\n // A chunk's source is identical across both lists in practice (the indexer\n // upserts the same id into docs + vec0 with one source), so preferring the\n // bm25 source is a deterministic pick, never a conflicting merge.\n const source = bm?.source ?? kn?.source ?? '';\n return { id, source, score, minRank, order };\n });\n\n // Sort: fused score desc → best (min) rank asc → first-seen insertion asc.\n entries.sort((a, b) => {\n if (a.score !== b.score) return b.score - a.score;\n if (a.minRank !== b.minRank) return a.minRank - b.minRank;\n return a.order - b.order;\n });\n\n // Strip the internal tie-break keys before returning the public shape.\n return entries.map(({ id, source, score }) => ({ id, source, score }));\n}\n","// Hybrid retriever for @noir-ai/context (slice S6, task t7).\n//\n// Pipeline (spec F6, §8 \"Query → BM25+vec → RRF → budget → snippets\"):\n// search(query)\n// │\n// ├─ store.searchFt(query, {limit, source}) → FtsHit[] (BM25, with\n// │ FTS5 16-token windowed\n// │ snippet + docs.meta)\n// ├─ store.knn(await embed(query), {limit, source}) → VecHit[] (cosine kNN,\n// │ L2-normalized vectors)\n// │\n// ├─ fuseRrf(bm25, knn, {k:60, weights:[0.5,0.5]}) → rank-based RRF fusion\n// │ (DS-3; NEVER sums raw\n// │ BM25+cosine scores)\n// │\n// ├─ enrich each fused id:\n// │ • BM25 hit → reuse FtsHit.snippet VERBATIM (F7 — never truncate);\n// │ path/parentDocId backfilled from docs.meta (ChunkMeta)\n// │ • kNN-only hit → window-extract a snippet from `readDoc(id)` (the\n// │ chunk's content) — vec0 carries no meta column, so\n// │ without a hydrator the hit degrades to an empty\n// │ snippet but KEEPS ITS RANK (F8 spirit: never crash,\n// │ never drop a ranked semantic hit on a missing window)\n// │\n// ├─ collapse duplicate parentDocId (keep the top-scoring chunk per parent —\n// │ DS-6; one file can't flood the result set). Unhydrated kNN-only hits\n// │ (empty parentDocId) collapse on their unique id, so they never merge.\n// │\n// └─ greedy token-budget fill (default 4096) over the collapsed list:\n// accumulate estimateTokens(snippet) until budgetTokens; the top hit is\n// always admitted even if it alone exceeds the budget (avoid returning\n// zero results for one large hit). truncated:true if the list is\n// exhausted before the budget (DS-6).\n//\n// Degradation (F8): when the embedder is unavailable — `kind:'none'`, a native\n// load failure, a provider error, OR `knn()` itself threw — `search` falls back\n// to BM25-only and the payload carries `degraded:true, mode:'bm25-only'`. The\n// embedder signals unavailability by throwing from `embed()` (the `'none'`\n// factory does exactly that), so the retriever needs no a-priori knowledge of\n// the embedder kind — the throw IS the signal. A BM25 throw (e.g. a foreign\n// read-only DB missing `docs_fts`) is also caught: search degrades to an empty\n// or vec-only result set rather than crashing.\n//\n// Mode truthfulness (C1): beyond the bm25-only fallback there is a softer\n// degradation — `'knn'`. When the kNN leg ran successfully but a kNN-only hit\n// could not be hydrated (no `readDoc` wired, or the source doc was\n// deleted/degraded), the hit keeps its rank but carries an empty snippet, and\n// the payload carries `mode:'knn'` so the caller knows it did not receive full\n// hybrid snippet quality. `'hybrid'` is reserved for the case where both legs\n// ran AND every hit got a real windowed snippet.\n//\n// Hard rules honored (see the slice brief):\n// • Reuses the existing Store API only — no getDoc added, no schema migration.\n// • RRF k=60 RANK-BASED (no score normalization; raw scores never summed).\n// • BM25 snippets reused verbatim (never truncated); kNN windows are prefix\n// windows with `<<term>>` highlight to mirror the FTS5 snippet convention.\n// • Canonical ProjectId only (carried by the store); in-process only.\n\nimport { estimateTokens } from './chunker.js';\nimport { DEFAULT_RRF_K, fuseRrf } from './rrf.js';\nimport type {\n ChunkMeta,\n EmbedFn,\n FtsHit,\n RetrieverHit,\n RetrieverMeta,\n RRFWeights,\n SearchMode,\n SearchResult,\n SourceKind,\n Store,\n VecHit,\n} from './types.js';\n\n// ---------------------------------------------------------------------------\n// Defaults\n// ---------------------------------------------------------------------------\n\n/**\n * Default per-query hit cap applied to BOTH the BM25 and kNN legs so RRF has\n * equal-depth lists to fuse (matches `Store.searchFt`'s default of 10; the\n * store's `knn` default of 5 is intentionally raised here for fusion parity).\n */\nexport const DEFAULT_SEARCH_LIMIT = 10;\n\n/** Default token budget for the greedy packer (spec §6 / config `budgetTokens`). */\nexport const DEFAULT_BUDGET_TOKENS = 4096;\n\n/**\n * Default snippet window for kNN-only hydration, in (estimated) tokens. Mirrors\n * the FTS5 `snippet(..., 16)` 16-token window used by `Store.searchFt` so a\n * kNN-only hit's fallback window is the same width as a BM25 hit's window.\n */\nexport const DEFAULT_SNIPPET_WINDOW_TOKENS = 16;\n\n// ---------------------------------------------------------------------------\n// Options\n// ---------------------------------------------------------------------------\n\n/**\n * Content + meta returned by the optional {@link RetrieverOptions.readDoc}\n * hydrator. This is the shape needed to window-extract a snippet for a kNN-only\n * hit (vec0 returns no meta, and the `Store` interface exposes no read-by-id).\n */\nexport interface ChunkDoc {\n /** Full chunk text (the hydrator decides the source: KV, a future getDoc, …). */\n content: string;\n /** The chunk's stored metadata, if available. */\n meta?: ChunkMeta;\n}\n\n/** Tunables held for the life of the retriever (RRF + budget + hydration). */\nexport interface RetrieverOptions {\n /** RRF constant `k` (defaults to {@link DEFAULT_RRF_K} = 60). */\n k?: number;\n /** RRF per-retriever weights `[bm25Weight, kNNWeight]` (default `[0.5, 0.5]`). */\n weights?: RRFWeights;\n /** Default token budget (overridable per `search()` call). */\n budgetTokens?: number;\n /** kNN-only snippet window width in tokens (default 16). */\n snippetWindowTokens?: number;\n /**\n * Optional content/meta lookup for kNN-only hits. The store's `knn` returns\n * only `{id, source, score}` (vec0 has no meta column) and the `Store`\n * interface exposes no read-by-id, so without a hydrator a purely-semantic\n * hit cannot be windowed. When provided and it hits, the chunk's content is\n * prefix-windowed with `<<query-term>>` highlights (mirroring FTS5). When\n * omitted or it misses, the hit is emitted with an empty snippet — degraded\n * but ranked (F8) — AND the search result's `mode` becomes `'knn'` so the\n * caller can tell the snippet quality is degraded (C1). The engine (t8)\n * wires this from the indexer's `readChunkContent` when a content source\n * exists.\n */\n readDoc?: (id: string) => ChunkDoc | null;\n}\n\n/** Per-call options for {@link Retriever.search}. */\nexport interface SearchOptions {\n /** Max hits requested from EACH leg before fusion (default 10). */\n limit?: number;\n /** Token budget for this call (defaults to the retriever's `budgetTokens`). */\n budgetTokens?: number;\n /** Restrict both legs to a single source bucket (passes through to the store). */\n source?: string;\n}\n\n/** Constructor dependencies for {@link createRetriever}. */\nexport interface RetrieverDeps {\n /** The store handle (the daemon's single-writer handle, or a read-only one). */\n store: Store;\n /** Query/chunk embedder. A throw on `embed(query)` ⇒ BM25-only fallback (F8). */\n embed: EmbedFn;\n /** Tunables + the optional kNN-only hydrator. */\n opts?: RetrieverOptions;\n}\n\n/** The retriever surface returned by {@link createRetriever}. */\nexport interface Retriever {\n search(query: string, opts?: SearchOptions): Promise<SearchResult>;\n}\n\n// ---------------------------------------------------------------------------\n// Small narrowing / windowing helpers\n// ---------------------------------------------------------------------------\n\n/**\n * Defensively narrow an `unknown` meta (from `FtsHit.meta` / `ChunkDoc.meta`)\n * to a {@link ChunkMeta}. The store types meta as `unknown`; for context chunks\n * it is the `ChunkMeta` the indexer wrote, but a foreign/legacy row may carry\n * anything. Returns `undefined` when the required `path` + `parentDocId` fields\n * are absent so the caller can fall back to empty strings rather than crash.\n */\nfunction asChunkMeta(meta: unknown): ChunkMeta | undefined {\n if (typeof meta !== 'object' || meta === null) return undefined;\n const m = meta as Record<string, unknown>;\n const path = m.path;\n const parentDocId = m.parentDocId;\n // The two required fields — bail to `undefined` if either is missing/non-string\n // so callers fall back to empty strings rather than crashing.\n if (typeof path !== 'string' || typeof parentDocId !== 'string') {\n return undefined;\n }\n const chunkIndex = m.chunkIndex;\n const language = m.language;\n const sha256Val = m.sha256;\n return {\n path,\n parentDocId,\n chunkIndex: typeof chunkIndex === 'number' ? chunkIndex : 0,\n language: typeof language === 'string' ? language : 'text',\n // ChunkMeta.sha256 is required (the indexer always writes it); for a\n // foreign/legacy row that lacks it, fall back to '' rather than crash.\n sha256: typeof sha256Val === 'string' ? sha256Val : '',\n };\n}\n\n/** Project the secondary fields of a {@link ChunkMeta} onto a {@link RetrieverMeta}. */\nfunction toRetrieverMeta(meta: ChunkMeta | undefined): RetrieverMeta {\n if (!meta) return {};\n const out: RetrieverMeta = {};\n if (meta.language) out.language = meta.language;\n if (meta.sha256) out.sha256 = meta.sha256;\n out.chunkIndex = meta.chunkIndex;\n return out;\n}\n\n/**\n * Narrow a free-form source string to a {@link SourceKind}. The indexer writes\n * valid source buckets, but the store types `source` as a plain string; an\n * unexpected value defaults to `'codebase'` rather than propagating.\n */\nfunction asSourceKind(source: string): SourceKind {\n switch (source) {\n case 'codebase':\n case 'docs':\n case 'spec':\n case 'memory':\n return source;\n default:\n return 'codebase';\n }\n}\n\n/**\n * Extract a lowercase query-term set for highlight matching. Splits on\n * whitespace/punctuation and drops empties. Used only to wrap matching words in\n * the kNN-only prefix window with `<<…>>` markers (mirrors FTS5's convention);\n * it has no effect on ranking.\n */\nfunction queryTermSet(query: string): Set<string> {\n const terms = query.toLowerCase().match(/[a-z0-9]+/g);\n return terms ? new Set(terms) : new Set();\n}\n\n/**\n * Build a best-effort prefix snippet for a kNN-only hit: the first\n * `windowTokens` whitespace-separated words of `content`, with any word whose\n * lowercase form is in the query-term set wrapped in `<<…>>` (mirroring FTS5's\n * `<<match>>` marker convention so BM25 and kNN snippets read the same way).\n *\n * Unlike FTS5's `snippet()` this is a PREFIX window (FTS5 picks the densest\n * window around match positions; vec0 gives us no match positions to center\n * on). Content shorter than the window is returned whole (highlighted) — it is\n * never padded or mid-word split.\n */\nexport function windowSnippet(content: string, query: string, windowTokens: number): string {\n const trimmed = content.trim();\n if (trimmed.length === 0) return '';\n const terms = queryTermSet(query);\n const words = trimmed.split(/\\s+/);\n const slice = words.slice(0, Math.max(1, windowTokens));\n return (\n slice\n .map((w) => {\n // Compare the alphanumeric core so trailing punctuation doesn't defeat\n // the match (e.g. `ContextEngine,` → core `contextengine`). We wrap the\n // ORIGINAL word, preserving punctuation, when its core matches.\n const core = w.toLowerCase().match(/[a-z0-9]+/)?.[0];\n if (core && terms.has(core)) return `<<${w}>>`;\n return w;\n })\n .join(' ')\n // Mark truncation only when content genuinely exceeded the window, so a\n // caller can tell a full short snippet from a truncated long one.\n .concat(words.length > slice.length ? ' …' : '')\n );\n}\n\n// ---------------------------------------------------------------------------\n// Collapse + budget\n// ---------------------------------------------------------------------------\n\n/**\n * Collapse duplicate parent-docs: keep the FIRST (top-scoring, since `hits` is\n * already RRF-sorted desc) chunk per `parentDocId`. A hit with an EMPTY\n * `parentDocId` (an unhydrated kNN-only hit) collapses on its own `id`, so the\n * missing parent link can never cause two unrelated semantic hits to merge.\n */\nfunction collapseByParent(hits: ReadonlyArray<RetrieverHit>): RetrieverHit[] {\n const seen = new Set<string>();\n const out: RetrieverHit[] = [];\n for (const hit of hits) {\n const key = hit.parentDocId || hit.id;\n if (seen.has(key)) continue;\n seen.add(key);\n out.push(hit);\n }\n return out;\n}\n\n/** Outcome of a greedy budget pack. */\ninterface PackedResult {\n hits: RetrieverHit[];\n consumedTokens: number;\n truncated: boolean;\n}\n\n/**\n * Greedy token-budget fill over the (already collapsed) ranked list: accumulate\n * `estimateTokens(snippet)` per hit until `budgetTokens` is reached. The FIRST\n * hit is always admitted even if it alone exceeds the budget (returning zero\n * results for one over-large top hit is worse than a small budget overshoot).\n * `truncated` is `true` iff the budget stopped the iteration before the list was\n * exhausted (DS-6).\n */\nfunction packBudget(hits: ReadonlyArray<RetrieverHit>, budgetTokens: number): PackedResult {\n let consumed = 0;\n const packed: RetrieverHit[] = [];\n for (const hit of hits) {\n const tokens = estimateTokens(hit.snippet);\n if (packed.length > 0 && consumed + tokens > budgetTokens) {\n return { hits: packed, consumedTokens: consumed, truncated: true };\n }\n packed.push(hit);\n consumed += tokens;\n }\n return { hits: packed, consumedTokens: consumed, truncated: false };\n}\n\n// ---------------------------------------------------------------------------\n// Factory\n// ---------------------------------------------------------------------------\n\n/**\n * Build a hybrid retriever over an injected store + embedder.\n *\n * The retriever owns no state beyond its configured tunables; every `search`\n * call is independent and side-effect-free (read-only against the store). It is\n * the ONLY read path for context — `context_search` (t9) delegates here. Write\n * (`indexDoc`/`upsertVec`) stays with the indexer/engine; the retriever never\n * mutates the store.\n */\nexport function createRetriever(deps: RetrieverDeps): Retriever {\n const { store, embed } = deps;\n const k = deps.opts?.k ?? DEFAULT_RRF_K;\n // `weights` is passed through verbatim; fuseRrf applies its own [0.5, 0.5]\n // default when undefined. (DEFAULT_RRF_WEIGHTS is `readonly` while\n // FuseRrfOptions.weights is a mutable tuple, so defaulting here would need a\n // copy — cleaner to let fuseRrf, which already handles the default, do it.)\n const weights = deps.opts?.weights;\n const defaultBudget = deps.opts?.budgetTokens ?? DEFAULT_BUDGET_TOKENS;\n const snippetWindowTokens = deps.opts?.snippetWindowTokens ?? DEFAULT_SNIPPET_WINDOW_TOKENS;\n const readDoc = deps.opts?.readDoc;\n\n return {\n async search(query: string, opts?: SearchOptions): Promise<SearchResult> {\n const limit = opts?.limit ?? DEFAULT_SEARCH_LIMIT;\n const budgetTokens = opts?.budgetTokens ?? defaultBudget;\n const source = opts?.source;\n\n // --- BM25 leg (always attempted; the cheap, always-available signal) ---\n let ftsHits: FtsHit[] = [];\n let ftsFailed = false;\n try {\n ftsHits = store.searchFt(query, { limit, source });\n } catch {\n // e.g. a foreign read-only DB with no `docs_fts` table. Keep going —\n // the vec leg may still return something; flag degraded.\n ftsFailed = true;\n }\n\n // --- kNN leg (attempted; any failure ⇒ BM25-only degradation, F8) ---\n let knnHits: VecHit[] = [];\n let knnFailed = false;\n try {\n const qvec = await embed(query); // throws on kind:'none' / load / provider error\n try {\n knnHits = store.knn(qvec, { limit, source });\n } catch {\n // vec table missing or query malformed — degrade to BM25-only.\n knnFailed = true;\n }\n } catch {\n // embed() threw: the embedder is unavailable. BM25-only (F8).\n knnFailed = true;\n }\n\n // --- RRF fusion (rank-based; raw BM25+cosine scores NEVER summed) ---\n const fused = fuseRrf(ftsHits, knnHits, { k, weights });\n\n // Index BM25 hits by id so each fused row can pick up its verbatim\n // snippet + ChunkMeta in O(1) (a kNN-only id simply misses here).\n const ftsById = new Map<string, FtsHit>();\n for (const h of ftsHits) {\n if (!ftsById.has(h.id)) ftsById.set(h.id, h);\n }\n\n // Track whether ANY kNN-only hit could not be hydrated (no readDoc, or\n // readDoc missed). When true, the search did not deliver full hybrid\n // snippet quality — surface that honestly via mode:'knn' (degraded but\n // distinct from 'bm25-only', which means the kNN leg did not run at all).\n let knnUnhydrated = false;\n\n // --- Enrich each fused row into a public RetrieverHit ---\n const enriched: RetrieverHit[] = fused.map((row) => {\n const fts = ftsById.get(row.id);\n if (fts) {\n // BM25 path: reuse the FTS5 windowed snippet VERBATIM (F7).\n const meta = asChunkMeta(fts.meta);\n return {\n id: row.id,\n source: asSourceKind(row.source),\n score: row.score,\n snippet: fts.snippet,\n path: meta?.path ?? '',\n parentDocId: meta?.parentDocId ?? '',\n meta: toRetrieverMeta(meta),\n };\n }\n // kNN-only path: hydrate content + meta to window-extract a snippet.\n if (readDoc) {\n const doc = readDoc(row.id);\n if (doc) {\n const meta = asChunkMeta(doc.meta);\n return {\n id: row.id,\n source: asSourceKind(row.source),\n score: row.score,\n snippet: windowSnippet(doc.content, query, snippetWindowTokens),\n path: meta?.path ?? '',\n parentDocId: meta?.parentDocId ?? '',\n meta: toRetrieverMeta(meta),\n };\n }\n }\n // Unhydratable kNN-only hit: keep the rank, emit an empty snippet.\n // (vec0 carries no meta; the Store has no read-by-id; no readDoc\n // wired, OR readDoc returned null because the source doc was\n // deleted/degraded.) Flag so the reported mode reflects the truth.\n knnUnhydrated = true;\n return {\n id: row.id,\n source: asSourceKind(row.source),\n score: row.score,\n snippet: '',\n path: '',\n parentDocId: '',\n meta: {},\n };\n });\n\n // Mode truthfulness (C1): 'bm25-only' when the kNN leg failed entirely;\n // 'knn' when the kNN leg ran but at least one kNN-only hit couldn't be\n // hydrated (rank delivered, snippet not); 'hybrid' when both legs ran\n // and every hit got a real snippet.\n const mode: SearchMode = knnFailed ? 'bm25-only' : knnUnhydrated ? 'knn' : 'hybrid';\n const degraded = knnFailed || ftsFailed || knnUnhydrated;\n\n // --- Collapse duplicate parent-docs, then pack to the token budget ---\n const collapsed = collapseByParent(enriched);\n const packed = packBudget(collapsed, budgetTokens);\n\n return {\n results: packed.hits,\n consumedTokens: packed.consumedTokens,\n truncated: packed.truncated,\n degraded,\n mode,\n };\n },\n };\n}\n","// ContextEngine for @noir-ai/context (slice S6, task t8).\n//\n// The single object that ties the embedder, indexer, and retriever together and\n// that the daemon injects as `ctx.context` — the new optional ServerContext\n// service, mirroring `ctx.store` / `ctx.engine`. It is constructed ONCE per\n// serve lifecycle from the daemon's already-open Store handle (the single\n// writer — blueprint D6: in-process, no sidecar, canonical ProjectId) and a\n// resolved EmbedderConfig, exactly as `buildWorkflowEngine` is built once from\n// the same handle.\n//\n// Responsibilities:\n// • resolve the embedder once (`createEmbedFn`) and share it between the\n// indexer (writes vectors) and the retriever (embeds the query), so a lazy\n// local model loads at most once per lifecycle;\n// • own the indexer + retriever, delegating `indexPaths` / `search` to them;\n// • surface a `status()` snapshot (spec F11) that mirrors `buildStoreStatus`\n// and adds the embedder description + the indexed-file count.\n//\n// Single-writer discipline: the engine — through its indexer — is the ONLY\n// thing that calls `indexDoc`/`upsertVec` for context. The retriever is purely\n// read-only. Both reuse the injected handle; the engine never opens a second\n// store connection.\n//\n// Degradation (mirrors the store's degraded story, spec F8/F12):\n// • engine-level `degraded` = the store handle is read-only (the daemon-down\n// fallback) OR the embedder is disabled (`kind:'none'`). It is a PERSISTENT\n// flag reported by `status()`.\n// • per-call `degraded` (on SearchResult) is computed by the retriever for\n// each query (true when the kNN leg failed THAT call). The two are\n// independent and both honest: `status()` describes the configured state;\n// `search()` describes the actual outcome.\n//\n// kNN-only hydration (spec F7, C1): the Store interface exposes no read-by-id\n// and vec0 carries no meta column, so a purely-semantic hit (kNN-only, no BM25\n// snippet) cannot be windowed without a content source. The engine wires the\n// indexer's `readChunkContent` as the retriever's `readDoc` hydrator — the\n// indexer owns the registry layout (`ctx:registry` + `ctx:file:<key>`) and the\n// chunk-id format, so it is the natural read-by-id. When the hydrator hits, the\n// chunk's CLEAN content is prefix-windowed with `<<query-term>>` highlights\n// (mirroring FTS5). When it misses (chunk unindexed, file deleted, content\n// drift), the hit degrades to an empty snippet AND the search result's `mode`\n// becomes `'knn'` so the caller knows the snippet quality is degraded (the\n// retriever's honest-mode logic, C1).\n\nimport { createEmbedFn } from './embedders/index.js';\nimport { CTX_REGISTRY_KEY, createIndexer, type Indexer, type IndexPathOptions } from './indexer.js';\nimport { createRetriever, type Retriever, type SearchOptions } from './retriever.js';\nimport type {\n EmbedderConfig,\n EmbedderInfo,\n IndexResult,\n ProjectId,\n SearchResult,\n Store,\n} from './types.js';\n\n// ---------------------------------------------------------------------------\n// Status payload (mirrors StoreStatus in @noir-ai/daemon server.ts)\n// ---------------------------------------------------------------------------\n\n/**\n * JSON returned by the `context_status` MCP tool (spec F11). Mirrors\n * `StoreStatus` and adds the active embedder description + the indexed-file\n * count (the size of the `ctx:registry` KV list the indexer maintains).\n */\nexport interface ContextStatus {\n ok: boolean;\n /** Canonical project id (never a filesystem path — blueprint D6). */\n projectId: string;\n /** Rows in `docs` (live read off the single writer handle — no cache). */\n docCount: number;\n /** Rows in `vec0` (live read off the single writer handle — no cache). */\n vecCount: number;\n /** Number of files currently tracked in `ctx:registry`. */\n indexedFiles: number;\n /** Active embedder description (`kind:'none'` ⇒ `{kind:'none', dim:0}`). */\n embedder: EmbedderInfo;\n /**\n * Persistent degradation flag: the store handle is read-only OR the embedder\n * is disabled (`kind:'none'`). Per-query degradation lives on SearchResult.\n */\n degraded: boolean;\n}\n\n// ---------------------------------------------------------------------------\n// Construction options\n// ---------------------------------------------------------------------------\n\n/** Construction options for {@link ContextEngine}. */\nexport interface ContextEngineOptions {\n /**\n * The daemon's store handle — the ONLY storage surface used (single writer).\n * May be a read-only fallback handle; pass {@link storeDegraded} so `status()`\n * can report it.\n */\n store: Store;\n /**\n * Project root. Paths passed to {@link ContextEngine.indexPaths} resolve\n * against it and (because the indexer is root-bound) are stored repo-relative\n * for portable `meta.path` across checkouts.\n */\n root: string;\n /** Canonical project identifier (NEVER a filesystem path). */\n projectId: ProjectId;\n /**\n * Resolved embedder config (from `resolveEmbedderConfig`). Construction never\n * touches the network or native runtime — even `local` defers its dynamic\n * import to the first `embed()` call.\n */\n embedderCfg: EmbedderConfig;\n /**\n * True when `store` was opened read-only (the daemon-down fallback). Threads\n * the store's degraded story into the engine's persistent `degraded` flag so\n * `status()` reports it and callers can branch on it.\n */\n storeDegraded?: boolean;\n}\n\n// ---------------------------------------------------------------------------\n// Engine\n// ---------------------------------------------------------------------------\n\n/**\n * Noir's embedded hybrid retrieval engine — the `ctx.context` service.\n *\n * Constructed once per serve lifecycle (mirror `buildWorkflowEngine`) from the\n * daemon's store handle + a resolved {@link EmbedderConfig}. Resolves the\n * embedder, then owns the indexer (the only context writer) and the retriever\n * (the only context reader). Public surface: {@link indexPaths}, {@link search},\n * {@link status} — the three operations the `context_{index,search,status}` MCP\n * tools (task t9) delegate to.\n */\nexport class ContextEngine {\n /** The daemon's single-writer store handle (possibly read-only). */\n readonly store: Store;\n /** Project root (paths resolve against this). */\n readonly root: string;\n /** Canonical project identifier. */\n readonly projectId: ProjectId;\n /** Description of the active embedder (surfaced by `status()`). */\n readonly embedder: EmbedderInfo;\n /**\n * Persistent degradation flag (read-only store OR `kind:'none'`). Per-query\n * degradation lives on {@link SearchResult}.\n */\n readonly degraded: boolean;\n\n private readonly indexer: Indexer;\n private readonly retriever: Retriever;\n\n constructor(opts: ContextEngineOptions) {\n this.store = opts.store;\n this.root = opts.root;\n this.projectId = opts.projectId;\n\n // Resolve the embedder ONCE; share it between the indexer (writes vectors)\n // and the retriever (embeds the query) so a lazy local model loads at most\n // once per serve lifecycle (the daemon owns a single ContextEngine).\n const { embed, info } = createEmbedFn(opts.embedderCfg);\n this.embedder = info;\n\n // Persistent degradation: read-only store OR vectors explicitly disabled.\n // (A misconfigured remote/ollama embedder is NOT degraded here — it builds\n // cleanly and surfaces its failure per-call via SearchResult.degraded, F8.)\n this.degraded = opts.storeDegraded === true || info.kind === 'none';\n\n // Both reuse the SAME injected handle — the engine never opens a second\n // connection (single writer). The indexer is root-bound so path keys are\n // repo-relative; the retriever is read-only and uses the retriever defaults\n // (RRF k=60 / [0.5,0.5], budget 4096). The retriever's `readDoc` hydrator\n // is wired to the indexer's `readChunkContent` so kNN-only hits hydrate to\n // a real windowed snippet from the source file (C1); a miss surfaces as\n // mode:'knn' on the per-call SearchResult (degraded but honest).\n this.indexer = createIndexer({ store: opts.store, embed, info, root: opts.root });\n this.retriever = createRetriever({\n store: opts.store,\n embed,\n opts: { readDoc: (id) => this.indexer.readChunkContent(id) },\n });\n }\n\n /**\n * Incrementally index `paths` (files or directories) into the store. Delegates\n * to the indexer (spec F1/F3/F4). The engine — through the indexer — is the\n * ONLY context writer; the daemon stays the single writer via this handle.\n */\n indexPaths(paths: string[], opts?: IndexPathOptions): Promise<IndexResult> {\n return this.indexer.indexPaths(paths, opts);\n }\n\n /**\n * Hybrid search: BM25 ∪ cosine-kNN fused by RRF (k=60), collapsed by\n * parent-doc, packed to a token budget with window-extracted snippets (spec\n * F6/F7). Delegates to the retriever. The per-call `degraded`/`mode` on the\n * returned {@link SearchResult} reflect THIS query's outcome (independent of\n * the engine's persistent {@link degraded}).\n */\n search(query: string, opts?: SearchOptions): Promise<SearchResult> {\n return this.retriever.search(query, opts);\n }\n\n /**\n * Snapshot the engine's state (spec F11; mirrors `buildStoreStatus`).\n *\n * `docCount`/`vecCount` are live reads off the single writer handle (no\n * cache); `indexedFiles` is the size of the `ctx:registry` KV list maintained\n * by the indexer; `embedder` describes the active provider; `degraded` is the\n * persistent flag (read-only store OR `kind:'none'`).\n */\n status(): ContextStatus {\n const registry = this.store.getState<string[]>(CTX_REGISTRY_KEY) ?? [];\n return {\n ok: true,\n projectId: this.store.projectId,\n docCount: this.store.countDocs(),\n vecCount: this.store.countVecs(),\n indexedFiles: registry.length,\n embedder: this.embedder,\n degraded: this.degraded,\n };\n }\n}\n","// Context-local types for @noir-ai/context (slice S6).\n//\n// These are the package's OWN interfaces — the storage surface (`Store`,\n// `EmbedFn`, `IndexDoc`, `FtsHit`, `VecHit`) is re-exported from\n// @noir-ai/store below, and the user-facing zod schema lives in @noir-ai/core\n// (`NoirConfigSchema.context`). There is deliberately NO zod here: core owns\n// the user schema and context owns this factory/engine type surface, which\n// keeps the dependency graph acyclic (core never imports context).\n//\n// Conventions mirror @noir-ai/store types (JSDoc on every interface/field)\n// and @noir-ai/workflow types (`as const` source tables → derived unions).\n\n// ---------------------------------------------------------------------------\n// Source taxonomy\n// ---------------------------------------------------------------------------\n\n/**\n * Logical source buckets a chunk can belong to. Reused by S7 (memory) —\n * `'memory'` is reserved for that slice. The store treats `source` as a\n * free-form string; this enum is the context package's contract for what a\n * well-formed `source` value is.\n */\nexport const SOURCES = ['codebase', 'docs', 'spec', 'memory'] as const;\nexport type SourceKind = (typeof SOURCES)[number];\n\n// ---------------------------------------------------------------------------\n// Embedder configuration + info\n// ---------------------------------------------------------------------------\n\n/** Discriminant for {@link EmbedderConfig}. */\nexport type EmbedderKind = 'local' | 'remote' | 'ollama' | 'none';\n\n/**\n * Local in-process embeddings via `@huggingface/transformers`\n * (default `Xenova/all-MiniLM-L6-v2`, 384-dim → zero vec0 migration).\n * The model is loaded lazily on first `embed()` call, never at import time.\n */\nexport interface LocalEmbedderConfig {\n kind: 'local';\n /** HF repo id (defaults to `Xenova/all-MiniLM-L6-v2` in the factory). */\n model?: string;\n}\n\n/**\n * Opt-in remote embeddings (OpenAI / Voyage / Cohere). Provider-explicit:\n * sends source text to a cloud endpoint. Vectors are L2-normalized and\n * Matryoshka-truncated to `dim` client-side to stay vec0-compatible.\n * NEVER the default (blueprint D6 — no silent paid calls).\n */\nexport interface RemoteEmbedderConfig {\n kind: 'remote';\n /** Provider key, e.g. `'openai'` | `'voyage'` | `'cohere'`. */\n provider: string;\n /** API key. If absent the factory throws a clear \"not configured\" error. */\n apiKey?: string;\n /** Provider-specific model id. */\n model: string;\n /** Target dimensionality (must be 384 to match the vec0 table). */\n dim: number;\n}\n\n/**\n * Opt-in Ollama embeddings via a user-supplied base URL. Provider-explicit;\n * never the default.\n */\nexport interface OllamaEmbedderConfig {\n kind: 'ollama';\n /** Base URL of the Ollama server, e.g. `http://localhost:11434`. */\n baseURL: string;\n /** Ollama model tag. */\n model: string;\n}\n\n/**\n * Escape hatch: disable vectors entirely. `search` degrades to BM25-only\n * (`degraded:true, mode:'bm25-only'`).\n */\nexport interface NoneEmbedderConfig {\n kind: 'none';\n}\n\n/**\n * Discriminated configuration for the embedder factory (`createEmbedFn`,\n * task t3). The core zod schema maps onto this via `resolveEmbedderConfig`\n * (task t10) — that mapper is the only bridge between the two layers.\n */\nexport type EmbedderConfig =\n | LocalEmbedderConfig\n | RemoteEmbedderConfig\n | OllamaEmbedderConfig\n | NoneEmbedderConfig;\n\n/**\n * Description of the active embedder, surfaced by `context_status` and\n * recorded in store KV (`ctx:embedder`) so a model swap is detectable.\n * For `kind:'none'`, `dim` is `0` and `model` is `undefined`.\n */\nexport interface EmbedderInfo {\n kind: EmbedderKind;\n model?: string;\n dim: number;\n}\n\n// ---------------------------------------------------------------------------\n// Chunking\n// ---------------------------------------------------------------------------\n\n/** Metadata attached to every chunk (stored as `docs.meta` / `vec.meta`). */\nexport interface ChunkMeta {\n /** Repo-relative or absolute path of the source file. */\n path: string;\n /**\n * `sha256(path)` — stable parent-document id. The budget packer collapses\n * duplicate parent-docs on this field so one file can't flood the results.\n */\n parentDocId: string;\n /** 0-based position of this chunk within its parent file. */\n chunkIndex: number;\n /** Detected language / extension hint (e.g. `'typescript'`, `'markdown'`). */\n language: string;\n /** SHA-256 of this chunk's (post-identifier-explosion) content. */\n sha256: string;\n}\n\n/**\n * A unit of indexed content. One chunk → one `docs` row (FTS-synced) AND one\n * `vec0` row under the SAME `id`, so RRF can join BM25 and kNN hits.\n * `id` is `<sha256(path)>#chunk-<n>` (stable across re-indexing).\n */\nexport interface Chunk {\n id: string;\n source: SourceKind;\n content: string;\n meta: ChunkMeta;\n}\n\n// ---------------------------------------------------------------------------\n// Retrieval\n// ---------------------------------------------------------------------------\n\n/** Pair of non-negative weights `[bm25Weight, kNNWeight]`; defaults `[0.5, 0.5]`. */\nexport type RRFWeights = [number, number];\n\n/**\n * Retrieval mode actually used for a `search` call.\n * - `'hybrid'` — BM25 ∪ kNN fused by RRF, AND every kNN-only hit was hydrated\n * to a real windowed snippet (the ideal outcome).\n * - `'knn'` — the kNN leg ran but at least one kNN-only hit could NOT be\n * hydrated (no `readDoc` hydrator, or the source doc was deleted/degraded),\n * so that hit keeps its rank but carries an empty snippet. Honest about the\n * fact the caller did not receive the full hybrid snippet quality.\n * - `'bm25-only'` — degraded fallback when the embedder is unavailable\n * (`kind:'none'` or `embed()` threw); the kNN leg did not run at all.\n */\nexport type SearchMode = 'hybrid' | 'knn' | 'bm25-only';\n\n/**\n * Secondary metadata carried on a retriever hit. All optional: a BM25 hit\n * reaches the retriever already enriched, while a kNN-only hit may only\n * carry `id` + `source` until the retriever backfills the rest. The primary\n * `path` / `parentDocId` fields live top-level on {@link RetrieverHit}\n * (spec F6); this holds the remaining enrichment.\n */\nexport interface RetrieverMeta {\n language?: string;\n sha256?: string;\n chunkIndex?: number;\n}\n\n/**\n * A single ranked result. `snippet` is FTS5 window-extracted\n * (`snippet(docs_fts,0,'<<','>>','…',16)`) for BM25 hits, or a window around\n * the chunk's first N tokens for kNN-only hits — never truncated mid-token.\n *\n * `path` and `parentDocId` are top-level (spec F6 enumerates the item as\n * `{id, source, score, snippet, path, parentDocId}`): the retriever\n * backfills them from the chunk's `meta` before the hit leaves `search()`,\n * so they are always present at the public/MCP boundary.\n */\nexport interface RetrieverHit {\n id: string;\n source: SourceKind;\n /** RRF-fused rank score (rank-based; not a normalized similarity). */\n score: number;\n snippet: string;\n /** Repo-relative or absolute path of the source file (backfilled from meta). */\n path: string;\n /** `sha256(path)` — stable parent-document id (backfilled from meta). */\n parentDocId: string;\n /** Secondary enrichment (language, sha256, chunkIndex). */\n meta: RetrieverMeta;\n}\n\n/**\n * Return value of `search`. `results` is packed greedily into `budgetTokens`\n * (default ~4k): when the budget is exhausted before the ranked list,\n * `truncated` is `true` and `consumedTokens` ~= `budgetTokens`.\n */\nexport interface SearchResult {\n results: RetrieverHit[];\n /** Tokens accumulated across the returned snippets. */\n consumedTokens: number;\n /** `true` if the budget was hit before exhausting the ranked list. */\n truncated: boolean;\n /** `true` when the embedder was unavailable (BM25-only fallback, F8). */\n degraded: boolean;\n /** Retrieval mode actually used. */\n mode: SearchMode;\n}\n\n// ---------------------------------------------------------------------------\n// Indexing\n// ---------------------------------------------------------------------------\n\n/**\n * Return value of `indexPaths`. Counts are mutually consistent: a chunk is\n * either newly `indexed`, `skipped` (content-hash unchanged in KV), or part\n * of a `deleted`/`failed` file. `totalChunks` is the running total tracked\n * for the indexed paths after this call. `degraded` mirrors the search path\n * (spec F10: `context_index -> {indexed, skipped, deleted, degraded}`).\n */\nexport interface IndexResult {\n /** Chunks newly written to `docs` + `vec0` this run. */\n indexed: number;\n /** Chunks whose SHA-256 matched KV (unchanged since last index). */\n skipped: number;\n /** Files removed since last index — their chunks + vectors were deleted. */\n deleted: number;\n /**\n * True when the embedder was unavailable this run (docs indexed without\n * vectors). Mirrors SearchResult.degraded / StoreStatus.degraded.\n */\n degraded: boolean;\n /** Files that could not be read/parsed (binary, IO error, encoding). */\n failed: number;\n /** Total chunks now tracked for the indexed paths. */\n totalChunks: number;\n}\n\n// ---------------------------------------------------------------------------\n// Re-exports (single import surface for the rest of the package)\n// ---------------------------------------------------------------------------\n\n// Canonical project identifier (NEVER a filesystem path — blueprint D6).\nexport type { ProjectId } from '@noir-ai/core';\n// The storage seam S6 builds on. Re-exported here so context modules import\n// from `../types.js` (or the barrel) rather than reaching into @noir-ai/store\n// directly for the handful of primitives they need.\nexport type {\n EmbedFn,\n FtsHit,\n IndexDoc,\n Store,\n VecHit,\n} from '@noir-ai/store';\n"],"mappings":"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","names":["readErrorBody","createHash","createHash"]}
|
|
1
|
+
{"version":3,"sources":["../src/hash.ts","../src/chunker.ts","../src/config.ts","../src/embedders/local.ts","../src/embedders/normalize.ts","../src/embedders/ollama.ts","../src/embedders/remote.ts","../src/embedders/fake.ts","../src/embedders/index.ts","../src/indexer.ts","../src/rrf.ts","../src/retriever.ts","../src/contextEngine.ts","../src/dedup.ts","../src/types.ts"],"sourcesContent":["// Shared SHA-256 helper for @noir-ai/context.\n//\n// Both the chunker (parentDocId / chunkId keys) and the indexer\n// (file-content skip key) hash with the same UTF-8 SHA-256 hex digest;\n// keep ONE definition here so the two cannot drift.\n\nimport { createHash } from 'node:crypto';\n\n/** UTF-8 SHA-256 hex digest. */\nexport function sha256Hex(text: string): string {\n return createHash('sha256').update(text, 'utf8').digest('hex');\n}\n","// Chunker for @noir-ai/context (slice S6, task t4).\n//\n// Splits a file's content into embeddable/indexable `Chunk`s:\n// • Markdown (`.md`/`.mdx`) → one chunk per ATX-heading-delimited section\n// (code-fence-aware, so `#`-comment lines inside ``` blocks are NOT treated\n// as headings). The heading line is kept with its body so the heading text\n// carries a BM25 signal (spec DS-5).\n// • Everything else → line/token-bounded windows (~512 tokens, 64-token\n// overlap by default) via a cheap whitespace token estimate. Windows align\n// to line boundaries (never split mid-line); consecutive windows overlap by\n// roughly `overlap` tokens of trailing lines so retrieval has continuity.\n//\n// `id = \\`${sha256(path)}#chunk-${n}\\`` and `meta.parentDocId = sha256(path)`\n// are stable across re-indexing — same path + content always yields the same\n// chunk ids, which is what makes the indexer's content-hash skip/delete exact\n// (spec DS-4, §7).\n//\n// `explodeIdentifiers` (DS-7) is exported here and used to derive\n// `meta.sha256` (post-identifier-explosion, per the ChunkMeta contract): the\n// indexer appends the same explosion stream to chunk content before\n// `indexDoc`/`upsertVec` so camelCase/snake_case identifier queries get a BM25\n// signal under the existing `porter unicode61` tokenizer — with NO schema\n// migration (trigram is deferred, OQ-6). `withIdentifierExplosion` is the\n// single canonical form of that append, so the chunker's hash and the\n// indexer's stored content stay byte-identical.\n\nimport { sha256Hex } from './hash.js';\nimport type { Chunk, SourceKind } from './types.js';\n\n// ---------------------------------------------------------------------------\n// Tunable defaults (mirror the `context.chunk` config block, task t10)\n// ---------------------------------------------------------------------------\n\n/** Default maximum estimated tokens per non-markdown chunk (spec DS-5). */\nexport const DEFAULT_CHUNK_MAX_TOKENS = 512;\n\n/** Default token overlap between consecutive non-markdown chunks (spec DS-5). */\nexport const DEFAULT_CHUNK_OVERLAP = 64;\n\n/**\n * Cheap token estimate factor: ~1.3 tokens per whitespace-separated word (the\n * canonical \"1 word ≈ 1.3 tokens\" heuristic). Good enough for windowing/budget\n * packing without pulling in a tokenizer dep.\n */\nexport const TOKEN_ESTIMATE_FACTOR = 1.3;\n\n// ---------------------------------------------------------------------------\n// Options\n// ---------------------------------------------------------------------------\n\n/**\n * Input to {@link chunkFile}. `path` + `content` are required; everything else\n * has a sensible default. `maxTokens`/`overlap` flow from the resolved\n * `context.chunk` config (task t10); `language`/`source` are inferred from the\n * path when omitted so a bare `chunkFile({path, content})` call just works.\n */\nexport interface ChunkOptions {\n /** Repo-relative or absolute path of the source file. */\n path: string;\n /** Full text content of the file (the caller reads it; the chunker never does I/O). */\n content: string;\n /**\n * Language hint (e.g. `'typescript'`, `'markdown'`). Inferred from the path\n * extension when absent (see {@link inferLanguage}).\n */\n language?: string;\n /**\n * Override the inferred {@link SourceKind}. Defaults to `'docs'` for\n * markdown and `'codebase'` otherwise; pass `'spec'`/`'memory'` when indexing\n * those trees explicitly.\n */\n source?: SourceKind;\n /** Max estimated tokens per code chunk (default {@link DEFAULT_CHUNK_MAX_TOKENS}). */\n maxTokens?: number;\n /** Token overlap between consecutive code chunks (default {@link DEFAULT_CHUNK_OVERLAP}). */\n overlap?: number;\n}\n\n// ---------------------------------------------------------------------------\n// Identifier explosion (DS-7)\n// ---------------------------------------------------------------------------\n\n/**\n * Split the identifiers in `text` into lowercase tokens, expanding\n * camelCase / PascalCase / snake_case / kebab-case / digit boundaries.\n *\n * Examples:\n * `contextEngine` → `context engine`\n * `ContextEngine` → `context engine`\n * `XMLHttpRequest` → `xml http request`\n * `myHTTPSConnection` → `my https connection`\n * `snake_case`/`kebab-case` → `snake case` / `kebab case`\n * `ctx:file:<path>` → `ctx file path`\n *\n * The stream is APPENDED to chunk content at index time (see\n * {@link withIdentifierExplosion}) so identifier queries match under\n * `porter unicode61` without a tokenizer migration. Pure/deterministic.\n */\nexport function explodeIdentifiers(text: string): string {\n // Match runs of [A-Za-z0-9]. '-' and '_' are intentionally excluded so\n // kebab/snake identifiers split at their separators for free; camelCase /\n // PascalCase boundaries are then split with two case-transition regexes.\n const words = text.match(/[A-Za-z0-9]+/g);\n if (!words) return '';\n const tokens: string[] = [];\n for (const word of words) {\n const spaced = word\n // lowercase|digit → uppercase: myVar -> my Var | HttpContext -> Http Context\n .replace(/([a-z0-9])([A-Z])/g, '$1 $2')\n // uppercase-run → uppercase+lowercase: XMLHttp -> XML Http | HTTPSConn -> HTTPS Conn\n .replace(/([A-Z]+)([A-Z][a-z])/g, '$1 $2');\n for (const piece of spaced.split(/\\s+/)) {\n if (piece.length > 0) tokens.push(piece.toLowerCase());\n }\n }\n return tokens.join(' ');\n}\n\n/**\n * The canonical index-time content for a chunk: its clean text followed by the\n * identifier-exploded token stream on a trailing line. Both the FTS row and\n * the embedding are produced from this exact string (so identifier queries get\n * a BM25 AND a semantic signal), and {@link chunkSha256} hashes this form to\n * match the `ChunkMeta.sha256` contract (\"post-identifier-explosion\").\n *\n * `chunk.content` itself stays CLEAN (no explosion) so FTS5 window-extracted\n * snippets read naturally — explosion is index-time-only (spec §7, DS-7).\n */\nexport function withIdentifierExplosion(content: string): string {\n const exploded = explodeIdentifiers(content);\n return exploded.length > 0 ? `${content}\\n${exploded}` : content;\n}\n\n// ---------------------------------------------------------------------------\n// Token estimation (shared with the retriever's budget packer)\n// ---------------------------------------------------------------------------\n\n/**\n * Cheap token-count proxy: ~1.3 tokens per whitespace-separated word (see\n * {@link TOKEN_ESTIMATE_FACTOR}). Empty/whitespace-only text → 0. Used both\n * for chunk windowing and (exported) for the retriever's budget packing.\n */\nexport function estimateTokens(text: string): number {\n const trimmed = text.trim();\n if (trimmed.length === 0) return 0;\n const words = trimmed.split(/\\s+/).filter((w) => w.length > 0);\n return Math.ceil(words.length * TOKEN_ESTIMATE_FACTOR);\n}\n\n// ---------------------------------------------------------------------------\n// Language + source inference\n// ---------------------------------------------------------------------------\n\nconst EXT_TO_LANGUAGE: Readonly<Record<string, string>> = {\n ts: 'typescript',\n tsx: 'tsx',\n mts: 'typescript',\n cts: 'typescript',\n js: 'javascript',\n jsx: 'jsx',\n mjs: 'javascript',\n cjs: 'javascript',\n md: 'markdown',\n mdx: 'markdown',\n py: 'python',\n rb: 'ruby',\n rs: 'rust',\n go: 'go',\n java: 'java',\n kt: 'kotlin',\n json: 'json',\n yml: 'yaml',\n yaml: 'yaml',\n toml: 'toml',\n sh: 'shell',\n bash: 'shell',\n zsh: 'shell',\n sql: 'sql',\n html: 'html',\n htm: 'html',\n css: 'css',\n scss: 'scss',\n xml: 'xml',\n txt: 'text',\n};\n\n/**\n * Best-effort language tag from the path extension (e.g. `.ts` → `'typescript'`).\n * Unknown extensions fall back to `'text'`. Used as the `language` default and\n * recorded per-file in the indexer's KV.\n */\nexport function inferLanguage(path: string): string {\n const ext = path.toLowerCase().match(/\\.([a-z0-9]+)$/)?.[1];\n if (!ext) return 'text';\n return EXT_TO_LANGUAGE[ext] ?? 'text';\n}\n\n/** Markdown is detected by extension OR an explicit `language: 'markdown'`. */\nfunction isMarkdown(path: string, language?: string): boolean {\n if (language === 'markdown') return true;\n return /\\.(md|mdx)$/i.test(path);\n}\n\n/**\n * Default {@link SourceKind}: markdown → `'docs'`, everything else →\n * `'codebase'`. Callers indexing the spec tree or (S7) memory override via\n * {@link ChunkOptions.source}.\n */\nfunction defaultSource(path: string, language?: string): SourceKind {\n return isMarkdown(path, language) ? 'docs' : 'codebase';\n}\n\n// ---------------------------------------------------------------------------\n// Hashing\n// ---------------------------------------------------------------------------\n\n/** `parentDocId` + `chunkId` root — `sha256(path)`, stable across re-index. */\nfunction parentDocIdOf(path: string): string {\n return sha256Hex(path);\n}\n\n/**\n * SHA-256 over a chunk's POST-identifier-explosion content (the exact string\n * the indexer stores/embeds), per the `ChunkMeta.sha256` contract. Reproducible\n * via {@link withIdentifierExplosion}.\n */\nfunction chunkSha256(content: string): string {\n return sha256Hex(withIdentifierExplosion(content));\n}\n\n// ---------------------------------------------------------------------------\n// Markdown sectioning (code-fence-aware ATX-heading split)\n// ---------------------------------------------------------------------------\n\nconst ATX_HEADING = /^(#{1,6})\\s/;\nconst FENCE_OPEN = /^\\s*(`{3,}|~{3,})/;\n\n/**\n * Split markdown into one section per ATX heading. Each section's body is the\n * heading line + its non-heading lines. `#`-comment lines inside a `` ` `` or\n * `~~~` code fence are NOT treated as headings (common in docs with code\n * samples — guards against false splits). A non-empty preamble before the\n * first heading becomes its own section.\n */\nfunction markdownSections(content: string): string[] {\n const lines = content.split('\\n');\n const sections: string[] = [];\n let current: string[] = [];\n let inFence = false;\n let fenceMarker = '';\n\n const flush = (): void => {\n if (current.length === 0) return;\n const body = current.join('\\n').replace(/\\s+$/, '');\n if (body.length > 0) sections.push(body);\n current = [];\n };\n\n for (const line of lines) {\n // Track code fences so `#` lines inside them don't read as headings.\n const fence = FENCE_OPEN.exec(line);\n if (fence?.[1]) {\n const marker = fence[1].charAt(0);\n if (!inFence) {\n inFence = true;\n fenceMarker = marker;\n } else if (marker === fenceMarker) {\n inFence = false;\n fenceMarker = '';\n }\n current.push(line);\n continue;\n }\n if (!inFence && ATX_HEADING.test(line)) {\n flush();\n current = [line];\n } else {\n current.push(line);\n }\n }\n flush();\n return sections;\n}\n\n// ---------------------------------------------------------------------------\n// Code windowing (line-bounded, token-budgeted, overlapping)\n// ---------------------------------------------------------------------------\n\n/**\n * Split code/general text into line-bounded windows of ~`maxTokens` estimated\n * tokens with ~`overlap` tokens of trailing-line overlap between consecutive\n * windows.\n *\n * Properties:\n * • Windows never split a line mid-line — a single line that alone exceeds\n * `maxTokens` is emitted as its own chunk (rather than being dropped).\n * • Consecutive multi-line windows always overlap by at least one line, so a\n * retrieval hit near a boundary isn't lost (and the overlap is observable).\n * • Pure/deterministic: identical input → identical windows. Forward progress\n * is guaranteed (each iteration advances `start` by ≥ 1).\n */\nfunction codeWindows(content: string, maxTokens: number, overlap: number): string[] {\n const lines = content.split('\\n');\n const n = lines.length;\n if (n === 0) return [];\n\n const windows: string[] = [];\n let start = 0;\n // Safety guard against any logic error turning this into a spin (each\n // iteration advances `start`, so `n` iterations is the real bound).\n let safety = 2 * n + 8;\n\n while (start < n && safety > 0) {\n safety -= 1;\n // Extend [start, end): accumulate lines while estimated tokens ≤ maxTokens.\n // The first line is always admitted (even if it alone exceeds the budget).\n let end = start;\n let body = '';\n for (let i = start; i < n; i++) {\n const line = lines[i];\n if (line === undefined) break;\n const trial = body.length === 0 ? line : `${body}\\n${line}`;\n if (i > start && estimateTokens(trial) > maxTokens) break;\n body = trial;\n end = i + 1;\n }\n if (body.length > 0) windows.push(body);\n\n if (end >= n) break;\n // Single-line window or no-progress: advance past it (no overlap possible).\n if (end - 1 <= start) {\n start = end;\n continue;\n }\n // Multi-line window: step back from `end` toward `start` carrying lines\n // until their estimated tokens reach `overlap`. Clamp into (start, end-1]\n // for guaranteed overlap + forward progress.\n let carryStart = end - 1;\n let carried = 0;\n while (carryStart > start && carried < overlap) {\n const line = lines[carryStart];\n if (line === undefined) {\n carryStart -= 1;\n continue;\n }\n carried += estimateTokens(line);\n if (carried >= overlap) break;\n carryStart -= 1;\n }\n if (carryStart <= start) carryStart = start + 1;\n if (carryStart > end - 1) carryStart = end - 1;\n start = carryStart;\n }\n return windows;\n}\n\n// ---------------------------------------------------------------------------\n// Public entry point\n// ---------------------------------------------------------------------------\n\n/**\n * Split a file's content into {@link Chunk}s.\n *\n * Markdown → heading-delimited sections; everything else → overlapping\n * line/token windows. Each chunk's `id` is `<sha256(path)>#chunk-<n>` and\n * `meta.parentDocId` is `sha256(path)` (stable across re-indexing), with\n * `meta.sha256` over the post-identifier-explosion content (see\n * {@link withIdentifierExplosion}). Empty content yields no chunks.\n */\nexport function chunkFile(opts: ChunkOptions): Chunk[] {\n const { path, content } = opts;\n // Reject empty AND whitespace-only content (whitespace has length > 0 but\n // carries no chunk signal — would otherwise yield a spurious blank chunk).\n if (content.trim().length === 0) return [];\n\n const language = opts.language ?? inferLanguage(path);\n const source = opts.source ?? defaultSource(path, language);\n const maxTokens = opts.maxTokens ?? DEFAULT_CHUNK_MAX_TOKENS;\n const overlap = opts.overlap ?? DEFAULT_CHUNK_OVERLAP;\n const parentDocId = parentDocIdOf(path);\n\n const bodies = isMarkdown(path, language)\n ? markdownSections(content)\n : codeWindows(content, maxTokens, overlap);\n\n const chunks: Chunk[] = [];\n for (let i = 0; i < bodies.length; i++) {\n const body = bodies[i];\n if (body === undefined || body.length === 0) continue;\n chunks.push({\n id: `${parentDocId}#chunk-${i}`,\n source,\n content: body,\n meta: {\n path,\n parentDocId,\n chunkIndex: i,\n language,\n sha256: chunkSha256(body),\n },\n });\n }\n return chunks;\n}\n","// Embedder config resolver for @noir-ai/context (slice S6).\n//\n// The single bridge from @noir-ai/core's user-facing `context` zod schema to\n// this package's discriminated {@link EmbedderConfig} (the `createEmbedFn`\n// factory input). Lives HERE, in context, so @noir-ai/core never imports\n// @noir-ai/context (no core→context cycle): core owns the user-facing schema,\n// context owns the factory type + this mapper (blueprint / hard rule).\n//\n// Provider-explicit, NEVER silent remote (blueprint D6): `kind:'remote'` and\n// `kind:'ollama'` are only selected when the user explicitly sets them in\n// config. The default is `kind:'local'` — in-process, offline, free, private.\n// Remote API keys are read from environment variables ONLY (secrets stay out of\n// the config file, per the S8 model-layer convention); a missing key yields a\n// config that builds cleanly but throws from `embed()` so the engine degrades\n// to BM25-only (F8) rather than crashing at construction.\n\nimport type { EmbedderConfig } from './types.js';\n\n/**\n * User-facing context config shape — mirrors `NoirConfig['context']` (the zod\n * block @noir-ai/core ships, slice S6). Declared locally with every field\n * optional so this module type-checks WITHOUT a forward dependency on a core\n * type (core never imports context — no cycle), AND so a config with no\n * `context:` block (or a partial one) parses cleanly (AC-7 / NFR-6). The fully\n * resolved zod output is structurally assignable to this permissive shape, so\n * the mapper accepts `NoirConfig['context']` directly.\n */\nexport interface ContextUserConfig {\n embedder?: {\n /** Default `'local'` (in-process transformers.js — offline, free, private). */\n kind?: 'local' | 'remote' | 'ollama' | 'none';\n /** Local HF repo id / remote provider model id / Ollama model tag. */\n model?: string;\n /** Remote provider key, e.g. `'openai'` (only meaningful when `kind:'remote'`). */\n provider?: string;\n /** Ollama base URL, e.g. `http://localhost:11434` (only when `kind:'ollama'`). */\n baseURL?: string;\n /** Target dimensionality (must be 384 to match the existing vec0 table). */\n dim?: number;\n };\n /** Configured index roots (informational here; the daemon/indexer consume them). */\n roots?: string[];\n /** Default token budget for `search` (informational here; consumed by the retriever). */\n budgetTokens?: number;\n}\n\n/**\n * The environment variable that carries the API key for a remote provider, or\n * `undefined` for an unknown provider (no inference — provider-explicit, D6).\n */\nfunction apiKeyEnvVar(provider: string): string | undefined {\n switch (provider) {\n case 'openai':\n return process.env.OPENAI_API_KEY;\n case 'voyage':\n return process.env.VOYAGE_API_KEY;\n case 'cohere':\n return process.env.COHERE_API_KEY;\n default:\n return undefined;\n }\n}\n\n/**\n * Resolve a user-facing {@link ContextUserConfig} into the discriminated\n * {@link EmbedderConfig} the factory (`createEmbedFn`) consumes.\n *\n * - `undefined` / missing block ⇒ `{kind:'local'}` (the safe default — a config\n * with no `context:` block stays local-embedder-attempted, AC-7 / NFR-6).\n * - `kind:'local'` ⇒ in-process transformers.js (model optional; factory\n * defaults to `Xenova/all-MiniLM-L6-v2`).\n * - `kind:'none'` ⇒ vectors disabled; `search` degrades to BM25-only.\n * - `kind:'remote'` / `'ollama'` ⇒ provider-explicit; the API key (remote) /\n * base URL (ollama) are resolved from env when absent in config. A missing\n * key is NOT an error here — the built embedder throws from `embed()` so the\n * engine degrades to BM25-only (F8) instead of crashing at construction.\n *\n * NEVER returns a remote/ollama config unless `kind` is explicitly that value —\n * there is no path from the default to a silent paid call (blueprint D6).\n */\nexport function resolveEmbedderConfig(ctx?: ContextUserConfig): EmbedderConfig {\n const e = ctx?.embedder;\n switch (e?.kind) {\n case 'local': {\n const model = e?.model;\n return { kind: 'local', ...(model ? { model } : {}) };\n }\n case 'remote': {\n // `e?.kind === 'remote'` implies `e` is defined; `e?.` is defensive.\n const provider = e?.provider ?? 'openai';\n const model = e?.model ?? '';\n const dim = e?.dim ?? 384;\n const apiKey = apiKeyEnvVar(provider);\n return { kind: 'remote', provider, model, dim, ...(apiKey ? { apiKey } : {}) };\n }\n case 'ollama': {\n const baseURL = e?.baseURL ?? process.env.OLLAMA_BASE_URL ?? '';\n const model = e?.model ?? '';\n return { kind: 'ollama', baseURL, model };\n }\n case 'none':\n return { kind: 'none' };\n default:\n // undefined / unknown kind ⇒ safe local default. NEVER a silent remote call.\n return { kind: 'local' };\n }\n}\n","// Local in-process embedder: `@huggingface/transformers` + all-MiniLM-L6-v2.\n//\n// DESIGN (spec DS-2 / NFR-4):\n// - The `@huggingface/transformers` module is imported LAZILY via dynamic\n// `import()` on the FIRST `embed()` call, never at module top level. This\n// keeps CLI startup and every non-context code path offline and fast, and\n// means a missing/broken `onnxruntime-node` native binary degrades to\n// BM25-only at runtime instead of crashing import (F8).\n// - The ONNX pipeline is created once and memoized for the process lifetime\n// (the daemon owns a single ContextEngine → one pipeline per serve cycle).\n// A failed load resets the memo so the next call can retry (e.g. after a\n// transient first-run download failure).\n// - Model weights cache is pinned to `~/.noir/models/` (HOME-relative — keeps\n// the project `.noir/` dir portable across machines; spec OQ-7 resolved),\n// centralized as `modelsDir()` in @noir-ai/core (task t10). The local\n// `MODELS_DIR` const re-exports that value so this module's existing\n// imports keep resolving to the identical path.\n// - Output is mean-pooled then L2-normalized through the shared `l2normalize`\n// so every provider path funnels through one normalization (DS-8).\n\nimport { mkdirSync } from 'node:fs';\nimport { modelsDir } from '@noir-ai/core';\nimport type { EmbedFn } from '../types.js';\nimport { EMBED_DIM, l2normalize } from './normalize.js';\n\n/** Default HF repo id — 384-dim, matches the vec0 table with zero migration. */\nexport const DEFAULT_LOCAL_MODEL = 'Xenova/all-MiniLM-L6-v2';\n\n/**\n * `~/.noir/models/` — user-global cache for downloaded ONNX weights.\n * HOME-relative (NOT project `.noir/`) so projects stay portable. Centralized\n * via `@noir-ai/core`'s `modelsDir()`; re-exported here so existing imports of\n * `MODELS_DIR` keep resolving to the same path.\n */\nexport const MODELS_DIR = modelsDir();\n\n// Minimal structural view of the transformers.js v3 module surface this file\n// touches. Declared locally (rather than importing the package's deep types) so\n// the dynamic import stays version-robust and the package builds even when the\n// optional native runtime is absent from the install.\ninterface HFTransformers {\n readonly env: { cacheDir?: string };\n pipeline: (\n task: 'feature-extraction',\n model: string,\n options?: { quantized?: boolean },\n ) => Promise<FeatureExtractionPipeline>;\n}\n\ntype FeatureExtractionPipeline = (\n texts: string,\n options?: { pooling?: 'mean' | 'cls' | 'none'; normalize?: boolean },\n) => Promise<{ data: ArrayLike<number>; dims: number[] }>;\n\n/** Result of a lazy load: a per-text embedder backed by a cached pipeline. */\ntype LoadedEmbedder = (text: string) => Promise<Float32Array>;\n\n// Memoized load promises, keyed by model id. One in-flight load per model\n// (the daemon is the single writer / one ContextEngine per serve lifecycle,\n// so typically a single entry; keying by model avoids a silent collision if\n// two different models are ever constructed in the same process). A failed\n// load evicts its own entry so the next call may retry.\nconst pipelineCache = new Map<string, Promise<LoadedEmbedder>>();\n\nasync function loadPipeline(model: string): Promise<LoadedEmbedder> {\n try {\n const mod = (await import('@huggingface/transformers')) as unknown as HFTransformers;\n // Pin the download cache before constructing the pipeline.\n mkdirSync(MODELS_DIR, { recursive: true });\n mod.env.cacheDir = MODELS_DIR;\n const extractor = await mod.pipeline('feature-extraction', model);\n return async (text: string): Promise<Float32Array> => {\n // mean-pool the token embeddings; `l2normalize` (not normalize:true) is\n // the single normalization every provider funnels through (DS-8).\n const out = await extractor(text, { pooling: 'mean' });\n // transformers.js `Tensor.data` is a typed array; copy into a fresh\n // Float32Array of exactly the model's embedding width.\n const dim = out.dims.at(-1) ?? EMBED_DIM;\n const raw = new Float32Array(dim);\n for (let i = 0; i < dim; i++) raw[i] = out.data[i] ?? 0;\n return l2normalize(raw);\n };\n } catch (e) {\n const reason = e instanceof Error ? e.message : String(e);\n throw new Error(\n `local embedder: failed to load model \"${model}\" (is @huggingface/transformers installed and the onnxruntime native binary present?): ${reason}`,\n );\n }\n}\n\n/** Lazily resolve (and memoize) the cached embedder, resetting on failure. */\nfunction getEmbedder(model: string): Promise<LoadedEmbedder> {\n const cached = pipelineCache.get(model);\n if (cached) return cached;\n // Evict this model's entry on rejection so the next call may retry;\n // concurrent callers share the single in-flight load promise for that model.\n const promise = loadPipeline(model).catch((e) => {\n pipelineCache.delete(model);\n throw e;\n });\n pipelineCache.set(model, promise);\n return promise;\n}\n\nexport interface LocalEmbedderOptions {\n /** HF repo id (defaults to {@link DEFAULT_LOCAL_MODEL}). */\n model?: string;\n}\n\nexport interface LocalEmbedder {\n /** Embed function — loads the model lazily on first invocation. */\n embed: EmbedFn;\n /** Resolved model id. */\n model: string;\n}\n\n/**\n * Build a local in-process embedder. Construction is synchronous and never\n * touches the network or the native runtime — the dynamic `import()` happens\n * inside `embed()`, so `localEmbedder()` is safe to call at startup. Load\n * failures surface as rejections from `embed()`; callers (retriever/engine)\n * catch them and degrade to BM25-only (F8).\n */\nexport function localEmbedder(opts: LocalEmbedderOptions = {}): LocalEmbedder {\n const model = opts.model ?? DEFAULT_LOCAL_MODEL;\n return {\n model,\n embed: async (text: string): Promise<Float32Array> => {\n const run = await getEmbedder(model);\n return run(text);\n },\n };\n}\n","// Shared numeric helpers for every embedder in this package.\n//\n// All embedder implementations (local / remote / ollama / fake) funnel their\n// final vector through `l2normalize` so that:\n// - the existing S1 `vec0` table (created with the DEFAULT distance metric)\n// yields cosine-equivalent ranking when vectors are unit-norm (spec DS-8),\n// - remote/ollama vectors that were Matryoshka-truncated to `EMBED_DIM` are\n// re-normalized client-side (truncation shrinks the norm),\n// - the kNN path sees a single, consistent scale regardless of provider.\n//\n// `EMBED_DIM` is the fixed width of the vec0 table (384). It is the single\n// source of truth for the target dimensionality and matches the MiniLM-L6-v2\n// default model — changing it would require a store schema migration, which\n// S6 explicitly does NOT do.\n\n/**\n * Fixed embedding width. Matches the S1 `vec0(float[384])` virtual table and\n * the default `all-MiniLM-L6-v2` model. Remote/ollama vectors are truncated or\n * required to be >= this width (see `remote.ts` / `ollama.ts`).\n */\nexport const EMBED_DIM = 384;\n\n/**\n * Return a unit-norm copy of `vec` (L2 normalization). The input is never\n * mutated. Length is preserved: callers responsible for ensuring the vector is\n * already `EMBED_DIM`-wide (truncate BEFORE normalizing — Matryoshka order\n * matters; normalizing then truncating would distort directions).\n *\n * A zero-length or all-zero / non-finite input cannot be normalized; for those\n * degenerate cases a fresh zeroed vector of the same length is returned rather\n * than inventing directional signal or producing NaN. Real embedders never\n * emit such vectors; this guard exists purely for robustness.\n */\nexport function l2normalize(vec: Float32Array): Float32Array {\n const n = vec.length;\n if (n === 0) return new Float32Array(0);\n\n let sum = 0;\n for (let i = 0; i < n; i++) {\n const v = vec[i];\n if (v !== undefined) sum += v * v;\n }\n const norm = Math.sqrt(sum);\n // norm === 0 (all-zero input) or NaN/Infinity → return zeros, no signal invented.\n if (norm === 0 || !Number.isFinite(norm)) {\n return new Float32Array(n);\n }\n\n const out = new Float32Array(n);\n const inv = 1 / norm;\n for (let i = 0; i < n; i++) {\n const v = vec[i];\n if (v !== undefined) out[i] = v * inv;\n }\n return out;\n}\n","// Opt-in OLLAMA embeddings (local Ollama server).\n//\n// DESIGN (spec DS-2 / blueprint D6):\n// - Provider-explicit and NEVER the default. The caller selects `kind:'ollama'`\n// with a concrete `baseURL`; the blueprint rejects an always-on Ollama\n// sidecar, so this is the supported opt-in path for users who already run\n// Ollama locally.\n// - Like the remote embedder, a missing `baseURL` throws a clear \"not\n// configured\" error from `embed()` (not at construction) so the engine can\n// report status and degrade to BM25-only (F8).\n// - Ollama's `/api/embeddings` returns the model's native-width vector; we\n// Matryoshka-truncate to `EMBED_DIM` (384) and re-normalize via the shared\n// `l2normalize`. A vector shorter than 384 is a hard config error (the user\n// must pick a >= 384-dim Ollama model such as `nomic-embed-text`).\n//\n// Thin stub: direct `fetch`, no retry/batching (post-v0, plan §7).\n\nimport type { EmbedFn } from '../types.js';\nimport { EMBED_DIM, l2normalize } from './normalize.js';\n\nexport interface OllamaEmbedderOptions {\n /** Base URL of the Ollama server, e.g. `http://localhost:11434`. */\n baseURL: string;\n /** Ollama model tag, e.g. `nomic-embed-text`. */\n model: string;\n /** Target dimensionality (defaults to {@link EMBED_DIM}). */\n dim?: number;\n}\n\ninterface OllamaResponse {\n embedding?: number[];\n}\n\n/** Read up to a few hundred chars of the error body for diagnostics. */\nasync function readErrorBody(res: Response): Promise<string> {\n try {\n const text = await res.text();\n return text.slice(0, 500);\n } catch {\n return '<unreadable response body>';\n }\n}\n\n/**\n * Build an Ollama embedder. Construction is synchronous and makes no network\n * calls. Each `embed()` POSTs the source text to `<baseURL>/api/embeddings`.\n */\nexport function ollamaEmbedder(opts: OllamaEmbedderOptions): EmbedFn {\n const targetDim = opts.dim ?? EMBED_DIM;\n const base = opts.baseURL.replace(/\\/+$/, '');\n\n return async (text: string): Promise<Float32Array> => {\n if (!opts.baseURL) {\n throw new Error(\n 'ollama embedder is not configured: baseURL is required (set context.embedder.baseURL, e.g. http://localhost:11434)',\n );\n }\n\n const res = await fetch(`${base}/api/embeddings`, {\n method: 'POST',\n headers: { 'content-type': 'application/json' },\n body: JSON.stringify({ model: opts.model, prompt: text }),\n });\n\n if (!res.ok) {\n const detail = await readErrorBody(res);\n throw new Error(\n `ollama embedder request failed (${res.status} ${res.statusText}): ${detail}`,\n );\n }\n\n const json = (await res.json()) as OllamaResponse;\n const vec = json.embedding ?? [];\n\n if (vec.length < targetDim) {\n throw new Error(\n `ollama model \"${opts.model}\" returned a ${vec.length}-dim vector (shorter than the required ${targetDim}); choose a >= ${targetDim}-dim model (e.g. nomic-embed-text)`,\n );\n }\n\n // Matryoshka truncate to the vec0 width, then re-normalize (DS-8).\n const truncated =\n vec.length > targetDim ? Float32Array.from(vec.slice(0, targetDim)) : Float32Array.from(vec);\n return l2normalize(truncated);\n };\n}\n","// Opt-in REMOTE embeddings (OpenAI / Voyage / Cohere).\n//\n// DESIGN (spec DS-2 / blueprint D6 / NFR-1):\n// - Provider-explicit and NEVER the default. The caller selects `kind:'remote'`\n// with a concrete `provider` in config; there is no inference from env vars,\n// so source text is never silently sent to a cloud endpoint (D6 hard rule).\n// - `apiKey` absence does NOT throw at construction — the embedder builds\n// cleanly so `context_status` can report `kind:'remote'`, and `embed()`\n// throws a clear \"not configured\" error so the engine degrades to BM25-only\n// (F8) rather than crashing. This makes a misconfigured key observable\n// instead of fatal.\n// - Vectors are Matryoshka-truncated client-side to `dim` (default 384) and\n// re-normalized via the shared `l2normalize` (truncation shrinks the norm;\n// DS-8). A vector SHORTER than `dim` is a hard config error (wrong model).\n//\n// This is a deliberately thin stub: it fetches the provider's embeddings\n// endpoint directly with the global `fetch`. Full provider SDKs (streaming,\n// retries, batching, structured usage) are post-v0 (plan §7).\n\nimport type { EmbedFn } from '../types.js';\nimport { EMBED_DIM, l2normalize } from './normalize.js';\n\nexport interface RemoteEmbedderOptions {\n /** Provider key: `'openai'` | `'voyage'` | `'cohere'` (others fall back to the OpenAI shape). */\n provider: string;\n /** API key. If absent, `embed()` throws a clear \"not configured\" error. */\n apiKey?: string;\n /** Provider-specific model id. */\n model: string;\n /** Target dimensionality (must be 384 to match the vec0 table). */\n dim?: number;\n}\n\n/** OpenAI-compatible embeddings endpoint; also the fallback for unknown providers. */\nconst OPENAI_DEFAULT = 'https://api.openai.com/v1/embeddings';\n\n/** Known provider endpoints. Unknown providers reuse the OpenAI-compatible shape. */\nconst ENDPOINTS: Record<string, string> = {\n openai: OPENAI_DEFAULT,\n voyage: 'https://api.voyageai.com/v1/embeddings',\n cohere: 'https://api.cohere.com/v2/embed',\n};\n\ninterface EmbeddingsResponse {\n data?: Array<{ embedding?: number[] }>;\n embeddings?: { float?: number[][] };\n}\n\n/** Best-effort body for the provider's embeddings endpoint. */\nfunction buildRequestBody(provider: string, model: string, text: string): string {\n switch (provider) {\n case 'voyage':\n return JSON.stringify({ model, inputs: [text] });\n case 'cohere':\n return JSON.stringify({\n model,\n texts: [text],\n input_type: 'search_document',\n embedding_types: ['float'],\n });\n default:\n // OpenAI-compatible. We deliberately omit `dimensions` (only the\n // text-embedding-3-* family accepts it; others 400) and rely on\n // client-side Matryoshka truncation below for schema compatibility.\n return JSON.stringify({ model, input: text });\n }\n}\n\n/** Extract the first embedding vector from a provider response shape. */\nfunction extractVector(json: EmbeddingsResponse, provider: string): number[] {\n if (provider === 'cohere') {\n const vec = json.embeddings?.float?.[0];\n return vec ?? [];\n }\n // openai / voyage / openai-compatible\n return json.data?.[0]?.embedding ?? [];\n}\n\n/** Read up to a few hundred chars of the error body for diagnostics. */\nasync function readErrorBody(res: Response): Promise<string> {\n try {\n const text = await res.text();\n return text.slice(0, 500);\n } catch {\n return '<unreadable response body>';\n }\n}\n\n/**\n * Build a remote (cloud) embedder. Construction is synchronous and makes no\n * network calls. Each `embed()` call POSTs the source text to the provider.\n */\nexport function remoteEmbedder(opts: RemoteEmbedderOptions): EmbedFn {\n const targetDim = opts.dim ?? EMBED_DIM;\n // String-literal fallback (not a second Record lookup): under\n // noUncheckedIndexedAccess, indexing a Record yields `string | undefined`, so\n // `ENDPOINTS[opts.provider] ?? ENDPOINTS.openai` stays `string | undefined`\n // and fails typecheck at the fetch() below. `?? OPENAI_DEFAULT` resolves to `string`.\n const endpoint = ENDPOINTS[opts.provider] ?? OPENAI_DEFAULT;\n\n return async (text: string): Promise<Float32Array> => {\n if (!opts.apiKey) {\n throw new Error(\n `remote embedder \"${opts.provider}\" is not configured: apiKey is required (set context.embedder.apiKey or the matching provider env var)`,\n );\n }\n\n const res = await fetch(endpoint, {\n method: 'POST',\n headers: {\n 'content-type': 'application/json',\n authorization: `Bearer ${opts.apiKey}`,\n },\n body: buildRequestBody(opts.provider, opts.model, text),\n });\n\n if (!res.ok) {\n const detail = await readErrorBody(res);\n throw new Error(\n `remote embedder \"${opts.provider}\" request failed (${res.status} ${res.statusText}): ${detail}`,\n );\n }\n\n const json = (await res.json()) as EmbeddingsResponse;\n const vec = extractVector(json, opts.provider);\n\n if (vec.length < targetDim) {\n throw new Error(\n `remote embedder \"${opts.provider}\" returned a ${vec.length}-dim vector (shorter than the required ${targetDim}); choose a >= ${targetDim}-dim model`,\n );\n }\n\n // Matryoshka truncate to the vec0 width, then re-normalize (DS-8).\n const truncated =\n vec.length > targetDim ? Float32Array.from(vec.slice(0, targetDim)) : Float32Array.from(vec);\n return l2normalize(truncated);\n };\n}\n","// Deterministic fake embedder for tests + degraded fallback.\n//\n// DESIGN (spec §13 / NFR-2 / NFR-5):\n// - The full unit suite runs OFFLINE with no model download and no network.\n// `fakeEmbedFn` is the deterministic stand-in: same text → same vector,\n// different text → different direction, always `EMBED_DIM`-wide and\n// unit-norm. This makes chunker/rrf/retriever/indexer tests reproducible.\n// - Determinism comes from a SHA-256 of the text (no RNG, no clock) —\n// identical inputs produce bit-identical vectors (NFR-5).\n// - Output is L2-normalized through the shared helper so it is\n// indistinguishable from a real embedder at the store boundary (DS-8).\n\nimport { createHash } from 'node:crypto';\nimport type { EmbedFn } from '../types.js';\nimport { EMBED_DIM, l2normalize } from './normalize.js';\n\n/**\n * Build a deterministic embedder. The returned function maps each input string\n * to a stable, unit-norm `EMBED_DIM`-wide `Float32Array` derived from a SHA-256\n * hash of the text. Suitable for unit tests and as a BM25-companion fallback\n * where a real vector signal is unavailable but the shape must be valid.\n *\n * @param dim output width (defaults to {@link EMBED_DIM} = 384)\n */\nexport function fakeEmbedFn(dim: number = EMBED_DIM): EmbedFn {\n return (text: string): Promise<Float32Array> => {\n const hash = createHash('sha256').update(text, 'utf8').digest();\n const raw = new Float32Array(dim);\n // Spread the 32 hash bytes deterministically across the `dim` slots. Values\n // land in [-1, 1) (signed byte / 128); the direction is what matters and is\n // normalized away from the scale by `l2normalize`.\n for (let i = 0; i < dim; i++) {\n const byte = hash[i % hash.length];\n if (byte !== undefined) raw[i] = (byte - 128) / 128;\n }\n return Promise.resolve(l2normalize(raw));\n };\n}\n","// Embedder factory + provider implementations for @noir-ai/context.\n//\n// Every implementation returns an L2-normalized `EMBED_DIM`-wide\n// `Float32Array` via the shared `l2normalize`, fulfilling the `EmbedFn` seam\n// declared by @noir-ai/store but never implemented there. `createEmbedFn` is the\n// single factory the engine calls; it dispatches on the `EmbedderConfig`\n// discriminated union (from ../types) and returns `{ embed, info }`.\n//\n// Provider matrix (spec DS-2):\n// local — @huggingface/transformers + all-MiniLM-L6-v2 (DEFAULT; lazy load)\n// remote — OpenAI / Voyage / Cohere (OPT-IN, provider-explicit, paid)\n// ollama — local Ollama server (OPT-IN, provider-explicit)\n// none — disable vectors; search degrades to BM25-only (F8)\n\nimport type { EmbedderConfig, EmbedderInfo, EmbedFn } from '../types.js';\nimport { localEmbedder } from './local.js';\nimport { EMBED_DIM } from './normalize.js';\nimport { ollamaEmbedder } from './ollama.js';\nimport { remoteEmbedder } from './remote.js';\n\n/** Result of resolving an embedder config: a callable + describing metadata. */\nexport interface ResolvedEmbedder {\n /** The `EmbedFn` the engine/retriever/indexer call. May throw on load/config failure. */\n embed: EmbedFn;\n /** Surfaced by `context_status` and recorded in store KV (`ctx:embedder`). */\n info: EmbedderInfo;\n}\n\n/**\n * Build an `{ embed, info }` pair from a config. Construction never touches the\n * network or the native runtime — even `local` defers its dynamic import to the\n * first `embed()` call. `kind:'none'` (and any load failure the caller lets\n * propagate) yields BM25-only retrieval downstream (F8).\n */\nexport function createEmbedFn(cfg: EmbedderConfig): ResolvedEmbedder {\n switch (cfg.kind) {\n case 'local': {\n const { embed, model } = localEmbedder({ model: cfg.model });\n return { embed, info: { kind: 'local', model, dim: EMBED_DIM } };\n }\n case 'remote': {\n const embed = remoteEmbedder({\n provider: cfg.provider,\n apiKey: cfg.apiKey,\n model: cfg.model,\n dim: cfg.dim,\n });\n return { embed, info: { kind: 'remote', model: cfg.model, dim: cfg.dim } };\n }\n case 'ollama': {\n const embed = ollamaEmbedder({ baseURL: cfg.baseURL, model: cfg.model });\n return { embed, info: { kind: 'ollama', model: cfg.model, dim: EMBED_DIM } };\n }\n case 'none': {\n // Defer the error to embed() — but the retriever branches on\n // info.kind === 'none' first, so this stub never runs in the normal path.\n const embed: EmbedFn = async () => {\n throw new Error('embedder disabled (kind:\"none\"); search degrades to BM25-only');\n };\n return { embed, info: { kind: 'none', dim: 0 } };\n }\n }\n}\n\nexport { fakeEmbedFn } from './fake.js';\nexport type { LocalEmbedder, LocalEmbedderOptions } from './local.js';\nexport { DEFAULT_LOCAL_MODEL, localEmbedder, MODELS_DIR } from './local.js';\n// Re-export the building blocks so the engine, tests, and t10's\n// `resolveEmbedderConfig` mapper can import everything from one path.\nexport { EMBED_DIM, l2normalize } from './normalize.js';\nexport type { OllamaEmbedderOptions } from './ollama.js';\nexport { ollamaEmbedder } from './ollama.js';\nexport type { RemoteEmbedderOptions } from './remote.js';\nexport { remoteEmbedder } from './remote.js';\n","// Indexer for @noir-ai/context (slice S6, task t6).\n//\n// SHA-256 content-hash incremental indexer over the existing Store. The daemon\n// is the single writer; the indexer never opens a second connection — it walks\n// the given paths through the INJECTED store handle only (blueprint D6:\n// in-process, no sidecar, canonical ProjectId).\n//\n// Per file the indexer:\n// 1. chunks it (chunker.ts: markdown-heading | line/token windows, DS-5);\n// 2. appends the identifier-exploded token stream (DS-7) to form the CANONICAL\n// indexed content — the SAME string feeds `indexDoc` (FTS) and `upsertVec`\n// (embedding) so BM25 and kNN join on identical material under the SAME\n// chunk id;\n// 3. writes one `docs` row + one `vec0` row per chunk, keyed by the chunk's\n// own SourceKind (`'docs'` | `'codebase'` | …) so the retriever's\n// per-source filter stays meaningful.\n//\n// Incremental discipline (spec DS-4 / F1–F4): each file's UTF-8 SHA-256 is the\n// skip key. Unchanged files are skipped wholesale (their chunk count rolls into\n// `skipped`); changed files have their old chunks + vectors deleted, then\n// re-inserted; files removed since the last scan (and under a re-scanned root)\n// have their chunks + vectors deleted and are dropped from the registry. The\n// first call on a fresh store naturally seeds a full reindex (registry empty ⇒\n// everything is new).\n//\n// State lives in the store KV, namespaced `ctx:` to stay disjoint from\n// `workflow:*` and store meta:\n// ctx:registry → string[] indexed path keys (sorted)\n// ctx:file:<key> → FileRecord per-file {sha256, chunkIds, language}\n// ctx:embedder → EmbedderInfo recorded once; model swap ⇒ warn (never silent)\n//\n// Degraded path (spec F8, mirrored for indexing): when the embedder is\n// `kind:'none'` or `embed()` throws (native load failed / misconfigured remote),\n// the indexer disables embedding for the rest of the run, still indexes the\n// `docs` rows, and reports `degraded:true` — it never crashes on a bad embedder.\n\nimport type { Dirent } from 'node:fs';\nimport { readFileSync } from 'node:fs';\nimport { readdir, readFile, stat } from 'node:fs/promises';\nimport { join, relative, resolve, sep } from 'node:path';\nimport { chunkFile, inferLanguage, withIdentifierExplosion } from './chunker.js';\nimport { sha256Hex } from './hash.js';\nimport type { ChunkMeta, EmbedderInfo, EmbedFn, IndexResult, SourceKind, Store } from './types.js';\n\n// ---------------------------------------------------------------------------\n// KV schema (namespaced `ctx:` — disjoint from `workflow:*` / store meta)\n// ---------------------------------------------------------------------------\n\n/** KV key holding the sorted list of indexed path keys. */\nexport const CTX_REGISTRY_KEY = 'ctx:registry';\n/** KV key holding the recorded {@link EmbedderInfo} (model-swap detection). */\nexport const CTX_EMBEDDER_KEY = 'ctx:embedder';\n/** Per-file record key prefix; the value is a {@link FileRecord} (or `null` tombstone). */\nexport const CTX_FILE_PREFIX = 'ctx:file:';\n\n/**\n * Per-file tracking record stored under {@link ctxFileKey}. `sha256` is the\n * content-hash skip key; `chunkIds` lets a change/removal delete exactly the\n * rows that belong to this file; `language` mirrors the chunker's inference.\n */\nexport interface FileRecord {\n /** SHA-256 of the file's UTF-8 content — the content-hash skip key (DS-4). */\n sha256: string;\n /** Chunk ids belonging to this file (for exact delete on change/removal). */\n chunkIds: string[];\n /** Detected language tag (mirrors the chunker's inference). */\n language: string;\n}\n\n/** Build a `ctx:file:<key>` KV key. */\nexport function ctxFileKey(pathKey: string): string {\n return `${CTX_FILE_PREFIX}${pathKey}`;\n}\n\n// ---------------------------------------------------------------------------\n// Walk skips (VCS / Noir state / build artifacts / dependency trees)\n// ---------------------------------------------------------------------------\n\n/**\n * Directory names never descended into during a walk (spec F1). Covers the\n * common `.gitignore` entries without needing a gitignore parser — full\n * `.gitignore` intersection (and `git diff --name-only` for incremental scope)\n * is deferred v0 debt (grounds §10 indexing trigger).\n */\nexport const SKIP_DIRS = new Set([\n '.git',\n '.hg',\n '.svn',\n '.noir',\n 'node_modules',\n 'bower_components',\n 'dist',\n 'build',\n 'out',\n '.next',\n '.nuxt',\n '.turbo',\n '.cache',\n 'coverage',\n '.nyc_output',\n '.venv',\n 'venv',\n 'env',\n '__pycache__',\n '.mypy_cache',\n '.pytest_cache',\n '.idea',\n '.vscode',\n]);\n\n/**\n * Extensions treated as binary and therefore unindexable (counted as `failed`,\n * never read in full — the extension check is cheap and avoids slurping large\n * assets). The null-byte guard below catches mis-extensioned binaries.\n */\nconst BINARY_EXTS = new Set([\n // Images\n 'png',\n 'jpg',\n 'jpeg',\n 'gif',\n 'webp',\n 'ico',\n 'bmp',\n 'tif',\n 'tiff',\n 'svgz',\n 'heic',\n 'avif',\n // Audio / video\n 'mp3',\n 'mp4',\n 'mov',\n 'avi',\n 'mkv',\n 'flac',\n 'wav',\n 'ogg',\n 'webm',\n 'aac',\n 'm4a',\n // Archives\n 'zip',\n 'gz',\n 'tar',\n 'tgz',\n 'br',\n 'lz',\n 'lzma',\n '7z',\n 'rar',\n 'bz2',\n // Documents (binary)\n 'pdf',\n 'doc',\n 'docx',\n 'xls',\n 'xlsx',\n 'ppt',\n 'pptx',\n // Compiled / native\n 'wasm',\n 'exe',\n 'dll',\n 'so',\n 'dylib',\n 'a',\n 'o',\n 'obj',\n 'lib',\n 'class',\n 'jar',\n 'war',\n 'pyc',\n 'pyo',\n 'pyd',\n // ML / model artifacts\n 'onnx',\n 'pickle',\n 'pt',\n 'bin',\n // Databases / locks\n 'db',\n 'sqlite',\n 'sqlite3',\n 'db-shm',\n 'db-wal',\n 'lock',\n // Fonts\n 'ttf',\n 'otf',\n 'woff',\n 'woff2',\n 'eot',\n]);\n\n/** True if the path's extension is a known binary type. */\nexport function isBinaryExt(pathOrName: string): boolean {\n const m = pathOrName.toLowerCase().match(/\\.([a-z0-9]+)$/);\n if (!m?.[1]) return false;\n return BINARY_EXTS.has(m[1]);\n}\n\n// ---------------------------------------------------------------------------\n// Sensitive-file denylist (post-review hardening: prevent secret exposure via\n// context_search). These files are NEVER chunked/embedded/indexed even when\n// they are plain text — indexing a `.env` or `id_rsa` would leak its contents\n// into FTS + vector snippets. Covers env files, private keys, credential\n// stores, and OS junk. Path-aware: pass a basename or a `/`-separated rel path.\n// ---------------------------------------------------------------------------\n\n/** Exact basenames never indexed (case-insensitive). */\nconst SENSITIVE_NAMES = new Set([\n '.env',\n '.npmrc',\n '.pypirc',\n '.git-credentials',\n '.netrc',\n '.ds_store',\n 'thumbs.db',\n]);\n\n/**\n * Basename prefixes (covers `.env.local` / `.env.production`, `id_rsa` /\n * `id_rsa.pub`, `id_ed25519` / `id_ed25519.pub`). Matched against the basename\n * only so a path segment like `deploy/` does not false-negative.\n */\nconst SENSITIVE_PREFIXES = ['.env.', 'id_rsa', 'id_ed25519'];\n\n/** Basename suffixes (covers `*.pem`, `*.key`, `*.secret`, `*.p12`, `*.pfx`, `*.local`). */\nconst SENSITIVE_SUFFIXES = ['.pem', '.key', '.secret', '.p12', '.pfx', '.local'];\n\n/**\n * Relative-path patterns not caught by a basename check — `.aws/credentials`\n * has the generic basename `credentials`, so it is anchored on the path suffix\n * instead (a bare `credentials` basename is intentionally NOT flagged).\n */\nconst SENSITIVE_PATHS = ['.aws/credentials'];\n\n/**\n * True if `name` is a sensitive file that must NEVER be chunked, embedded, or\n * indexed. Accepts a bare basename (`id_rsa`, `.env`, `cert.pem`) OR a\n * `/`-separated relative path (`deploy/id_rsa`, `.aws/credentials`); the\n * basename is extracted internally so prefix/suffix checks anchor on the file\n * name rather than a path segment. Wired into the walk's skip decision\n * alongside {@link isBinaryExt} so secrets never reach `indexDoc` / `upsertVec`.\n */\nexport function isSensitive(name: string): boolean {\n const lower = name.toLowerCase();\n // Path-anchored first (basename `credentials` alone is too generic to flag).\n for (const p of SENSITIVE_PATHS) {\n if (lower === p || lower.endsWith(`/${p}`)) return true;\n }\n const base = lower.slice(lower.lastIndexOf('/') + 1);\n if (SENSITIVE_NAMES.has(base)) return true;\n for (const pf of SENSITIVE_PREFIXES) {\n if (base.startsWith(pf)) return true;\n }\n for (const sf of SENSITIVE_SUFFIXES) {\n if (base.endsWith(sf)) return true;\n }\n return false;\n}\n\n// ---------------------------------------------------------------------------\n// Small path helpers (stable, cross-platform keys)\n// ---------------------------------------------------------------------------\n\n/** Normalize OS separators to `/` so registry keys match across platforms. */\nfunction posix(p: string): string {\n return sep === '/' ? p : p.split(sep).join('/');\n}\n\n// ---------------------------------------------------------------------------\n// Options + return types\n// ---------------------------------------------------------------------------\n\n/** Construction options for {@link createIndexer}. */\nexport interface IndexerOptions {\n /** The daemon's store handle — the ONLY storage surface used (single writer). */\n store: Store;\n /** The resolved embedder. Failures here degrade the run (F8-style), never crash. */\n embed: EmbedFn;\n /** Active embedder description; recorded in KV for model-swap detection. */\n info: EmbedderInfo;\n /**\n * Optional repository root. When provided, paths passed to\n * {@link Indexer.indexPaths} are resolved against it AND stored repo-relative\n * (portable `meta.path`, stable across checkouts). When omitted, paths resolve\n * against `process.cwd()` and are stored absolute.\n */\n root?: string;\n}\n\n/** Per-call options for {@link Indexer.indexPaths}. */\nexport interface IndexPathOptions {\n /** Chunk size override (flows from `context.chunk.maxTokens`). */\n maxTokens?: number;\n /** Chunk overlap override (flows from `context.chunk.overlap`). */\n overlap?: number;\n /**\n * Override the source bucket for every file in this call (otherwise the\n * chunker infers `'docs'` for markdown and `'codebase'` for everything else).\n */\n source?: SourceKind;\n}\n\n/** Return value of {@link Indexer.forget}. */\nexport interface ForgetResult {\n /** Files removed from the index (their chunks + vectors were deleted). */\n deleted: number;\n /** Total chunks now tracked. */\n totalChunks: number;\n}\n\n/** The indexer surface returned by {@link createIndexer}. */\nexport interface Indexer {\n /**\n * Walk + incrementally index `paths` (files or directories). Removed files\n * under a re-scanned root are reconciled (deleted). Returns chunk/file counts\n * + the `degraded` flag (spec F1/F3/F4, AC-1).\n */\n indexPaths(paths: string[], opts?: IndexPathOptions): Promise<IndexResult>;\n /** Remove `paths` (files or dirs) from the index; deletes their chunks + vectors. */\n forget(paths: string[]): Promise<ForgetResult>;\n /** Drop every indexed chunk + vector, then re-index the registered roots from scratch. */\n reindex(): Promise<IndexResult>;\n /**\n * Read a single chunk's CLEAN content + meta by id, by re-reading the source\n * file from disk and re-chunking it (the indexer writes the post-identifier-\n * explosion form to the `docs` table for FTS — the CLEAN pre-explosion text\n * is what a kNN-only-hit snippet wants, since it is what humans read). This\n * is the read side of the indexer's own writer: it knows the registry layout\n * (`ctx:registry` + `ctx:file:<key>`) and the chunk-id format\n * (`<sha256(path)>#chunk-<n>`), so it is the natural place to look up a\n * chunk by id.\n *\n * Used by the engine to hydrate kNN-only retriever hits (C1) — wired as the\n * retriever's `readDoc`. Returns `null` when:\n * • the chunk id is not in any tracked FileRecord (deleted, or never\n * indexed — e.g. a vec row whose `docs` row came from a foreign source);\n * • the source file is missing/unreadable on disk (deleted/moved/perm);\n * • the file's content changed and the re-chunk no longer yields this id.\n *\n * In all miss cases the caller (retriever) emits an empty snippet and the\n * payload's `mode` honestly reflects `'knn'` — never crashes.\n */\n readChunkContent(id: string): { content: string; meta: ChunkMeta } | null;\n}\n\n// ---------------------------------------------------------------------------\n// Factory\n// ---------------------------------------------------------------------------\n\n/**\n * Build an {@link Indexer} bound to a single store handle. Construction does no\n * I/O and touches no KV — all work happens inside `indexPaths`/`forget`/`reindex`.\n */\nexport function createIndexer(opts: IndexerOptions): Indexer {\n const { store, embed, info } = opts;\n const base = opts.root ?? process.cwd();\n\n // Single-flight serialization of mutating ops (post-review: prevent registry\n // KV races). `indexPaths` / `forget` / `reindex` do read-modify-write on the\n // `ctx:registry` / `ctx:file:*` KV across `await readdir` / `readFile` /\n // `embed` suspension points; without serialization, two CONCURRENT calls each\n // load the same registry snapshot, mutate their own copy, and persist\n // last-write-wins — orphaning the loser's chunks + vectors in `docs`/`vec0`\n // with no registry entry to ever reconcile them away. This promise chain\n // forces ALL mutating ops to run strictly one at a time over the shared\n // handle. Reads (the retriever's `search`, the engine's `status`) bypass the\n // chain entirely and stay concurrent.\n let chain: Promise<unknown> = Promise.resolve();\n function serialized<T>(work: () => Promise<T>): Promise<T> {\n const result = chain.then(work);\n // Advance the chain regardless of whether `work` resolves or rejects: a\n // failed op must not poison the queue for the next caller. The caller\n // observes the real outcome via `result`; the chain only tracks readiness.\n chain = result.then(\n () => undefined,\n () => undefined,\n );\n return result;\n }\n\n // path helpers --------------------------------------------------------------\n const resolveAbs = (p: string): string => resolve(base, p);\n // When `root` is given, keys are repo-relative (portable); otherwise absolute.\n const toKey = (abs: string): string => (opts.root ? relative(opts.root, abs) : abs);\n const keyAbs = (key: string): string => posix(resolve(base, key));\n\n /**\n * Path confinement (post-review): resolve-then-confine. True only when `abs`\n * is the project root itself or lives beneath it. Applied to BOTH the\n * explicit `paths` arguments and the walk's yielded entries so an absolute\n * out-of-root path (`/etc/passwd`) or a `../sibling` traversal is skipped —\n * never ingested, never stored as a `../../...` or absolute `meta.path`.\n */\n function isWithinRoot(abs: string): boolean {\n const r = resolve(base);\n const a = resolve(abs);\n return a === r || a.startsWith(`${r}${sep}`);\n }\n\n // store helpers -------------------------------------------------------------\n function deleteChunks(ids: string[]): void {\n for (const id of ids) {\n store.deleteDoc(id);\n store.deleteVec(id);\n }\n }\n\n /** Load every per-file record currently in the registry into a Map. */\n function loadRecords(): Map<string, FileRecord> {\n const registry = store.getState<string[]>(CTX_REGISTRY_KEY) ?? [];\n const records = new Map<string, FileRecord>();\n for (const p of registry) {\n const rec = store.getState<FileRecord>(ctxFileKey(p));\n if (rec) records.set(p, rec);\n }\n return records;\n }\n\n /** Write back the registry + per-file records; tombstone the removed keys. */\n function persist(records: Map<string, FileRecord>, tombstones: string[]): void {\n store.setState(CTX_REGISTRY_KEY, [...records.keys()].sort());\n for (const [key, rec] of records) store.setState(ctxFileKey(key), rec);\n for (const key of tombstones) store.setState(ctxFileKey(key), null);\n }\n\n /**\n * Record the active embedder in KV. On a model swap (kind/model/dim change),\n * warn loudly — vectors may now be incompatible — but NEVER auto-reindex; the\n * caller decides via `reindex()` (spec §7: \"warn + offer reindex, not silent\").\n */\n function recordEmbedder(): void {\n const prev = store.getState<EmbedderInfo>(CTX_EMBEDDER_KEY);\n if (prev === null) {\n store.setState(CTX_EMBEDDER_KEY, info);\n return;\n }\n if (!sameEmbedder(prev, info)) {\n console.warn(\n `[noir-context] embedder changed (${describeEmbedder(prev)} → ${describeEmbedder(info)}); ` +\n 'existing vectors may be stale — call reindex() to refresh',\n );\n store.setState(CTX_EMBEDDER_KEY, info);\n }\n }\n\n // walk ----------------------------------------------------------------------\n /** Recursively collect files under `rootAbs`, skipping {@link SKIP_DIRS}. */\n async function walk(rootAbs: string): Promise<string[]> {\n const out: string[] = [];\n const stack: string[] = [rootAbs];\n while (stack.length > 0) {\n const dir = stack.pop();\n if (dir === undefined) break;\n let entries: Dirent[];\n try {\n entries = await readdir(dir, { withFileTypes: true });\n } catch {\n continue; // unreadable / restricted → skip silently (don't fail the run)\n }\n for (const ent of entries) {\n if (ent.isDirectory()) {\n if (SKIP_DIRS.has(ent.name)) continue;\n stack.push(join(dir, ent.name));\n } else if (ent.isFile()) {\n out.push(join(dir, ent.name));\n }\n }\n }\n return out;\n }\n\n /** Is `absKey` underneath (or equal to) any of the re-scanned `absRoots`? */\n function inScope(absKey: string, absRoots: string[]): boolean {\n for (const r of absRoots) {\n const rp = posix(r);\n if (absKey === rp || absKey.startsWith(`${rp}/`)) return true;\n }\n return false;\n }\n\n // --- indexPaths ------------------------------------------------------------\n async function indexPaths(inputPaths: string[], o?: IndexPathOptions): Promise<IndexResult> {\n recordEmbedder();\n const records = loadRecords();\n\n // Resolve inputs → absolute roots; walk dirs / admit files into `scanned`.\n const absRoots: string[] = [];\n const scanned = new Map<string, string>(); // key -> abs\n for (const input of inputPaths) {\n const abs = resolveAbs(input);\n // Path confinement: reject traversal / out-of-root ingestion. An absolute\n // path (`/etc/passwd`) or a `../sibling` resolves outside `base`; skip it\n // entirely — never stat, never walk, never store a `../` meta.path.\n if (!isWithinRoot(abs)) continue;\n const st = await stat(abs).catch(() => null);\n if (st === null) continue; // missing path — nothing to index, nothing to reconcile here\n if (st.isDirectory()) {\n absRoots.push(abs);\n for (const file of await walk(abs)) {\n // Defensive confinement on walk entries: the walk starts in-root, but\n // a symlink resolved by stat/readdir could point outside — re-check.\n if (!isWithinRoot(file)) continue;\n scanned.set(posix(toKey(file)), file);\n }\n } else if (st.isFile()) {\n absRoots.push(abs);\n scanned.set(posix(toKey(abs)), abs);\n }\n }\n\n let indexed = 0;\n let skipped = 0;\n let failed = 0;\n let deleted = 0;\n // `kind:'none'` ⇒ no vectors from the start; a thrown embed() flips this mid-run.\n let embedDisabled = info.kind === 'none';\n const tombstones: string[] = [];\n\n const tryEmbed = async (content: string): Promise<Float32Array | null> => {\n if (embedDisabled) return null;\n try {\n return await embed(content);\n } catch {\n // First failure (native load / config / network) ⇒ disable for the rest\n // of the run. Docs still get indexed; their vectors are skipped (F8).\n embedDisabled = true;\n return null;\n }\n };\n\n // 1. Reconcile removals: registry entries under a re-scanned root that are\n // no longer on disk get their chunks + vectors deleted. Files indexed\n // under OTHER roots are left untouched (incremental, scoped reconcile).\n for (const key of [...records.keys()]) {\n if (scanned.has(key)) continue;\n if (!inScope(keyAbs(key), absRoots)) continue;\n const rec = records.get(key);\n if (rec) deleteChunks(rec.chunkIds);\n records.delete(key);\n tombstones.push(key);\n deleted += 1;\n }\n\n // 2. Index scanned files (skip unchanged by content-hash; re-chunk changed).\n for (const [key, abs] of scanned) {\n // Binary by extension ⇒ unindexable (cheap check, no full read). Counts as\n // `failed` per the IndexResult contract (binary / IO / encoding).\n if (isBinaryExt(key)) {\n failed += 1;\n continue;\n }\n // Sensitive file (secret / key / credential) ⇒ NEVER chunked, embedded, or\n // indexed, even when plain text — prevents secret exposure via\n // `context_search` snippets. Counts as `failed` (deliberately excluded by\n // policy, like the binary guard above). Post-review hardening.\n if (isSensitive(key)) {\n failed += 1;\n continue;\n }\n let content: string;\n try {\n content = await readFile(abs, 'utf8');\n } catch {\n failed += 1;\n continue;\n }\n if (content.includes(String.fromCharCode(0))) {\n // Null byte ⇒ binary masquerading as text (extension check missed it).\n failed += 1;\n continue;\n }\n\n const fileHash = sha256Hex(content);\n const prev = records.get(key);\n if (prev !== undefined && prev.sha256 === fileHash) {\n // Content-hash hit: skip wholesale (AC-1). Roll the chunk count into `skipped`.\n skipped += prev.chunkIds.length;\n continue;\n }\n // Changed or new: delete the file's prior chunks before re-inserting.\n if (prev !== undefined) deleteChunks(prev.chunkIds);\n\n const chunks = chunkFile({\n path: key,\n content,\n source: o?.source,\n maxTokens: o?.maxTokens,\n overlap: o?.overlap,\n });\n const chunkIds: string[] = [];\n let language = inferLanguage(key);\n for (const chunk of chunks) {\n // Canonical indexed content: clean text + identifier explosion (DS-7).\n // Both FTS and the embedding consume this exact string; chunk.meta.sha256\n // (computed by the chunker) is the hash of this same form.\n const indexedContent = withIdentifierExplosion(chunk.content);\n store.indexDoc({\n id: chunk.id,\n source: chunk.source,\n content: indexedContent,\n meta: chunk.meta,\n });\n language = chunk.meta.language;\n const vec = await tryEmbed(indexedContent);\n if (vec !== null) {\n store.upsertVec(chunk.id, vec, { source: chunk.source });\n }\n chunkIds.push(chunk.id);\n indexed += 1;\n }\n records.set(key, { sha256: fileHash, chunkIds, language });\n }\n\n persist(records, tombstones);\n\n let totalChunks = 0;\n for (const rec of records.values()) totalChunks += rec.chunkIds.length;\n\n return {\n indexed,\n skipped,\n deleted,\n failed,\n totalChunks,\n // `degraded` is truthful about \"docs indexed without vectors\": only set\n // when embedding was off AND at least one doc went in without one.\n degraded: embedDisabled && indexed > 0,\n };\n }\n\n // --- forget ----------------------------------------------------------------\n async function forget(inputPaths: string[]): Promise<ForgetResult> {\n const records = loadRecords();\n const targets = inputPaths.map((p) => posix(resolveAbs(p)));\n const tombstones: string[] = [];\n for (const key of [...records.keys()]) {\n const abs = keyAbs(key);\n const hit = targets.some((t) => abs === t || abs.startsWith(`${t}/`));\n if (!hit) continue;\n const rec = records.get(key);\n if (rec) deleteChunks(rec.chunkIds);\n records.delete(key);\n tombstones.push(key);\n }\n persist(records, tombstones);\n let totalChunks = 0;\n for (const rec of records.values()) totalChunks += rec.chunkIds.length;\n return { deleted: tombstones.length, totalChunks };\n }\n\n // --- reindex ---------------------------------------------------------------\n async function reindex(): Promise<IndexResult> {\n // Wipe the registry + every per-file record, deleting all chunks + vectors,\n // then re-index the SAME roots from scratch (typically after a model swap).\n const registry = store.getState<string[]>(CTX_REGISTRY_KEY) ?? [];\n const tombstones: string[] = [];\n for (const key of registry) {\n const rec = store.getState<FileRecord>(ctxFileKey(key));\n if (rec) deleteChunks(rec.chunkIds);\n tombstones.push(key);\n }\n persist(new Map(), tombstones);\n // `registry` holds the prior path keys (relative-to-root or absolute); they\n // resolve back to absolute via `base` inside indexPaths.\n return indexPaths(registry);\n }\n\n // --- readChunkContent ------------------------------------------------------\n /**\n * Read-side companion to `indexPaths`/`forget`/`reindex`. Look up a chunk's\n * CLEAN content + meta by id, by re-reading the source file from disk and\n * re-chunking it (the CLEAN pre-explosion text is what a kNN-only snippet\n * wants — humans read it; the post-identifier-explosion form is FTS-only).\n *\n * The chunk-id format is `<parentDocId>#chunk-<n>` where `parentDocId =\n * sha256(path)`, but the indexer keys FileRecords by PATH KEY (which may be\n * repo-relative OR absolute depending on `opts.root`), NOT by sha256(path).\n * So the lookup walks the registry's FileRecords and picks the one whose\n * `chunkIds` contains `id`. Returns `null` on any miss (no entry, missing\n * file, content drift) so the retriever degrades honestly to `mode:'knn'`.\n *\n * Pure-read: no KV mutation, no `await` (synchronous file reads via\n * `readFileSync`). Bypasses the serialized mutation queue — reads can run\n * freely concurrent with each other AND with a concurrent `indexPaths`\n * (SQLite WAL handles read concurrency; the worst case is a transient\n * content-mismatch miss, which surfaces honestly as `mode:'knn'`).\n */\n function readChunkContent(id: string): { content: string; meta: ChunkMeta } | null {\n const records = loadRecords();\n // Find the FileRecord that owns this chunk id. Short-circuit: most kNN\n // queries hydrate 0–few hits, and the registry is small (one entry per\n // indexed file), so the linear scan is cheap.\n let ownerKey: string | null = null;\n for (const [key, rec] of records) {\n if (rec.chunkIds.includes(id)) {\n ownerKey = key;\n break;\n }\n }\n if (ownerKey === null) return null;\n // Resolve the path key back to an absolute path the same way indexPaths\n // does (`base` is the indexer's `opts.root ?? process.cwd()`). POSIX-form\n // the result to match the on-disk key separator.\n const abs = keyAbs(ownerKey);\n let content: string;\n try {\n content = readFileSync(abs, 'utf8');\n } catch {\n // Missing/unreadable file (deleted, moved, permissions). Surface as a\n // null miss — the retriever reports mode:'knn'.\n return null;\n }\n // Null-byte guard mirrors indexPaths: don't trust a file that turned binary\n // under us (would yield garbage chunks); treat as a miss.\n if (content.includes(String.fromCharCode(0))) return null;\n // Re-chunk and pick the chunk whose id matches. If the file's content\n // drifted (chunkIds no longer line up), this returns null honestly.\n const chunks = chunkFile({ path: ownerKey, content });\n const found = chunks.find((c) => c.id === id);\n if (!found) return null;\n return { content: found.content, meta: found.meta };\n }\n\n // Mutating ops are wrapped in `serialized` so they run strictly one at a time\n // over the shared store handle (see `chain` above). The inner closures call\n // the un-wrapped functions directly: `reindex` invokes `indexPaths` inline\n // within its own serialized slot, so there is no re-queue / deadlock.\n // `readChunkContent` is a pure read and bypasses the queue (see its JSDoc).\n return {\n indexPaths: (inputPaths: string[], o?: IndexPathOptions) =>\n serialized(() => indexPaths(inputPaths, o)),\n forget: (inputPaths: string[]) => serialized(() => forget(inputPaths)),\n reindex: () => serialized(reindex),\n readChunkContent,\n };\n}\n\n// ---------------------------------------------------------------------------\n// Embedder comparison (model-swap detection)\n// ---------------------------------------------------------------------------\n\nfunction sameEmbedder(a: EmbedderInfo, b: EmbedderInfo): boolean {\n return a.kind === b.kind && a.model === b.model && a.dim === b.dim;\n}\n\nfunction describeEmbedder(e: EmbedderInfo): string {\n return e.model ? `${e.kind}:${e.model}(${e.dim})` : `${e.kind}(${e.dim})`;\n}\n","// Reciprocal Rank Fusion (RRF) for hybrid retrieval — slice S6, task t5.\n//\n// Fuses two pre-ranked retrieval lists — BM25 (`Store.searchFt`) and cosine\n// kNN (`Store.knn`) — into one ranked list via the canonical Cormack SIGIR'09\n// rank-based formula. RRF is deliberately RANK-BASED: it never reads either\n// list's raw scores, so it sidesteps the BM25-vs-cosine scale mismatch\n// entirely (spec DS-3 / NFR-5). A document present in only one list contributes\n// only that list's term — no penalty, no normalization — and the raw BM25 and\n// cosine scores are NEVER summed.\n//\n// Reference: Cormack, Clarke, Büttcher — Reciprocal Rank Fusion (SIGIR 2009).\n// score(d) = Σ_i w_i / (k + rank_i(d)) (1-based ranks, default k = 60)\n\nimport type { FtsHit, VecHit } from './types.js';\n\n/**\n * Canonical RRF constant (Cormack SIGIR'09). Larger values dampen the\n * advantage of the top ranks.\n */\nexport const DEFAULT_RRF_K = 60;\n\n/** Default per-retriever weights `[bm25Weight, kNNWeight]` (DS-3). */\nexport const DEFAULT_RRF_WEIGHTS: readonly [number, number] = [0.5, 0.5];\n\n/** Options for {@link fuseRrf}. */\nexport interface FuseRrfOptions {\n /**\n * RRF constant `k`. Larger values flatten the top-rank advantage.\n * Defaults to {@link DEFAULT_RRF_K} (60 — the canonical value).\n */\n k?: number;\n /**\n * Per-retriever weights `[bm25Weight, kNNWeight]`. Need not sum to 1: RRF is\n * rank-based, not a normalized blend. Defaults to `[0.5, 0.5]`.\n */\n weights?: [number, number];\n}\n\n/** A fused result row from {@link fuseRrf}. */\nexport interface RrfResult {\n /** Chunk id (the same id was indexed into both `docs` and `vec0`). */\n id: string;\n /** Source bucket from the originating hit (bm25's, else kNN's). */\n source: string;\n /**\n * Fused RRF score `Σ w_i/(k+rank_i)`. Rank-based — NOT a normalized\n * similarity; only meaningful for ordering within a single `fuseRrf` call.\n */\n score: number;\n}\n\n/**\n * Build a 1-based rank map (`id → {rank, source}`) from a pre-ranked list,\n * taking the FIRST occurrence's position as the rank. Defends against a\n * duplicate id within one list (a well-formed ranking never emits one, but the\n * store does not strictly forbid it); later duplicates are ignored.\n */\nfunction rankMap(\n list: ReadonlyArray<{ id: string; source: string }>,\n): Map<string, { rank: number; source: string }> {\n const map = new Map<string, { rank: number; source: string }>();\n for (const [i, hit] of list.entries()) {\n if (map.has(hit.id)) continue; // first occurrence wins\n map.set(hit.id, { rank: i + 1, source: hit.source });\n }\n return map;\n}\n\n/**\n * Fuse two pre-ranked retrieval lists via Reciprocal Rank Fusion.\n *\n * The input lists MUST be ordered best-first (the store returns them that way:\n * `searchFt` ranks by BM25 relevance, `knn` by ascending distance). Position in\n * the array IS the rank — the hits' raw `score` fields are intentionally\n * ignored (DS-3: never sum raw BM25 + cosine).\n *\n * For each unique id: `score = w_bm25/(k + rank_bm25) + w_knn/(k + rank_knn)`,\n * dropping a term when the id is absent from that list (no penalty, no\n * normalization). Results sort by score descending; ties break by the doc's\n * best (minimum) rank across both lists, then by first-seen insertion order\n * (bm25 list first, then kNN-only docs) — all deterministic, so identical\n * inputs always yield identical ordering (NFR-5).\n *\n * Pure and side-effect-free.\n */\nexport function fuseRrf(\n bm25: ReadonlyArray<FtsHit>,\n knn: ReadonlyArray<VecHit>,\n opts?: FuseRrfOptions,\n): RrfResult[] {\n const k = opts?.k ?? DEFAULT_RRF_K;\n const [wBm25, wKnn] = opts?.weights ?? DEFAULT_RRF_WEIGHTS;\n\n const bm25Ranks = rankMap(bm25);\n const knnRanks = rankMap(knn);\n\n // Unique ids in first-seen order (bm25 list first, then kNN-only docs).\n // Captured up front so the final tie-break is deterministic without relying\n // on the host JS engine's Array.sort stability.\n const seen = new Set<string>();\n const orderedIds: string[] = [];\n for (const hit of bm25) {\n if (!seen.has(hit.id)) {\n seen.add(hit.id);\n orderedIds.push(hit.id);\n }\n }\n for (const hit of knn) {\n if (!seen.has(hit.id)) {\n seen.add(hit.id);\n orderedIds.push(hit.id);\n }\n }\n\n const entries = orderedIds.map((id, order) => {\n const bm = bm25Ranks.get(id);\n const kn = knnRanks.get(id);\n const rankBm25 = bm?.rank;\n const rankKnn = kn?.rank;\n const score =\n (rankBm25 != null ? wBm25 / (k + rankBm25) : 0) +\n (rankKnn != null ? wKnn / (k + rankKnn) : 0);\n const minRank = Math.min(\n rankBm25 ?? Number.POSITIVE_INFINITY,\n rankKnn ?? Number.POSITIVE_INFINITY,\n );\n // A chunk's source is identical across both lists in practice (the indexer\n // upserts the same id into docs + vec0 with one source), so preferring the\n // bm25 source is a deterministic pick, never a conflicting merge.\n const source = bm?.source ?? kn?.source ?? '';\n return { id, source, score, minRank, order };\n });\n\n // Sort: fused score desc → best (min) rank asc → first-seen insertion asc.\n entries.sort((a, b) => {\n if (a.score !== b.score) return b.score - a.score;\n if (a.minRank !== b.minRank) return a.minRank - b.minRank;\n return a.order - b.order;\n });\n\n // Strip the internal tie-break keys before returning the public shape.\n return entries.map(({ id, source, score }) => ({ id, source, score }));\n}\n","// Hybrid retriever for @noir-ai/context (slice S6, task t7).\n//\n// Pipeline (spec F6, §8 \"Query → BM25+vec → RRF → budget → snippets\"):\n// search(query)\n// │\n// ├─ store.searchFt(query, {limit, source}) → FtsHit[] (BM25, with\n// │ FTS5 16-token windowed\n// │ snippet + docs.meta)\n// ├─ store.knn(await embed(query), {limit, source}) → VecHit[] (cosine kNN,\n// │ L2-normalized vectors)\n// │\n// ├─ fuseRrf(bm25, knn, {k:60, weights:[0.5,0.5]}) → rank-based RRF fusion\n// │ (DS-3; NEVER sums raw\n// │ BM25+cosine scores)\n// │\n// ├─ enrich each fused id:\n// │ • BM25 hit → reuse FtsHit.snippet VERBATIM (F7 — never truncate);\n// │ path/parentDocId backfilled from docs.meta (ChunkMeta)\n// │ • kNN-only hit → window-extract a snippet from `readDoc(id)` (the\n// │ chunk's content) — vec0 carries no meta column, so\n// │ without a hydrator the hit degrades to an empty\n// │ snippet but KEEPS ITS RANK (F8 spirit: never crash,\n// │ never drop a ranked semantic hit on a missing window)\n// │\n// ├─ collapse duplicate parentDocId (keep the top-scoring chunk per parent —\n// │ DS-6; one file can't flood the result set). Unhydrated kNN-only hits\n// │ (empty parentDocId) collapse on their unique id, so they never merge.\n// │\n// └─ greedy token-budget fill (default 4096) over the collapsed list:\n// accumulate estimateTokens(snippet) until budgetTokens; the top hit is\n// always admitted even if it alone exceeds the budget (avoid returning\n// zero results for one large hit). truncated:true if the list is\n// exhausted before the budget (DS-6).\n//\n// Degradation (F8): when the embedder is unavailable — `kind:'none'`, a native\n// load failure, a provider error, OR `knn()` itself threw — `search` falls back\n// to BM25-only and the payload carries `degraded:true, mode:'bm25-only'`. The\n// embedder signals unavailability by throwing from `embed()` (the `'none'`\n// factory does exactly that), so the retriever needs no a-priori knowledge of\n// the embedder kind — the throw IS the signal. A BM25 throw (e.g. a foreign\n// read-only DB missing `docs_fts`) is also caught: search degrades to an empty\n// or vec-only result set rather than crashing.\n//\n// Mode truthfulness (C1): beyond the bm25-only fallback there is a softer\n// degradation — `'knn'`. When the kNN leg ran successfully but a kNN-only hit\n// could not be hydrated (no `readDoc` wired, or the source doc was\n// deleted/degraded), the hit keeps its rank but carries an empty snippet, and\n// the payload carries `mode:'knn'` so the caller knows it did not receive full\n// hybrid snippet quality. `'hybrid'` is reserved for the case where both legs\n// ran AND every hit got a real windowed snippet.\n//\n// Hard rules honored (see the slice brief):\n// • Reuses the existing Store API only — no getDoc added, no schema migration.\n// • RRF k=60 RANK-BASED (no score normalization; raw scores never summed).\n// • BM25 snippets reused verbatim (never truncated); kNN windows are prefix\n// windows with `<<term>>` highlight to mirror the FTS5 snippet convention.\n// • Canonical ProjectId only (carried by the store); in-process only.\n\nimport { estimateTokens } from './chunker.js';\nimport { DEFAULT_RRF_K, fuseRrf } from './rrf.js';\nimport type {\n ChunkMeta,\n EmbedFn,\n FtsHit,\n RetrieverHit,\n RetrieverMeta,\n RRFWeights,\n SearchMode,\n SearchResult,\n SourceKind,\n Store,\n VecHit,\n} from './types.js';\n\n// ---------------------------------------------------------------------------\n// Defaults\n// ---------------------------------------------------------------------------\n\n/**\n * Default per-query hit cap applied to BOTH the BM25 and kNN legs so RRF has\n * equal-depth lists to fuse (matches `Store.searchFt`'s default of 10; the\n * store's `knn` default of 5 is intentionally raised here for fusion parity).\n */\nexport const DEFAULT_SEARCH_LIMIT = 10;\n\n/** Default token budget for the greedy packer (spec §6 / config `budgetTokens`). */\nexport const DEFAULT_BUDGET_TOKENS = 4096;\n\n/**\n * Default snippet window for kNN-only hydration, in (estimated) tokens. Mirrors\n * the FTS5 `snippet(..., 16)` 16-token window used by `Store.searchFt` so a\n * kNN-only hit's fallback window is the same width as a BM25 hit's window.\n */\nexport const DEFAULT_SNIPPET_WINDOW_TOKENS = 16;\n\n// ---------------------------------------------------------------------------\n// Options\n// ---------------------------------------------------------------------------\n\n/**\n * Content + meta returned by the optional {@link RetrieverOptions.readDoc}\n * hydrator. This is the shape needed to window-extract a snippet for a kNN-only\n * hit (vec0 returns no meta, and the `Store` interface exposes no read-by-id).\n */\nexport interface ChunkDoc {\n /** Full chunk text (the hydrator decides the source: KV, a future getDoc, …). */\n content: string;\n /** The chunk's stored metadata, if available. */\n meta?: ChunkMeta;\n}\n\n/** Tunables held for the life of the retriever (RRF + budget + hydration). */\nexport interface RetrieverOptions {\n /** RRF constant `k` (defaults to {@link DEFAULT_RRF_K} = 60). */\n k?: number;\n /** RRF per-retriever weights `[bm25Weight, kNNWeight]` (default `[0.5, 0.5]`). */\n weights?: RRFWeights;\n /** Default token budget (overridable per `search()` call). */\n budgetTokens?: number;\n /** kNN-only snippet window width in tokens (default 16). */\n snippetWindowTokens?: number;\n /**\n * Optional content/meta lookup for kNN-only hits. The store's `knn` returns\n * only `{id, source, score}` (vec0 has no meta column) and the `Store`\n * interface exposes no read-by-id, so without a hydrator a purely-semantic\n * hit cannot be windowed. When provided and it hits, the chunk's content is\n * prefix-windowed with `<<query-term>>` highlights (mirroring FTS5). When\n * omitted or it misses, the hit is emitted with an empty snippet — degraded\n * but ranked (F8) — AND the search result's `mode` becomes `'knn'` so the\n * caller can tell the snippet quality is degraded (C1). The engine (t8)\n * wires this from the indexer's `readChunkContent` when a content source\n * exists.\n */\n readDoc?: (id: string) => ChunkDoc | null;\n}\n\n/** Per-call options for {@link Retriever.search}. */\nexport interface SearchOptions {\n /** Max hits requested from EACH leg before fusion (default 10). */\n limit?: number;\n /** Token budget for this call (defaults to the retriever's `budgetTokens`). */\n budgetTokens?: number;\n /** Restrict both legs to a single source bucket (passes through to the store). */\n source?: string;\n}\n\n/** Constructor dependencies for {@link createRetriever}. */\nexport interface RetrieverDeps {\n /** The store handle (the daemon's single-writer handle, or a read-only one). */\n store: Store;\n /** Query/chunk embedder. A throw on `embed(query)` ⇒ BM25-only fallback (F8). */\n embed: EmbedFn;\n /** Tunables + the optional kNN-only hydrator. */\n opts?: RetrieverOptions;\n}\n\n/** The retriever surface returned by {@link createRetriever}. */\nexport interface Retriever {\n search(query: string, opts?: SearchOptions): Promise<SearchResult>;\n}\n\n// ---------------------------------------------------------------------------\n// Small narrowing / windowing helpers\n// ---------------------------------------------------------------------------\n\n/**\n * Defensively narrow an `unknown` meta (from `FtsHit.meta` / `ChunkDoc.meta`)\n * to a {@link ChunkMeta}. The store types meta as `unknown`; for context chunks\n * it is the `ChunkMeta` the indexer wrote, but a foreign/legacy row may carry\n * anything. Returns `undefined` when the required `path` + `parentDocId` fields\n * are absent so the caller can fall back to empty strings rather than crash.\n */\nfunction asChunkMeta(meta: unknown): ChunkMeta | undefined {\n if (typeof meta !== 'object' || meta === null) return undefined;\n const m = meta as Record<string, unknown>;\n const path = m.path;\n const parentDocId = m.parentDocId;\n // The two required fields — bail to `undefined` if either is missing/non-string\n // so callers fall back to empty strings rather than crashing.\n if (typeof path !== 'string' || typeof parentDocId !== 'string') {\n return undefined;\n }\n const chunkIndex = m.chunkIndex;\n const language = m.language;\n const sha256Val = m.sha256;\n return {\n path,\n parentDocId,\n chunkIndex: typeof chunkIndex === 'number' ? chunkIndex : 0,\n language: typeof language === 'string' ? language : 'text',\n // ChunkMeta.sha256 is required (the indexer always writes it); for a\n // foreign/legacy row that lacks it, fall back to '' rather than crash.\n sha256: typeof sha256Val === 'string' ? sha256Val : '',\n };\n}\n\n/** Project the secondary fields of a {@link ChunkMeta} onto a {@link RetrieverMeta}. */\nfunction toRetrieverMeta(meta: ChunkMeta | undefined): RetrieverMeta {\n if (!meta) return {};\n const out: RetrieverMeta = {};\n if (meta.language) out.language = meta.language;\n if (meta.sha256) out.sha256 = meta.sha256;\n out.chunkIndex = meta.chunkIndex;\n return out;\n}\n\n/**\n * Narrow a free-form source string to a {@link SourceKind}. The indexer writes\n * valid source buckets, but the store types `source` as a plain string; an\n * unexpected value defaults to `'codebase'` rather than propagating.\n */\nfunction asSourceKind(source: string): SourceKind {\n switch (source) {\n case 'codebase':\n case 'docs':\n case 'spec':\n case 'memory':\n return source;\n default:\n return 'codebase';\n }\n}\n\n/**\n * Extract a lowercase query-term set for highlight matching. Splits on\n * whitespace/punctuation and drops empties. Used only to wrap matching words in\n * the kNN-only prefix window with `<<…>>` markers (mirrors FTS5's convention);\n * it has no effect on ranking.\n */\nfunction queryTermSet(query: string): Set<string> {\n const terms = query.toLowerCase().match(/[a-z0-9]+/g);\n return terms ? new Set(terms) : new Set();\n}\n\n/**\n * Build a best-effort prefix snippet for a kNN-only hit: the first\n * `windowTokens` whitespace-separated words of `content`, with any word whose\n * lowercase form is in the query-term set wrapped in `<<…>>` (mirroring FTS5's\n * `<<match>>` marker convention so BM25 and kNN snippets read the same way).\n *\n * Unlike FTS5's `snippet()` this is a PREFIX window (FTS5 picks the densest\n * window around match positions; vec0 gives us no match positions to center\n * on). Content shorter than the window is returned whole (highlighted) — it is\n * never padded or mid-word split.\n */\nexport function windowSnippet(content: string, query: string, windowTokens: number): string {\n const trimmed = content.trim();\n if (trimmed.length === 0) return '';\n const terms = queryTermSet(query);\n const words = trimmed.split(/\\s+/);\n const slice = words.slice(0, Math.max(1, windowTokens));\n return (\n slice\n .map((w) => {\n // Compare the alphanumeric core so trailing punctuation doesn't defeat\n // the match (e.g. `ContextEngine,` → core `contextengine`). We wrap the\n // ORIGINAL word, preserving punctuation, when its core matches.\n const core = w.toLowerCase().match(/[a-z0-9]+/)?.[0];\n if (core && terms.has(core)) return `<<${w}>>`;\n return w;\n })\n .join(' ')\n // Mark truncation only when content genuinely exceeded the window, so a\n // caller can tell a full short snippet from a truncated long one.\n .concat(words.length > slice.length ? ' …' : '')\n );\n}\n\n// ---------------------------------------------------------------------------\n// Collapse + budget\n// ---------------------------------------------------------------------------\n\n/**\n * Collapse duplicate parent-docs: keep the FIRST (top-scoring, since `hits` is\n * already RRF-sorted desc) chunk per `parentDocId`. A hit with an EMPTY\n * `parentDocId` (an unhydrated kNN-only hit) collapses on its own `id`, so the\n * missing parent link can never cause two unrelated semantic hits to merge.\n */\nfunction collapseByParent(hits: ReadonlyArray<RetrieverHit>): RetrieverHit[] {\n const seen = new Set<string>();\n const out: RetrieverHit[] = [];\n for (const hit of hits) {\n const key = hit.parentDocId || hit.id;\n if (seen.has(key)) continue;\n seen.add(key);\n out.push(hit);\n }\n return out;\n}\n\n/** Outcome of a greedy budget pack. */\ninterface PackedResult {\n hits: RetrieverHit[];\n consumedTokens: number;\n truncated: boolean;\n}\n\n/**\n * Greedy token-budget fill over the (already collapsed) ranked list: accumulate\n * `estimateTokens(snippet)` per hit until `budgetTokens` is reached. The FIRST\n * hit is always admitted even if it alone exceeds the budget (returning zero\n * results for one over-large top hit is worse than a small budget overshoot).\n * `truncated` is `true` iff the budget stopped the iteration before the list was\n * exhausted (DS-6).\n */\nfunction packBudget(hits: ReadonlyArray<RetrieverHit>, budgetTokens: number): PackedResult {\n let consumed = 0;\n const packed: RetrieverHit[] = [];\n for (const hit of hits) {\n const tokens = estimateTokens(hit.snippet);\n if (packed.length > 0 && consumed + tokens > budgetTokens) {\n return { hits: packed, consumedTokens: consumed, truncated: true };\n }\n packed.push(hit);\n consumed += tokens;\n }\n return { hits: packed, consumedTokens: consumed, truncated: false };\n}\n\n// ---------------------------------------------------------------------------\n// Factory\n// ---------------------------------------------------------------------------\n\n/**\n * Build a hybrid retriever over an injected store + embedder.\n *\n * The retriever owns no state beyond its configured tunables; every `search`\n * call is independent and side-effect-free (read-only against the store). It is\n * the ONLY read path for context — `context_search` (t9) delegates here. Write\n * (`indexDoc`/`upsertVec`) stays with the indexer/engine; the retriever never\n * mutates the store.\n */\nexport function createRetriever(deps: RetrieverDeps): Retriever {\n const { store, embed } = deps;\n const k = deps.opts?.k ?? DEFAULT_RRF_K;\n // `weights` is passed through verbatim; fuseRrf applies its own [0.5, 0.5]\n // default when undefined. (DEFAULT_RRF_WEIGHTS is `readonly` while\n // FuseRrfOptions.weights is a mutable tuple, so defaulting here would need a\n // copy — cleaner to let fuseRrf, which already handles the default, do it.)\n const weights = deps.opts?.weights;\n const defaultBudget = deps.opts?.budgetTokens ?? DEFAULT_BUDGET_TOKENS;\n const snippetWindowTokens = deps.opts?.snippetWindowTokens ?? DEFAULT_SNIPPET_WINDOW_TOKENS;\n const readDoc = deps.opts?.readDoc;\n\n return {\n async search(query: string, opts?: SearchOptions): Promise<SearchResult> {\n const limit = opts?.limit ?? DEFAULT_SEARCH_LIMIT;\n const budgetTokens = opts?.budgetTokens ?? defaultBudget;\n const source = opts?.source;\n\n // --- BM25 leg (always attempted; the cheap, always-available signal) ---\n let ftsHits: FtsHit[] = [];\n let ftsFailed = false;\n try {\n ftsHits = store.searchFt(query, { limit, source });\n } catch {\n // e.g. a foreign read-only DB with no `docs_fts` table. Keep going —\n // the vec leg may still return something; flag degraded.\n ftsFailed = true;\n }\n\n // --- kNN leg (attempted; any failure ⇒ BM25-only degradation, F8) ---\n let knnHits: VecHit[] = [];\n let knnFailed = false;\n try {\n const qvec = await embed(query); // throws on kind:'none' / load / provider error\n try {\n knnHits = store.knn(qvec, { limit, source });\n } catch {\n // vec table missing or query malformed — degrade to BM25-only.\n knnFailed = true;\n }\n } catch {\n // embed() threw: the embedder is unavailable. BM25-only (F8).\n knnFailed = true;\n }\n\n // --- RRF fusion (rank-based; raw BM25+cosine scores NEVER summed) ---\n const fused = fuseRrf(ftsHits, knnHits, { k, weights });\n\n // Index BM25 hits by id so each fused row can pick up its verbatim\n // snippet + ChunkMeta in O(1) (a kNN-only id simply misses here).\n const ftsById = new Map<string, FtsHit>();\n for (const h of ftsHits) {\n if (!ftsById.has(h.id)) ftsById.set(h.id, h);\n }\n\n // Track whether ANY kNN-only hit could not be hydrated (no readDoc, or\n // readDoc missed). When true, the search did not deliver full hybrid\n // snippet quality — surface that honestly via mode:'knn' (degraded but\n // distinct from 'bm25-only', which means the kNN leg did not run at all).\n let knnUnhydrated = false;\n\n // --- Enrich each fused row into a public RetrieverHit ---\n const enriched: RetrieverHit[] = fused.map((row) => {\n const fts = ftsById.get(row.id);\n if (fts) {\n // BM25 path: reuse the FTS5 windowed snippet VERBATIM (F7).\n const meta = asChunkMeta(fts.meta);\n return {\n id: row.id,\n source: asSourceKind(row.source),\n score: row.score,\n snippet: fts.snippet,\n path: meta?.path ?? '',\n parentDocId: meta?.parentDocId ?? '',\n meta: toRetrieverMeta(meta),\n };\n }\n // kNN-only path: hydrate content + meta to window-extract a snippet.\n if (readDoc) {\n const doc = readDoc(row.id);\n if (doc) {\n const meta = asChunkMeta(doc.meta);\n return {\n id: row.id,\n source: asSourceKind(row.source),\n score: row.score,\n snippet: windowSnippet(doc.content, query, snippetWindowTokens),\n path: meta?.path ?? '',\n parentDocId: meta?.parentDocId ?? '',\n meta: toRetrieverMeta(meta),\n };\n }\n }\n // Unhydratable kNN-only hit: keep the rank, emit an empty snippet.\n // (vec0 carries no meta; the Store has no read-by-id; no readDoc\n // wired, OR readDoc returned null because the source doc was\n // deleted/degraded.) Flag so the reported mode reflects the truth.\n knnUnhydrated = true;\n return {\n id: row.id,\n source: asSourceKind(row.source),\n score: row.score,\n snippet: '',\n path: '',\n parentDocId: '',\n meta: {},\n };\n });\n\n // Mode truthfulness (C1): 'bm25-only' when the kNN leg failed entirely;\n // 'knn' when the kNN leg ran but at least one kNN-only hit couldn't be\n // hydrated (rank delivered, snippet not); 'hybrid' when both legs ran\n // and every hit got a real snippet.\n const mode: SearchMode = knnFailed ? 'bm25-only' : knnUnhydrated ? 'knn' : 'hybrid';\n const degraded = knnFailed || ftsFailed || knnUnhydrated;\n\n // --- Collapse duplicate parent-docs, then pack to the token budget ---\n const collapsed = collapseByParent(enriched);\n const packed = packBudget(collapsed, budgetTokens);\n\n return {\n results: packed.hits,\n consumedTokens: packed.consumedTokens,\n truncated: packed.truncated,\n degraded,\n mode,\n };\n },\n };\n}\n","// ContextEngine for @noir-ai/context (slice S6, task t8).\n//\n// The single object that ties the embedder, indexer, and retriever together and\n// that the daemon injects as `ctx.context` — the new optional ServerContext\n// service, mirroring `ctx.store` / `ctx.engine`. It is constructed ONCE per\n// serve lifecycle from the daemon's already-open Store handle (the single\n// writer — blueprint D6: in-process, no sidecar, canonical ProjectId) and a\n// resolved EmbedderConfig, exactly as `buildWorkflowEngine` is built once from\n// the same handle.\n//\n// Responsibilities:\n// • resolve the embedder once (`createEmbedFn`) and share it between the\n// indexer (writes vectors) and the retriever (embeds the query), so a lazy\n// local model loads at most once per lifecycle;\n// • own the indexer + retriever, delegating `indexPaths` / `search` to them;\n// • surface a `status()` snapshot (spec F11) that mirrors `buildStoreStatus`\n// and adds the embedder description + the indexed-file count.\n//\n// Single-writer discipline: the engine — through its indexer — is the ONLY\n// thing that calls `indexDoc`/`upsertVec` for context. The retriever is purely\n// read-only. Both reuse the injected handle; the engine never opens a second\n// store connection.\n//\n// Degradation (mirrors the store's degraded story, spec F8/F12):\n// • engine-level `degraded` = the store handle is read-only (the daemon-down\n// fallback) OR the embedder is disabled (`kind:'none'`). It is a PERSISTENT\n// flag reported by `status()`.\n// • per-call `degraded` (on SearchResult) is computed by the retriever for\n// each query (true when the kNN leg failed THAT call). The two are\n// independent and both honest: `status()` describes the configured state;\n// `search()` describes the actual outcome.\n//\n// kNN-only hydration (spec F7, C1): the Store interface exposes no read-by-id\n// and vec0 carries no meta column, so a purely-semantic hit (kNN-only, no BM25\n// snippet) cannot be windowed without a content source. The engine wires the\n// indexer's `readChunkContent` as the retriever's `readDoc` hydrator — the\n// indexer owns the registry layout (`ctx:registry` + `ctx:file:<key>`) and the\n// chunk-id format, so it is the natural read-by-id. When the hydrator hits, the\n// chunk's CLEAN content is prefix-windowed with `<<query-term>>` highlights\n// (mirroring FTS5). When it misses (chunk unindexed, file deleted, content\n// drift), the hit degrades to an empty snippet AND the search result's `mode`\n// becomes `'knn'` so the caller knows the snippet quality is degraded (the\n// retriever's honest-mode logic, C1).\n\nimport { createEmbedFn } from './embedders/index.js';\nimport { CTX_REGISTRY_KEY, createIndexer, type Indexer, type IndexPathOptions } from './indexer.js';\nimport { createRetriever, type Retriever, type SearchOptions } from './retriever.js';\nimport type {\n EmbedderConfig,\n EmbedderInfo,\n IndexResult,\n ProjectId,\n SearchResult,\n Store,\n} from './types.js';\n\n// ---------------------------------------------------------------------------\n// Status payload (mirrors StoreStatus in @noir-ai/daemon server.ts)\n// ---------------------------------------------------------------------------\n\n/**\n * JSON returned by the `context_status` MCP tool (spec F11). Mirrors\n * `StoreStatus` and adds the active embedder description + the indexed-file\n * count (the size of the `ctx:registry` KV list the indexer maintains).\n */\nexport interface ContextStatus {\n ok: boolean;\n /** Canonical project id (never a filesystem path — blueprint D6). */\n projectId: string;\n /** Rows in `docs` (live read off the single writer handle — no cache). */\n docCount: number;\n /** Rows in `vec0` (live read off the single writer handle — no cache). */\n vecCount: number;\n /** Number of files currently tracked in `ctx:registry`. */\n indexedFiles: number;\n /** Active embedder description (`kind:'none'` ⇒ `{kind:'none', dim:0}`). */\n embedder: EmbedderInfo;\n /**\n * Persistent degradation flag: the store handle is read-only OR the embedder\n * is disabled (`kind:'none'`). Per-query degradation lives on SearchResult.\n */\n degraded: boolean;\n}\n\n// ---------------------------------------------------------------------------\n// Construction options\n// ---------------------------------------------------------------------------\n\n/** Construction options for {@link ContextEngine}. */\nexport interface ContextEngineOptions {\n /**\n * The daemon's store handle — the ONLY storage surface used (single writer).\n * May be a read-only fallback handle; pass {@link storeDegraded} so `status()`\n * can report it.\n */\n store: Store;\n /**\n * Project root. Paths passed to {@link ContextEngine.indexPaths} resolve\n * against it and (because the indexer is root-bound) are stored repo-relative\n * for portable `meta.path` across checkouts.\n */\n root: string;\n /** Canonical project identifier (NEVER a filesystem path). */\n projectId: ProjectId;\n /**\n * Resolved embedder config (from `resolveEmbedderConfig`). Construction never\n * touches the network or native runtime — even `local` defers its dynamic\n * import to the first `embed()` call.\n */\n embedderCfg: EmbedderConfig;\n /**\n * True when `store` was opened read-only (the daemon-down fallback). Threads\n * the store's degraded story into the engine's persistent `degraded` flag so\n * `status()` reports it and callers can branch on it.\n */\n storeDegraded?: boolean;\n}\n\n// ---------------------------------------------------------------------------\n// Engine\n// ---------------------------------------------------------------------------\n\n/**\n * Noir's embedded hybrid retrieval engine — the `ctx.context` service.\n *\n * Constructed once per serve lifecycle (mirror `buildWorkflowEngine`) from the\n * daemon's store handle + a resolved {@link EmbedderConfig}. Resolves the\n * embedder, then owns the indexer (the only context writer) and the retriever\n * (the only context reader). Public surface: {@link indexPaths}, {@link search},\n * {@link status} — the three operations the `context_{index,search,status}` MCP\n * tools (task t9) delegate to.\n */\nexport class ContextEngine {\n /** The daemon's single-writer store handle (possibly read-only). */\n readonly store: Store;\n /** Project root (paths resolve against this). */\n readonly root: string;\n /** Canonical project identifier. */\n readonly projectId: ProjectId;\n /** Description of the active embedder (surfaced by `status()`). */\n readonly embedder: EmbedderInfo;\n /**\n * Persistent degradation flag (read-only store OR `kind:'none'`). Per-query\n * degradation lives on {@link SearchResult}.\n */\n readonly degraded: boolean;\n\n private readonly indexer: Indexer;\n private readonly retriever: Retriever;\n\n constructor(opts: ContextEngineOptions) {\n this.store = opts.store;\n this.root = opts.root;\n this.projectId = opts.projectId;\n\n // Resolve the embedder ONCE; share it between the indexer (writes vectors)\n // and the retriever (embeds the query) so a lazy local model loads at most\n // once per serve lifecycle (the daemon owns a single ContextEngine).\n const { embed, info } = createEmbedFn(opts.embedderCfg);\n this.embedder = info;\n\n // Persistent degradation: read-only store OR vectors explicitly disabled.\n // (A misconfigured remote/ollama embedder is NOT degraded here — it builds\n // cleanly and surfaces its failure per-call via SearchResult.degraded, F8.)\n this.degraded = opts.storeDegraded === true || info.kind === 'none';\n\n // Both reuse the SAME injected handle — the engine never opens a second\n // connection (single writer). The indexer is root-bound so path keys are\n // repo-relative; the retriever is read-only and uses the retriever defaults\n // (RRF k=60 / [0.5,0.5], budget 4096). The retriever's `readDoc` hydrator\n // is wired to the indexer's `readChunkContent` so kNN-only hits hydrate to\n // a real windowed snippet from the source file (C1); a miss surfaces as\n // mode:'knn' on the per-call SearchResult (degraded but honest).\n this.indexer = createIndexer({ store: opts.store, embed, info, root: opts.root });\n this.retriever = createRetriever({\n store: opts.store,\n embed,\n opts: { readDoc: (id) => this.indexer.readChunkContent(id) },\n });\n }\n\n /**\n * Incrementally index `paths` (files or directories) into the store. Delegates\n * to the indexer (spec F1/F3/F4). The engine — through the indexer — is the\n * ONLY context writer; the daemon stays the single writer via this handle.\n */\n indexPaths(paths: string[], opts?: IndexPathOptions): Promise<IndexResult> {\n return this.indexer.indexPaths(paths, opts);\n }\n\n /**\n * Hybrid search: BM25 ∪ cosine-kNN fused by RRF (k=60), collapsed by\n * parent-doc, packed to a token budget with window-extracted snippets (spec\n * F6/F7). Delegates to the retriever. The per-call `degraded`/`mode` on the\n * returned {@link SearchResult} reflect THIS query's outcome (independent of\n * the engine's persistent {@link degraded}).\n */\n search(query: string, opts?: SearchOptions): Promise<SearchResult> {\n return this.retriever.search(query, opts);\n }\n\n /**\n * Snapshot the engine's state (spec F11; mirrors `buildStoreStatus`).\n *\n * `docCount`/`vecCount` are live reads off the single writer handle (no\n * cache); `indexedFiles` is the size of the `ctx:registry` KV list maintained\n * by the indexer; `embedder` describes the active provider; `degraded` is the\n * persistent flag (read-only store OR `kind:'none'`).\n */\n status(): ContextStatus {\n const registry = this.store.getState<string[]>(CTX_REGISTRY_KEY) ?? [];\n return {\n ok: true,\n projectId: this.store.projectId,\n docCount: this.store.countDocs(),\n vecCount: this.store.countVecs(),\n indexedFiles: registry.length,\n embedder: this.embedder,\n degraded: this.degraded,\n };\n }\n}\n","// SP-C (deferred slice) — semantic duplicate detection.\n//\n// Embeds file contents via an injected EmbedFn (the S6 local embedder in\n// production; a deterministic fake in tests) and finds near-duplicate pairs by\n// cosine similarity. This is the ONLY mechanism that catches cross-file\n// SEMANTIC overlap (e.g. a hand-mirrored CLAUDE.md ≈ AGENTS.md) — exact\n// content-hash cannot, because the two files differ byte-wise.\n\nimport { l2normalize } from './embedders/normalize.js';\nimport type { EmbedFn } from './types.js';\n\nexport interface DupCandidate {\n /** Path label used only for reporting (repo-relative or absolute). */\n path: string;\n /** Full text content to embed. Empty/whitespace-only files are skipped. */\n text: string;\n}\n\nexport interface DupPair {\n /** The two near-duplicate paths, ordered `a ≤ b` (each unordered pair once). */\n a: string;\n b: string;\n /** Cosine similarity in [−1, 1] (≥ the threshold). */\n similarity: number;\n}\n\n/** Default near-duplicate threshold (cosine). High → few false positives. */\nexport const DEFAULT_DUP_THRESHOLD = 0.9;\n\n/**\n * Find near-duplicate file pairs by cosine similarity over embedded contents.\n * `embed` is expected to return an L2-normalized vector (the local/remote\n * embedders do); vectors are re-normalized defensively so cosine = dot product.\n * Complexity is O(n²) in the file count — fine for the small candidate set a\n * dedup scan feeds (host context files + `.noir/` docs). Empty-text files are\n * skipped. Returns pairs with similarity ≥ `threshold`, sorted desc, each\n * unordered pair once.\n */\nexport async function findSemanticDuplicates(\n files: readonly DupCandidate[],\n embed: EmbedFn,\n threshold: number = DEFAULT_DUP_THRESHOLD,\n): Promise<DupPair[]> {\n const embedded = await Promise.all(\n files.map(async (f) => {\n if (f.text.trim().length === 0) return { path: f.path, vec: null as Float32Array | null };\n return { path: f.path, vec: l2normalize(await embed(f.text)) };\n }),\n );\n const pairs: DupPair[] = [];\n for (let i = 0; i < embedded.length; i++) {\n const vi = embedded[i];\n if (!vi?.vec) continue;\n for (let j = i + 1; j < embedded.length; j++) {\n const vj = embedded[j];\n if (!vj?.vec) continue;\n const sim = dot(vi.vec, vj.vec);\n if (sim >= threshold) {\n const [a, b] = vi.path <= vj.path ? [vi.path, vj.path] : [vj.path, vi.path];\n pairs.push({ a, b, similarity: sim });\n }\n }\n }\n pairs.sort((x, y) => y.similarity - x.similarity);\n return pairs;\n}\n\n/** Dot product (both vectors assumed L2-normalized ⇒ cosine similarity). */\nfunction dot(a: Float32Array, b: Float32Array): number {\n const n = Math.min(a.length, b.length);\n let s = 0;\n for (let i = 0; i < n; i++) s += (a[i] ?? 0) * (b[i] ?? 0);\n return s;\n}\n","// Context-local types for @noir-ai/context (slice S6).\n//\n// These are the package's OWN interfaces — the storage surface (`Store`,\n// `EmbedFn`, `IndexDoc`, `FtsHit`, `VecHit`) is re-exported from\n// @noir-ai/store below, and the user-facing zod schema lives in @noir-ai/core\n// (`NoirConfigSchema.context`). There is deliberately NO zod here: core owns\n// the user schema and context owns this factory/engine type surface, which\n// keeps the dependency graph acyclic (core never imports context).\n//\n// Conventions mirror @noir-ai/store types (JSDoc on every interface/field)\n// and @noir-ai/workflow types (`as const` source tables → derived unions).\n\n// ---------------------------------------------------------------------------\n// Source taxonomy\n// ---------------------------------------------------------------------------\n\n/**\n * Logical source buckets a chunk can belong to. Reused by S7 (memory) —\n * `'memory'` is reserved for that slice. The store treats `source` as a\n * free-form string; this enum is the context package's contract for what a\n * well-formed `source` value is.\n */\nexport const SOURCES = ['codebase', 'docs', 'spec', 'memory'] as const;\nexport type SourceKind = (typeof SOURCES)[number];\n\n// ---------------------------------------------------------------------------\n// Embedder configuration + info\n// ---------------------------------------------------------------------------\n\n/** Discriminant for {@link EmbedderConfig}. */\nexport type EmbedderKind = 'local' | 'remote' | 'ollama' | 'none';\n\n/**\n * Local in-process embeddings via `@huggingface/transformers`\n * (default `Xenova/all-MiniLM-L6-v2`, 384-dim → zero vec0 migration).\n * The model is loaded lazily on first `embed()` call, never at import time.\n */\nexport interface LocalEmbedderConfig {\n kind: 'local';\n /** HF repo id (defaults to `Xenova/all-MiniLM-L6-v2` in the factory). */\n model?: string;\n}\n\n/**\n * Opt-in remote embeddings (OpenAI / Voyage / Cohere). Provider-explicit:\n * sends source text to a cloud endpoint. Vectors are L2-normalized and\n * Matryoshka-truncated to `dim` client-side to stay vec0-compatible.\n * NEVER the default (blueprint D6 — no silent paid calls).\n */\nexport interface RemoteEmbedderConfig {\n kind: 'remote';\n /** Provider key, e.g. `'openai'` | `'voyage'` | `'cohere'`. */\n provider: string;\n /** API key. If absent the factory throws a clear \"not configured\" error. */\n apiKey?: string;\n /** Provider-specific model id. */\n model: string;\n /** Target dimensionality (must be 384 to match the vec0 table). */\n dim: number;\n}\n\n/**\n * Opt-in Ollama embeddings via a user-supplied base URL. Provider-explicit;\n * never the default.\n */\nexport interface OllamaEmbedderConfig {\n kind: 'ollama';\n /** Base URL of the Ollama server, e.g. `http://localhost:11434`. */\n baseURL: string;\n /** Ollama model tag. */\n model: string;\n}\n\n/**\n * Escape hatch: disable vectors entirely. `search` degrades to BM25-only\n * (`degraded:true, mode:'bm25-only'`).\n */\nexport interface NoneEmbedderConfig {\n kind: 'none';\n}\n\n/**\n * Discriminated configuration for the embedder factory (`createEmbedFn`,\n * task t3). The core zod schema maps onto this via `resolveEmbedderConfig`\n * (task t10) — that mapper is the only bridge between the two layers.\n */\nexport type EmbedderConfig =\n | LocalEmbedderConfig\n | RemoteEmbedderConfig\n | OllamaEmbedderConfig\n | NoneEmbedderConfig;\n\n/**\n * Description of the active embedder, surfaced by `context_status` and\n * recorded in store KV (`ctx:embedder`) so a model swap is detectable.\n * For `kind:'none'`, `dim` is `0` and `model` is `undefined`.\n */\nexport interface EmbedderInfo {\n kind: EmbedderKind;\n model?: string;\n dim: number;\n}\n\n// ---------------------------------------------------------------------------\n// Chunking\n// ---------------------------------------------------------------------------\n\n/** Metadata attached to every chunk (stored as `docs.meta` / `vec.meta`). */\nexport interface ChunkMeta {\n /** Repo-relative or absolute path of the source file. */\n path: string;\n /**\n * `sha256(path)` — stable parent-document id. The budget packer collapses\n * duplicate parent-docs on this field so one file can't flood the results.\n */\n parentDocId: string;\n /** 0-based position of this chunk within its parent file. */\n chunkIndex: number;\n /** Detected language / extension hint (e.g. `'typescript'`, `'markdown'`). */\n language: string;\n /** SHA-256 of this chunk's (post-identifier-explosion) content. */\n sha256: string;\n}\n\n/**\n * A unit of indexed content. One chunk → one `docs` row (FTS-synced) AND one\n * `vec0` row under the SAME `id`, so RRF can join BM25 and kNN hits.\n * `id` is `<sha256(path)>#chunk-<n>` (stable across re-indexing).\n */\nexport interface Chunk {\n id: string;\n source: SourceKind;\n content: string;\n meta: ChunkMeta;\n}\n\n// ---------------------------------------------------------------------------\n// Retrieval\n// ---------------------------------------------------------------------------\n\n/** Pair of non-negative weights `[bm25Weight, kNNWeight]`; defaults `[0.5, 0.5]`. */\nexport type RRFWeights = [number, number];\n\n/**\n * Retrieval mode actually used for a `search` call.\n * - `'hybrid'` — BM25 ∪ kNN fused by RRF, AND every kNN-only hit was hydrated\n * to a real windowed snippet (the ideal outcome).\n * - `'knn'` — the kNN leg ran but at least one kNN-only hit could NOT be\n * hydrated (no `readDoc` hydrator, or the source doc was deleted/degraded),\n * so that hit keeps its rank but carries an empty snippet. Honest about the\n * fact the caller did not receive the full hybrid snippet quality.\n * - `'bm25-only'` — degraded fallback when the embedder is unavailable\n * (`kind:'none'` or `embed()` threw); the kNN leg did not run at all.\n */\nexport type SearchMode = 'hybrid' | 'knn' | 'bm25-only';\n\n/**\n * Secondary metadata carried on a retriever hit. All optional: a BM25 hit\n * reaches the retriever already enriched, while a kNN-only hit may only\n * carry `id` + `source` until the retriever backfills the rest. The primary\n * `path` / `parentDocId` fields live top-level on {@link RetrieverHit}\n * (spec F6); this holds the remaining enrichment.\n */\nexport interface RetrieverMeta {\n language?: string;\n sha256?: string;\n chunkIndex?: number;\n}\n\n/**\n * A single ranked result. `snippet` is FTS5 window-extracted\n * (`snippet(docs_fts,0,'<<','>>','…',16)`) for BM25 hits, or a window around\n * the chunk's first N tokens for kNN-only hits — never truncated mid-token.\n *\n * `path` and `parentDocId` are top-level (spec F6 enumerates the item as\n * `{id, source, score, snippet, path, parentDocId}`): the retriever\n * backfills them from the chunk's `meta` before the hit leaves `search()`,\n * so they are always present at the public/MCP boundary.\n */\nexport interface RetrieverHit {\n id: string;\n source: SourceKind;\n /** RRF-fused rank score (rank-based; not a normalized similarity). */\n score: number;\n snippet: string;\n /** Repo-relative or absolute path of the source file (backfilled from meta). */\n path: string;\n /** `sha256(path)` — stable parent-document id (backfilled from meta). */\n parentDocId: string;\n /** Secondary enrichment (language, sha256, chunkIndex). */\n meta: RetrieverMeta;\n}\n\n/**\n * Return value of `search`. `results` is packed greedily into `budgetTokens`\n * (default ~4k): when the budget is exhausted before the ranked list,\n * `truncated` is `true` and `consumedTokens` ~= `budgetTokens`.\n */\nexport interface SearchResult {\n results: RetrieverHit[];\n /** Tokens accumulated across the returned snippets. */\n consumedTokens: number;\n /** `true` if the budget was hit before exhausting the ranked list. */\n truncated: boolean;\n /** `true` when the embedder was unavailable (BM25-only fallback, F8). */\n degraded: boolean;\n /** Retrieval mode actually used. */\n mode: SearchMode;\n}\n\n// ---------------------------------------------------------------------------\n// Indexing\n// ---------------------------------------------------------------------------\n\n/**\n * Return value of `indexPaths`. Counts are mutually consistent: a chunk is\n * either newly `indexed`, `skipped` (content-hash unchanged in KV), or part\n * of a `deleted`/`failed` file. `totalChunks` is the running total tracked\n * for the indexed paths after this call. `degraded` mirrors the search path\n * (spec F10: `context_index -> {indexed, skipped, deleted, degraded}`).\n */\nexport interface IndexResult {\n /** Chunks newly written to `docs` + `vec0` this run. */\n indexed: number;\n /** Chunks whose SHA-256 matched KV (unchanged since last index). */\n skipped: number;\n /** Files removed since last index — their chunks + vectors were deleted. */\n deleted: number;\n /**\n * True when the embedder was unavailable this run (docs indexed without\n * vectors). Mirrors SearchResult.degraded / StoreStatus.degraded.\n */\n degraded: boolean;\n /** Files that could not be read/parsed (binary, IO error, encoding). */\n failed: number;\n /** Total chunks now tracked for the indexed paths. */\n totalChunks: number;\n}\n\n// ---------------------------------------------------------------------------\n// Re-exports (single import surface for the rest of the package)\n// ---------------------------------------------------------------------------\n\n// Canonical project identifier (NEVER a filesystem path — blueprint D6).\nexport type { ProjectId } from '@noir-ai/core';\n// The storage seam S6 builds on. Re-exported here so context modules import\n// from `../types.js` (or the barrel) rather than reaching into @noir-ai/store\n// directly for the handful of primitives they need.\nexport type {\n EmbedFn,\n FtsHit,\n IndexDoc,\n Store,\n VecHit,\n} from '@noir-ai/store';\n"],"mappings":"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","names":["readErrorBody","createHash","createHash"]}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@noir-ai/context",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.3.0-beta.1",
|
|
4
4
|
"description": "Noir context — SHA-256 incremental indexer and hybrid retrieval (BM25 + vector kNN + Reciprocal Rank Fusion).",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"author": "agaaaptr",
|
|
@@ -45,8 +45,8 @@
|
|
|
45
45
|
],
|
|
46
46
|
"dependencies": {
|
|
47
47
|
"@huggingface/transformers": "^3.0.0",
|
|
48
|
-
"@noir-ai/store": "1.
|
|
49
|
-
"@noir-ai/core": "1.
|
|
48
|
+
"@noir-ai/store": "1.3.0-beta.1",
|
|
49
|
+
"@noir-ai/core": "1.3.0-beta.1"
|
|
50
50
|
},
|
|
51
51
|
"devDependencies": {
|
|
52
52
|
"@types/node": "^26.1.1"
|