@noir-ai/context 1.0.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.js ADDED
@@ -0,0 +1,1182 @@
1
+ // src/hash.ts
2
+ import { createHash } from "crypto";
3
+ function sha256Hex(text) {
4
+ return createHash("sha256").update(text, "utf8").digest("hex");
5
+ }
6
+
7
+ // src/chunker.ts
8
+ var DEFAULT_CHUNK_MAX_TOKENS = 512;
9
+ var DEFAULT_CHUNK_OVERLAP = 64;
10
+ var TOKEN_ESTIMATE_FACTOR = 1.3;
11
+ function explodeIdentifiers(text) {
12
+ const words = text.match(/[A-Za-z0-9]+/g);
13
+ if (!words) return "";
14
+ const tokens = [];
15
+ for (const word of words) {
16
+ const spaced = word.replace(/([a-z0-9])([A-Z])/g, "$1 $2").replace(/([A-Z]+)([A-Z][a-z])/g, "$1 $2");
17
+ for (const piece of spaced.split(/\s+/)) {
18
+ if (piece.length > 0) tokens.push(piece.toLowerCase());
19
+ }
20
+ }
21
+ return tokens.join(" ");
22
+ }
23
+ function withIdentifierExplosion(content) {
24
+ const exploded = explodeIdentifiers(content);
25
+ return exploded.length > 0 ? `${content}
26
+ ${exploded}` : content;
27
+ }
28
+ function estimateTokens(text) {
29
+ const trimmed = text.trim();
30
+ if (trimmed.length === 0) return 0;
31
+ const words = trimmed.split(/\s+/).filter((w) => w.length > 0);
32
+ return Math.ceil(words.length * TOKEN_ESTIMATE_FACTOR);
33
+ }
34
+ var EXT_TO_LANGUAGE = {
35
+ ts: "typescript",
36
+ tsx: "tsx",
37
+ mts: "typescript",
38
+ cts: "typescript",
39
+ js: "javascript",
40
+ jsx: "jsx",
41
+ mjs: "javascript",
42
+ cjs: "javascript",
43
+ md: "markdown",
44
+ mdx: "markdown",
45
+ py: "python",
46
+ rb: "ruby",
47
+ rs: "rust",
48
+ go: "go",
49
+ java: "java",
50
+ kt: "kotlin",
51
+ json: "json",
52
+ yml: "yaml",
53
+ yaml: "yaml",
54
+ toml: "toml",
55
+ sh: "shell",
56
+ bash: "shell",
57
+ zsh: "shell",
58
+ sql: "sql",
59
+ html: "html",
60
+ htm: "html",
61
+ css: "css",
62
+ scss: "scss",
63
+ xml: "xml",
64
+ txt: "text"
65
+ };
66
+ function inferLanguage(path) {
67
+ const ext = path.toLowerCase().match(/\.([a-z0-9]+)$/)?.[1];
68
+ if (!ext) return "text";
69
+ return EXT_TO_LANGUAGE[ext] ?? "text";
70
+ }
71
+ function isMarkdown(path, language) {
72
+ if (language === "markdown") return true;
73
+ return /\.(md|mdx)$/i.test(path);
74
+ }
75
+ function defaultSource(path, language) {
76
+ return isMarkdown(path, language) ? "docs" : "codebase";
77
+ }
78
+ function parentDocIdOf(path) {
79
+ return sha256Hex(path);
80
+ }
81
+ function chunkSha256(content) {
82
+ return sha256Hex(withIdentifierExplosion(content));
83
+ }
84
+ var ATX_HEADING = /^(#{1,6})\s/;
85
+ var FENCE_OPEN = /^\s*(`{3,}|~{3,})/;
86
+ function markdownSections(content) {
87
+ const lines = content.split("\n");
88
+ const sections = [];
89
+ let current = [];
90
+ let inFence = false;
91
+ let fenceMarker = "";
92
+ const flush = () => {
93
+ if (current.length === 0) return;
94
+ const body = current.join("\n").replace(/\s+$/, "");
95
+ if (body.length > 0) sections.push(body);
96
+ current = [];
97
+ };
98
+ for (const line of lines) {
99
+ const fence = FENCE_OPEN.exec(line);
100
+ if (fence && fence[1]) {
101
+ const marker = fence[1].charAt(0);
102
+ if (!inFence) {
103
+ inFence = true;
104
+ fenceMarker = marker;
105
+ } else if (marker === fenceMarker) {
106
+ inFence = false;
107
+ fenceMarker = "";
108
+ }
109
+ current.push(line);
110
+ continue;
111
+ }
112
+ if (!inFence && ATX_HEADING.test(line)) {
113
+ flush();
114
+ current = [line];
115
+ } else {
116
+ current.push(line);
117
+ }
118
+ }
119
+ flush();
120
+ return sections;
121
+ }
122
+ function codeWindows(content, maxTokens, overlap) {
123
+ const lines = content.split("\n");
124
+ const n = lines.length;
125
+ if (n === 0) return [];
126
+ const windows = [];
127
+ let start = 0;
128
+ let safety = 2 * n + 8;
129
+ while (start < n && safety > 0) {
130
+ safety -= 1;
131
+ let end = start;
132
+ let body = "";
133
+ for (let i = start; i < n; i++) {
134
+ const line = lines[i];
135
+ if (line === void 0) break;
136
+ const trial = body.length === 0 ? line : `${body}
137
+ ${line}`;
138
+ if (i > start && estimateTokens(trial) > maxTokens) break;
139
+ body = trial;
140
+ end = i + 1;
141
+ }
142
+ if (body.length > 0) windows.push(body);
143
+ if (end >= n) break;
144
+ if (end - 1 <= start) {
145
+ start = end;
146
+ continue;
147
+ }
148
+ let carryStart = end - 1;
149
+ let carried = 0;
150
+ while (carryStart > start && carried < overlap) {
151
+ const line = lines[carryStart];
152
+ if (line === void 0) {
153
+ carryStart -= 1;
154
+ continue;
155
+ }
156
+ carried += estimateTokens(line);
157
+ if (carried >= overlap) break;
158
+ carryStart -= 1;
159
+ }
160
+ if (carryStart <= start) carryStart = start + 1;
161
+ if (carryStart > end - 1) carryStart = end - 1;
162
+ start = carryStart;
163
+ }
164
+ return windows;
165
+ }
166
+ function chunkFile(opts) {
167
+ const { path, content } = opts;
168
+ if (content.trim().length === 0) return [];
169
+ const language = opts.language ?? inferLanguage(path);
170
+ const source = opts.source ?? defaultSource(path, language);
171
+ const maxTokens = opts.maxTokens ?? DEFAULT_CHUNK_MAX_TOKENS;
172
+ const overlap = opts.overlap ?? DEFAULT_CHUNK_OVERLAP;
173
+ const parentDocId = parentDocIdOf(path);
174
+ const bodies = isMarkdown(path, language) ? markdownSections(content) : codeWindows(content, maxTokens, overlap);
175
+ const chunks = [];
176
+ for (let i = 0; i < bodies.length; i++) {
177
+ const body = bodies[i];
178
+ if (body === void 0 || body.length === 0) continue;
179
+ chunks.push({
180
+ id: `${parentDocId}#chunk-${i}`,
181
+ source,
182
+ content: body,
183
+ meta: {
184
+ path,
185
+ parentDocId,
186
+ chunkIndex: i,
187
+ language,
188
+ sha256: chunkSha256(body)
189
+ }
190
+ });
191
+ }
192
+ return chunks;
193
+ }
194
+
195
+ // src/config.ts
196
+ function apiKeyEnvVar(provider) {
197
+ switch (provider) {
198
+ case "openai":
199
+ return process.env.OPENAI_API_KEY;
200
+ case "voyage":
201
+ return process.env.VOYAGE_API_KEY;
202
+ case "cohere":
203
+ return process.env.COHERE_API_KEY;
204
+ default:
205
+ return void 0;
206
+ }
207
+ }
208
+ function resolveEmbedderConfig(ctx) {
209
+ const e = ctx?.embedder;
210
+ switch (e?.kind) {
211
+ case "local": {
212
+ const model = e?.model;
213
+ return { kind: "local", ...model ? { model } : {} };
214
+ }
215
+ case "remote": {
216
+ const provider = e?.provider ?? "openai";
217
+ const model = e?.model ?? "";
218
+ const dim = e?.dim ?? 384;
219
+ const apiKey = apiKeyEnvVar(provider);
220
+ return { kind: "remote", provider, model, dim, ...apiKey ? { apiKey } : {} };
221
+ }
222
+ case "ollama": {
223
+ const baseURL = e?.baseURL ?? process.env.OLLAMA_BASE_URL ?? "";
224
+ const model = e?.model ?? "";
225
+ return { kind: "ollama", baseURL, model };
226
+ }
227
+ case "none":
228
+ return { kind: "none" };
229
+ default:
230
+ return { kind: "local" };
231
+ }
232
+ }
233
+
234
+ // src/embedders/local.ts
235
+ import { mkdirSync } from "fs";
236
+ import { modelsDir } from "@noir-ai/core";
237
+
238
+ // src/embedders/normalize.ts
239
+ var EMBED_DIM = 384;
240
+ function l2normalize(vec) {
241
+ const n = vec.length;
242
+ if (n === 0) return new Float32Array(0);
243
+ let sum = 0;
244
+ for (let i = 0; i < n; i++) {
245
+ const v = vec[i];
246
+ if (v !== void 0) sum += v * v;
247
+ }
248
+ const norm = Math.sqrt(sum);
249
+ if (norm === 0 || !Number.isFinite(norm)) {
250
+ return new Float32Array(n);
251
+ }
252
+ const out = new Float32Array(n);
253
+ const inv = 1 / norm;
254
+ for (let i = 0; i < n; i++) {
255
+ const v = vec[i];
256
+ if (v !== void 0) out[i] = v * inv;
257
+ }
258
+ return out;
259
+ }
260
+
261
+ // src/embedders/local.ts
262
+ var DEFAULT_LOCAL_MODEL = "Xenova/all-MiniLM-L6-v2";
263
+ var MODELS_DIR = modelsDir();
264
+ var pipelineCache = /* @__PURE__ */ new Map();
265
+ async function loadPipeline(model) {
266
+ try {
267
+ const mod = await import("@huggingface/transformers");
268
+ mkdirSync(MODELS_DIR, { recursive: true });
269
+ mod.env.cacheDir = MODELS_DIR;
270
+ const extractor = await mod.pipeline("feature-extraction", model);
271
+ return async (text) => {
272
+ const out = await extractor(text, { pooling: "mean" });
273
+ const dim = out.dims.at(-1) ?? EMBED_DIM;
274
+ const raw = new Float32Array(dim);
275
+ for (let i = 0; i < dim; i++) raw[i] = out.data[i] ?? 0;
276
+ return l2normalize(raw);
277
+ };
278
+ } catch (e) {
279
+ const reason = e instanceof Error ? e.message : String(e);
280
+ throw new Error(
281
+ `local embedder: failed to load model "${model}" (is @huggingface/transformers installed and the onnxruntime native binary present?): ${reason}`
282
+ );
283
+ }
284
+ }
285
+ function getEmbedder(model) {
286
+ const cached = pipelineCache.get(model);
287
+ if (cached) return cached;
288
+ const promise = loadPipeline(model).catch((e) => {
289
+ pipelineCache.delete(model);
290
+ throw e;
291
+ });
292
+ pipelineCache.set(model, promise);
293
+ return promise;
294
+ }
295
+ function localEmbedder(opts = {}) {
296
+ const model = opts.model ?? DEFAULT_LOCAL_MODEL;
297
+ return {
298
+ model,
299
+ embed: async (text) => {
300
+ const run = await getEmbedder(model);
301
+ return run(text);
302
+ }
303
+ };
304
+ }
305
+
306
+ // src/embedders/ollama.ts
307
+ async function readErrorBody(res) {
308
+ try {
309
+ const text = await res.text();
310
+ return text.slice(0, 500);
311
+ } catch {
312
+ return "<unreadable response body>";
313
+ }
314
+ }
315
+ function ollamaEmbedder(opts) {
316
+ const targetDim = opts.dim ?? EMBED_DIM;
317
+ const base = opts.baseURL.replace(/\/+$/, "");
318
+ return async (text) => {
319
+ if (!opts.baseURL) {
320
+ throw new Error(
321
+ "ollama embedder is not configured: baseURL is required (set context.embedder.baseURL, e.g. http://localhost:11434)"
322
+ );
323
+ }
324
+ const res = await fetch(`${base}/api/embeddings`, {
325
+ method: "POST",
326
+ headers: { "content-type": "application/json" },
327
+ body: JSON.stringify({ model: opts.model, prompt: text })
328
+ });
329
+ if (!res.ok) {
330
+ const detail = await readErrorBody(res);
331
+ throw new Error(
332
+ `ollama embedder request failed (${res.status} ${res.statusText}): ${detail}`
333
+ );
334
+ }
335
+ const json = await res.json();
336
+ const vec = json.embedding ?? [];
337
+ if (vec.length < targetDim) {
338
+ throw new Error(
339
+ `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)`
340
+ );
341
+ }
342
+ const truncated = vec.length > targetDim ? Float32Array.from(vec.slice(0, targetDim)) : Float32Array.from(vec);
343
+ return l2normalize(truncated);
344
+ };
345
+ }
346
+
347
+ // src/embedders/remote.ts
348
+ var OPENAI_DEFAULT = "https://api.openai.com/v1/embeddings";
349
+ var ENDPOINTS = {
350
+ openai: OPENAI_DEFAULT,
351
+ voyage: "https://api.voyageai.com/v1/embeddings",
352
+ cohere: "https://api.cohere.com/v2/embed"
353
+ };
354
+ function buildRequestBody(provider, model, text) {
355
+ switch (provider) {
356
+ case "voyage":
357
+ return JSON.stringify({ model, inputs: [text] });
358
+ case "cohere":
359
+ return JSON.stringify({
360
+ model,
361
+ texts: [text],
362
+ input_type: "search_document",
363
+ embedding_types: ["float"]
364
+ });
365
+ case "openai":
366
+ default:
367
+ return JSON.stringify({ model, input: text });
368
+ }
369
+ }
370
+ function extractVector(json, provider) {
371
+ if (provider === "cohere") {
372
+ const vec = json.embeddings?.float?.[0];
373
+ return vec ?? [];
374
+ }
375
+ return json.data?.[0]?.embedding ?? [];
376
+ }
377
+ async function readErrorBody2(res) {
378
+ try {
379
+ const text = await res.text();
380
+ return text.slice(0, 500);
381
+ } catch {
382
+ return "<unreadable response body>";
383
+ }
384
+ }
385
+ function remoteEmbedder(opts) {
386
+ const targetDim = opts.dim ?? EMBED_DIM;
387
+ const endpoint = ENDPOINTS[opts.provider] ?? OPENAI_DEFAULT;
388
+ return async (text) => {
389
+ if (!opts.apiKey) {
390
+ throw new Error(
391
+ `remote embedder "${opts.provider}" is not configured: apiKey is required (set context.embedder.apiKey or the matching provider env var)`
392
+ );
393
+ }
394
+ const res = await fetch(endpoint, {
395
+ method: "POST",
396
+ headers: {
397
+ "content-type": "application/json",
398
+ authorization: `Bearer ${opts.apiKey}`
399
+ },
400
+ body: buildRequestBody(opts.provider, opts.model, text)
401
+ });
402
+ if (!res.ok) {
403
+ const detail = await readErrorBody2(res);
404
+ throw new Error(
405
+ `remote embedder "${opts.provider}" request failed (${res.status} ${res.statusText}): ${detail}`
406
+ );
407
+ }
408
+ const json = await res.json();
409
+ const vec = extractVector(json, opts.provider);
410
+ if (vec.length < targetDim) {
411
+ throw new Error(
412
+ `remote embedder "${opts.provider}" returned a ${vec.length}-dim vector (shorter than the required ${targetDim}); choose a >= ${targetDim}-dim model`
413
+ );
414
+ }
415
+ const truncated = vec.length > targetDim ? Float32Array.from(vec.slice(0, targetDim)) : Float32Array.from(vec);
416
+ return l2normalize(truncated);
417
+ };
418
+ }
419
+
420
+ // src/embedders/fake.ts
421
+ import { createHash as createHash2 } from "crypto";
422
+ function fakeEmbedFn(dim = EMBED_DIM) {
423
+ return (text) => {
424
+ const hash = createHash2("sha256").update(text, "utf8").digest();
425
+ const raw = new Float32Array(dim);
426
+ for (let i = 0; i < dim; i++) {
427
+ const byte = hash[i % hash.length];
428
+ if (byte !== void 0) raw[i] = (byte - 128) / 128;
429
+ }
430
+ return Promise.resolve(l2normalize(raw));
431
+ };
432
+ }
433
+
434
+ // src/embedders/index.ts
435
+ function createEmbedFn(cfg) {
436
+ switch (cfg.kind) {
437
+ case "local": {
438
+ const { embed, model } = localEmbedder({ model: cfg.model });
439
+ return { embed, info: { kind: "local", model, dim: EMBED_DIM } };
440
+ }
441
+ case "remote": {
442
+ const embed = remoteEmbedder({
443
+ provider: cfg.provider,
444
+ apiKey: cfg.apiKey,
445
+ model: cfg.model,
446
+ dim: cfg.dim
447
+ });
448
+ return { embed, info: { kind: "remote", model: cfg.model, dim: cfg.dim } };
449
+ }
450
+ case "ollama": {
451
+ const embed = ollamaEmbedder({ baseURL: cfg.baseURL, model: cfg.model });
452
+ return { embed, info: { kind: "ollama", model: cfg.model, dim: EMBED_DIM } };
453
+ }
454
+ case "none": {
455
+ const embed = async () => {
456
+ throw new Error('embedder disabled (kind:"none"); search degrades to BM25-only');
457
+ };
458
+ return { embed, info: { kind: "none", dim: 0 } };
459
+ }
460
+ }
461
+ }
462
+
463
+ // src/indexer.ts
464
+ import { readdir, readFile, stat } from "fs/promises";
465
+ import { join, relative, resolve, sep } from "path";
466
+ var CTX_REGISTRY_KEY = "ctx:registry";
467
+ var CTX_EMBEDDER_KEY = "ctx:embedder";
468
+ var CTX_FILE_PREFIX = "ctx:file:";
469
+ function ctxFileKey(pathKey) {
470
+ return `${CTX_FILE_PREFIX}${pathKey}`;
471
+ }
472
+ var SKIP_DIRS = /* @__PURE__ */ new Set([
473
+ ".git",
474
+ ".hg",
475
+ ".svn",
476
+ ".noir",
477
+ "node_modules",
478
+ "bower_components",
479
+ "dist",
480
+ "build",
481
+ "out",
482
+ ".next",
483
+ ".nuxt",
484
+ ".turbo",
485
+ ".cache",
486
+ "coverage",
487
+ ".nyc_output",
488
+ ".venv",
489
+ "venv",
490
+ "env",
491
+ "__pycache__",
492
+ ".mypy_cache",
493
+ ".pytest_cache",
494
+ ".idea",
495
+ ".vscode"
496
+ ]);
497
+ var BINARY_EXTS = /* @__PURE__ */ new Set([
498
+ // Images
499
+ "png",
500
+ "jpg",
501
+ "jpeg",
502
+ "gif",
503
+ "webp",
504
+ "ico",
505
+ "bmp",
506
+ "tif",
507
+ "tiff",
508
+ "svgz",
509
+ "heic",
510
+ "avif",
511
+ // Audio / video
512
+ "mp3",
513
+ "mp4",
514
+ "mov",
515
+ "avi",
516
+ "mkv",
517
+ "flac",
518
+ "wav",
519
+ "ogg",
520
+ "webm",
521
+ "aac",
522
+ "m4a",
523
+ // Archives
524
+ "zip",
525
+ "gz",
526
+ "tar",
527
+ "tgz",
528
+ "br",
529
+ "lz",
530
+ "lzma",
531
+ "7z",
532
+ "rar",
533
+ "bz2",
534
+ // Documents (binary)
535
+ "pdf",
536
+ "doc",
537
+ "docx",
538
+ "xls",
539
+ "xlsx",
540
+ "ppt",
541
+ "pptx",
542
+ // Compiled / native
543
+ "wasm",
544
+ "exe",
545
+ "dll",
546
+ "so",
547
+ "dylib",
548
+ "a",
549
+ "o",
550
+ "obj",
551
+ "lib",
552
+ "class",
553
+ "jar",
554
+ "war",
555
+ "pyc",
556
+ "pyo",
557
+ "pyd",
558
+ // ML / model artifacts
559
+ "onnx",
560
+ "pickle",
561
+ "pt",
562
+ "bin",
563
+ // Databases / locks
564
+ "db",
565
+ "sqlite",
566
+ "sqlite3",
567
+ "db-shm",
568
+ "db-wal",
569
+ "lock",
570
+ // Fonts
571
+ "ttf",
572
+ "otf",
573
+ "woff",
574
+ "woff2",
575
+ "eot"
576
+ ]);
577
+ function isBinaryExt(pathOrName) {
578
+ const m = pathOrName.toLowerCase().match(/\.([a-z0-9]+)$/);
579
+ if (!m || !m[1]) return false;
580
+ return BINARY_EXTS.has(m[1]);
581
+ }
582
+ var SENSITIVE_NAMES = /* @__PURE__ */ new Set([
583
+ ".env",
584
+ ".npmrc",
585
+ ".pypirc",
586
+ ".git-credentials",
587
+ ".netrc",
588
+ ".ds_store",
589
+ "thumbs.db"
590
+ ]);
591
+ var SENSITIVE_PREFIXES = [".env.", "id_rsa", "id_ed25519"];
592
+ var SENSITIVE_SUFFIXES = [".pem", ".key", ".secret", ".p12", ".pfx", ".local"];
593
+ var SENSITIVE_PATHS = [".aws/credentials"];
594
+ function isSensitive(name) {
595
+ const lower = name.toLowerCase();
596
+ for (const p of SENSITIVE_PATHS) {
597
+ if (lower === p || lower.endsWith(`/${p}`)) return true;
598
+ }
599
+ const base = lower.slice(lower.lastIndexOf("/") + 1);
600
+ if (SENSITIVE_NAMES.has(base)) return true;
601
+ for (const pf of SENSITIVE_PREFIXES) {
602
+ if (base.startsWith(pf)) return true;
603
+ }
604
+ for (const sf of SENSITIVE_SUFFIXES) {
605
+ if (base.endsWith(sf)) return true;
606
+ }
607
+ return false;
608
+ }
609
+ function posix(p) {
610
+ return sep === "/" ? p : p.split(sep).join("/");
611
+ }
612
+ function createIndexer(opts) {
613
+ const { store, embed, info } = opts;
614
+ const base = opts.root ?? process.cwd();
615
+ let chain = Promise.resolve();
616
+ function serialized(work) {
617
+ const result = chain.then(work);
618
+ chain = result.then(
619
+ () => void 0,
620
+ () => void 0
621
+ );
622
+ return result;
623
+ }
624
+ const resolveAbs = (p) => resolve(base, p);
625
+ const toKey = (abs) => opts.root ? relative(opts.root, abs) : abs;
626
+ const keyAbs = (key) => posix(resolve(base, key));
627
+ function isWithinRoot(abs) {
628
+ const r = resolve(base);
629
+ const a = resolve(abs);
630
+ return a === r || a.startsWith(`${r}${sep}`);
631
+ }
632
+ function deleteChunks(ids) {
633
+ for (const id of ids) {
634
+ store.deleteDoc(id);
635
+ store.deleteVec(id);
636
+ }
637
+ }
638
+ function loadRecords() {
639
+ const registry = store.getState(CTX_REGISTRY_KEY) ?? [];
640
+ const records = /* @__PURE__ */ new Map();
641
+ for (const p of registry) {
642
+ const rec = store.getState(ctxFileKey(p));
643
+ if (rec) records.set(p, rec);
644
+ }
645
+ return records;
646
+ }
647
+ function persist(records, tombstones) {
648
+ store.setState(CTX_REGISTRY_KEY, [...records.keys()].sort());
649
+ for (const [key, rec] of records) store.setState(ctxFileKey(key), rec);
650
+ for (const key of tombstones) store.setState(ctxFileKey(key), null);
651
+ }
652
+ function recordEmbedder() {
653
+ const prev = store.getState(CTX_EMBEDDER_KEY);
654
+ if (prev === null) {
655
+ store.setState(CTX_EMBEDDER_KEY, info);
656
+ return;
657
+ }
658
+ if (!sameEmbedder(prev, info)) {
659
+ console.warn(
660
+ `[noir-context] embedder changed (${describeEmbedder(prev)} \u2192 ${describeEmbedder(info)}); existing vectors may be stale \u2014 call reindex() to refresh`
661
+ );
662
+ store.setState(CTX_EMBEDDER_KEY, info);
663
+ }
664
+ }
665
+ async function walk(rootAbs) {
666
+ const out = [];
667
+ const stack = [rootAbs];
668
+ while (stack.length > 0) {
669
+ const dir = stack.pop();
670
+ if (dir === void 0) break;
671
+ let entries;
672
+ try {
673
+ entries = await readdir(dir, { withFileTypes: true });
674
+ } catch {
675
+ continue;
676
+ }
677
+ for (const ent of entries) {
678
+ if (ent.isDirectory()) {
679
+ if (SKIP_DIRS.has(ent.name)) continue;
680
+ stack.push(join(dir, ent.name));
681
+ } else if (ent.isFile()) {
682
+ out.push(join(dir, ent.name));
683
+ }
684
+ }
685
+ }
686
+ return out;
687
+ }
688
+ function inScope(absKey, absRoots) {
689
+ for (const r of absRoots) {
690
+ const rp = posix(r);
691
+ if (absKey === rp || absKey.startsWith(`${rp}/`)) return true;
692
+ }
693
+ return false;
694
+ }
695
+ async function indexPaths(inputPaths, o) {
696
+ recordEmbedder();
697
+ const records = loadRecords();
698
+ const absRoots = [];
699
+ const scanned = /* @__PURE__ */ new Map();
700
+ for (const input of inputPaths) {
701
+ const abs = resolveAbs(input);
702
+ if (!isWithinRoot(abs)) continue;
703
+ const st = await stat(abs).catch(() => null);
704
+ if (st === null) continue;
705
+ if (st.isDirectory()) {
706
+ absRoots.push(abs);
707
+ for (const file of await walk(abs)) {
708
+ if (!isWithinRoot(file)) continue;
709
+ scanned.set(posix(toKey(file)), file);
710
+ }
711
+ } else if (st.isFile()) {
712
+ absRoots.push(abs);
713
+ scanned.set(posix(toKey(abs)), abs);
714
+ }
715
+ }
716
+ let indexed = 0;
717
+ let skipped = 0;
718
+ let failed = 0;
719
+ let deleted = 0;
720
+ let embedDisabled = info.kind === "none";
721
+ const tombstones = [];
722
+ const tryEmbed = async (content) => {
723
+ if (embedDisabled) return null;
724
+ try {
725
+ return await embed(content);
726
+ } catch {
727
+ embedDisabled = true;
728
+ return null;
729
+ }
730
+ };
731
+ for (const key of [...records.keys()]) {
732
+ if (scanned.has(key)) continue;
733
+ if (!inScope(keyAbs(key), absRoots)) continue;
734
+ const rec = records.get(key);
735
+ if (rec) deleteChunks(rec.chunkIds);
736
+ records.delete(key);
737
+ tombstones.push(key);
738
+ deleted += 1;
739
+ }
740
+ for (const [key, abs] of scanned) {
741
+ if (isBinaryExt(key)) {
742
+ failed += 1;
743
+ continue;
744
+ }
745
+ if (isSensitive(key)) {
746
+ failed += 1;
747
+ continue;
748
+ }
749
+ let content;
750
+ try {
751
+ content = await readFile(abs, "utf8");
752
+ } catch {
753
+ failed += 1;
754
+ continue;
755
+ }
756
+ if (content.includes(String.fromCharCode(0))) {
757
+ failed += 1;
758
+ continue;
759
+ }
760
+ const fileHash = sha256Hex(content);
761
+ const prev = records.get(key);
762
+ if (prev !== void 0 && prev.sha256 === fileHash) {
763
+ skipped += prev.chunkIds.length;
764
+ continue;
765
+ }
766
+ if (prev !== void 0) deleteChunks(prev.chunkIds);
767
+ const chunks = chunkFile({
768
+ path: key,
769
+ content,
770
+ source: o?.source,
771
+ maxTokens: o?.maxTokens,
772
+ overlap: o?.overlap
773
+ });
774
+ const chunkIds = [];
775
+ let language = inferLanguage(key);
776
+ for (const chunk of chunks) {
777
+ const indexedContent = withIdentifierExplosion(chunk.content);
778
+ store.indexDoc({
779
+ id: chunk.id,
780
+ source: chunk.source,
781
+ content: indexedContent,
782
+ meta: chunk.meta
783
+ });
784
+ language = chunk.meta.language;
785
+ const vec = await tryEmbed(indexedContent);
786
+ if (vec !== null) {
787
+ store.upsertVec(chunk.id, vec, { source: chunk.source });
788
+ }
789
+ chunkIds.push(chunk.id);
790
+ indexed += 1;
791
+ }
792
+ records.set(key, { sha256: fileHash, chunkIds, language });
793
+ }
794
+ persist(records, tombstones);
795
+ let totalChunks = 0;
796
+ for (const rec of records.values()) totalChunks += rec.chunkIds.length;
797
+ return {
798
+ indexed,
799
+ skipped,
800
+ deleted,
801
+ failed,
802
+ totalChunks,
803
+ // `degraded` is truthful about "docs indexed without vectors": only set
804
+ // when embedding was off AND at least one doc went in without one.
805
+ degraded: embedDisabled && indexed > 0
806
+ };
807
+ }
808
+ async function forget(inputPaths) {
809
+ const records = loadRecords();
810
+ const targets = inputPaths.map((p) => posix(resolveAbs(p)));
811
+ const tombstones = [];
812
+ for (const key of [...records.keys()]) {
813
+ const abs = keyAbs(key);
814
+ const hit = targets.some((t) => abs === t || abs.startsWith(`${t}/`));
815
+ if (!hit) continue;
816
+ const rec = records.get(key);
817
+ if (rec) deleteChunks(rec.chunkIds);
818
+ records.delete(key);
819
+ tombstones.push(key);
820
+ }
821
+ persist(records, tombstones);
822
+ let totalChunks = 0;
823
+ for (const rec of records.values()) totalChunks += rec.chunkIds.length;
824
+ return { deleted: tombstones.length, totalChunks };
825
+ }
826
+ async function reindex() {
827
+ const registry = store.getState(CTX_REGISTRY_KEY) ?? [];
828
+ const tombstones = [];
829
+ for (const key of registry) {
830
+ const rec = store.getState(ctxFileKey(key));
831
+ if (rec) deleteChunks(rec.chunkIds);
832
+ tombstones.push(key);
833
+ }
834
+ persist(/* @__PURE__ */ new Map(), tombstones);
835
+ return indexPaths(registry);
836
+ }
837
+ return {
838
+ indexPaths: (inputPaths, o) => serialized(() => indexPaths(inputPaths, o)),
839
+ forget: (inputPaths) => serialized(() => forget(inputPaths)),
840
+ reindex: () => serialized(reindex)
841
+ };
842
+ }
843
+ function sameEmbedder(a, b) {
844
+ return a.kind === b.kind && a.model === b.model && a.dim === b.dim;
845
+ }
846
+ function describeEmbedder(e) {
847
+ return e.model ? `${e.kind}:${e.model}(${e.dim})` : `${e.kind}(${e.dim})`;
848
+ }
849
+
850
+ // src/rrf.ts
851
+ var DEFAULT_RRF_K = 60;
852
+ var DEFAULT_RRF_WEIGHTS = [0.5, 0.5];
853
+ function rankMap(list) {
854
+ const map = /* @__PURE__ */ new Map();
855
+ for (const [i, hit] of list.entries()) {
856
+ if (map.has(hit.id)) continue;
857
+ map.set(hit.id, { rank: i + 1, source: hit.source });
858
+ }
859
+ return map;
860
+ }
861
+ function fuseRrf(bm25, knn, opts) {
862
+ const k = opts?.k ?? DEFAULT_RRF_K;
863
+ const [wBm25, wKnn] = opts?.weights ?? DEFAULT_RRF_WEIGHTS;
864
+ const bm25Ranks = rankMap(bm25);
865
+ const knnRanks = rankMap(knn);
866
+ const seen = /* @__PURE__ */ new Set();
867
+ const orderedIds = [];
868
+ for (const hit of bm25) {
869
+ if (!seen.has(hit.id)) {
870
+ seen.add(hit.id);
871
+ orderedIds.push(hit.id);
872
+ }
873
+ }
874
+ for (const hit of knn) {
875
+ if (!seen.has(hit.id)) {
876
+ seen.add(hit.id);
877
+ orderedIds.push(hit.id);
878
+ }
879
+ }
880
+ const entries = orderedIds.map((id, order) => {
881
+ const bm = bm25Ranks.get(id);
882
+ const kn = knnRanks.get(id);
883
+ const rankBm25 = bm?.rank;
884
+ const rankKnn = kn?.rank;
885
+ const score = (rankBm25 != null ? wBm25 / (k + rankBm25) : 0) + (rankKnn != null ? wKnn / (k + rankKnn) : 0);
886
+ const minRank = Math.min(
887
+ rankBm25 ?? Number.POSITIVE_INFINITY,
888
+ rankKnn ?? Number.POSITIVE_INFINITY
889
+ );
890
+ const source = bm?.source ?? kn?.source ?? "";
891
+ return { id, source, score, minRank, order };
892
+ });
893
+ entries.sort((a, b) => {
894
+ if (a.score !== b.score) return b.score - a.score;
895
+ if (a.minRank !== b.minRank) return a.minRank - b.minRank;
896
+ return a.order - b.order;
897
+ });
898
+ return entries.map(({ id, source, score }) => ({ id, source, score }));
899
+ }
900
+
901
+ // src/retriever.ts
902
+ var DEFAULT_SEARCH_LIMIT = 10;
903
+ var DEFAULT_BUDGET_TOKENS = 4096;
904
+ var DEFAULT_SNIPPET_WINDOW_TOKENS = 16;
905
+ function asChunkMeta(meta) {
906
+ if (typeof meta !== "object" || meta === null) return void 0;
907
+ const m = meta;
908
+ const path = m["path"];
909
+ const parentDocId = m["parentDocId"];
910
+ if (typeof path !== "string" || typeof parentDocId !== "string") {
911
+ return void 0;
912
+ }
913
+ const chunkIndex = m["chunkIndex"];
914
+ const language = m["language"];
915
+ const sha256Val = m["sha256"];
916
+ return {
917
+ path,
918
+ parentDocId,
919
+ chunkIndex: typeof chunkIndex === "number" ? chunkIndex : 0,
920
+ language: typeof language === "string" ? language : "text",
921
+ // ChunkMeta.sha256 is required (the indexer always writes it); for a
922
+ // foreign/legacy row that lacks it, fall back to '' rather than crash.
923
+ sha256: typeof sha256Val === "string" ? sha256Val : ""
924
+ };
925
+ }
926
+ function toRetrieverMeta(meta) {
927
+ if (!meta) return {};
928
+ const out = {};
929
+ if (meta.language) out.language = meta.language;
930
+ if (meta.sha256) out.sha256 = meta.sha256;
931
+ out.chunkIndex = meta.chunkIndex;
932
+ return out;
933
+ }
934
+ function asSourceKind(source) {
935
+ switch (source) {
936
+ case "codebase":
937
+ case "docs":
938
+ case "spec":
939
+ case "memory":
940
+ return source;
941
+ default:
942
+ return "codebase";
943
+ }
944
+ }
945
+ function queryTermSet(query) {
946
+ const terms = query.toLowerCase().match(/[a-z0-9]+/g);
947
+ return terms ? new Set(terms) : /* @__PURE__ */ new Set();
948
+ }
949
+ function windowSnippet(content, query, windowTokens) {
950
+ const trimmed = content.trim();
951
+ if (trimmed.length === 0) return "";
952
+ const terms = queryTermSet(query);
953
+ const words = trimmed.split(/\s+/);
954
+ const slice = words.slice(0, Math.max(1, windowTokens));
955
+ return slice.map((w) => {
956
+ const core = w.toLowerCase().match(/[a-z0-9]+/)?.[0];
957
+ if (core && terms.has(core)) return `<<${w}>>`;
958
+ return w;
959
+ }).join(" ").concat(words.length > slice.length ? " \u2026" : "");
960
+ }
961
+ function collapseByParent(hits) {
962
+ const seen = /* @__PURE__ */ new Set();
963
+ const out = [];
964
+ for (const hit of hits) {
965
+ const key = hit.parentDocId || hit.id;
966
+ if (seen.has(key)) continue;
967
+ seen.add(key);
968
+ out.push(hit);
969
+ }
970
+ return out;
971
+ }
972
+ function packBudget(hits, budgetTokens) {
973
+ let consumed = 0;
974
+ const packed = [];
975
+ for (const hit of hits) {
976
+ const tokens = estimateTokens(hit.snippet);
977
+ if (packed.length > 0 && consumed + tokens > budgetTokens) {
978
+ return { hits: packed, consumedTokens: consumed, truncated: true };
979
+ }
980
+ packed.push(hit);
981
+ consumed += tokens;
982
+ }
983
+ return { hits: packed, consumedTokens: consumed, truncated: false };
984
+ }
985
+ function createRetriever(deps) {
986
+ const { store, embed } = deps;
987
+ const k = deps.opts?.k ?? DEFAULT_RRF_K;
988
+ const weights = deps.opts?.weights;
989
+ const defaultBudget = deps.opts?.budgetTokens ?? DEFAULT_BUDGET_TOKENS;
990
+ const snippetWindowTokens = deps.opts?.snippetWindowTokens ?? DEFAULT_SNIPPET_WINDOW_TOKENS;
991
+ const readDoc = deps.opts?.readDoc;
992
+ return {
993
+ async search(query, opts) {
994
+ const limit = opts?.limit ?? DEFAULT_SEARCH_LIMIT;
995
+ const budgetTokens = opts?.budgetTokens ?? defaultBudget;
996
+ const source = opts?.source;
997
+ let ftsHits = [];
998
+ let ftsFailed = false;
999
+ try {
1000
+ ftsHits = store.searchFt(query, { limit, source });
1001
+ } catch {
1002
+ ftsFailed = true;
1003
+ }
1004
+ let knnHits = [];
1005
+ let knnFailed = false;
1006
+ try {
1007
+ const qvec = await embed(query);
1008
+ try {
1009
+ knnHits = store.knn(qvec, { limit, source });
1010
+ } catch {
1011
+ knnFailed = true;
1012
+ }
1013
+ } catch {
1014
+ knnFailed = true;
1015
+ }
1016
+ const mode = knnFailed ? "bm25-only" : "hybrid";
1017
+ const degraded = knnFailed || ftsFailed;
1018
+ const fused = fuseRrf(ftsHits, knnHits, { k, weights });
1019
+ const ftsById = /* @__PURE__ */ new Map();
1020
+ for (const h of ftsHits) {
1021
+ if (!ftsById.has(h.id)) ftsById.set(h.id, h);
1022
+ }
1023
+ const enriched = fused.map((row) => {
1024
+ const fts = ftsById.get(row.id);
1025
+ if (fts) {
1026
+ const meta = asChunkMeta(fts.meta);
1027
+ return {
1028
+ id: row.id,
1029
+ source: asSourceKind(row.source),
1030
+ score: row.score,
1031
+ snippet: fts.snippet,
1032
+ path: meta?.path ?? "",
1033
+ parentDocId: meta?.parentDocId ?? "",
1034
+ meta: toRetrieverMeta(meta)
1035
+ };
1036
+ }
1037
+ if (readDoc) {
1038
+ const doc = readDoc(row.id);
1039
+ if (doc) {
1040
+ const meta = asChunkMeta(doc.meta);
1041
+ return {
1042
+ id: row.id,
1043
+ source: asSourceKind(row.source),
1044
+ score: row.score,
1045
+ snippet: windowSnippet(doc.content, query, snippetWindowTokens),
1046
+ path: meta?.path ?? "",
1047
+ parentDocId: meta?.parentDocId ?? "",
1048
+ meta: toRetrieverMeta(meta)
1049
+ };
1050
+ }
1051
+ }
1052
+ return {
1053
+ id: row.id,
1054
+ source: asSourceKind(row.source),
1055
+ score: row.score,
1056
+ snippet: "",
1057
+ path: "",
1058
+ parentDocId: "",
1059
+ meta: {}
1060
+ };
1061
+ });
1062
+ const collapsed = collapseByParent(enriched);
1063
+ const packed = packBudget(collapsed, budgetTokens);
1064
+ return {
1065
+ results: packed.hits,
1066
+ consumedTokens: packed.consumedTokens,
1067
+ truncated: packed.truncated,
1068
+ degraded,
1069
+ mode
1070
+ };
1071
+ }
1072
+ };
1073
+ }
1074
+
1075
+ // src/contextEngine.ts
1076
+ var ContextEngine = class {
1077
+ /** The daemon's single-writer store handle (possibly read-only). */
1078
+ store;
1079
+ /** Project root (paths resolve against this). */
1080
+ root;
1081
+ /** Canonical project identifier. */
1082
+ projectId;
1083
+ /** Description of the active embedder (surfaced by `status()`). */
1084
+ embedder;
1085
+ /**
1086
+ * Persistent degradation flag (read-only store OR `kind:'none'`). Per-query
1087
+ * degradation lives on {@link SearchResult}.
1088
+ */
1089
+ degraded;
1090
+ indexer;
1091
+ retriever;
1092
+ constructor(opts) {
1093
+ this.store = opts.store;
1094
+ this.root = opts.root;
1095
+ this.projectId = opts.projectId;
1096
+ const { embed, info } = createEmbedFn(opts.embedderCfg);
1097
+ this.embedder = info;
1098
+ this.degraded = opts.storeDegraded === true || info.kind === "none";
1099
+ this.indexer = createIndexer({ store: opts.store, embed, info, root: opts.root });
1100
+ this.retriever = createRetriever({ store: opts.store, embed });
1101
+ }
1102
+ /**
1103
+ * Incrementally index `paths` (files or directories) into the store. Delegates
1104
+ * to the indexer (spec F1/F3/F4). The engine — through the indexer — is the
1105
+ * ONLY context writer; the daemon stays the single writer via this handle.
1106
+ */
1107
+ indexPaths(paths, opts) {
1108
+ return this.indexer.indexPaths(paths, opts);
1109
+ }
1110
+ /**
1111
+ * Hybrid search: BM25 ∪ cosine-kNN fused by RRF (k=60), collapsed by
1112
+ * parent-doc, packed to a token budget with window-extracted snippets (spec
1113
+ * F6/F7). Delegates to the retriever. The per-call `degraded`/`mode` on the
1114
+ * returned {@link SearchResult} reflect THIS query's outcome (independent of
1115
+ * the engine's persistent {@link degraded}).
1116
+ */
1117
+ search(query, opts) {
1118
+ return this.retriever.search(query, opts);
1119
+ }
1120
+ /**
1121
+ * Snapshot the engine's state (spec F11; mirrors `buildStoreStatus`).
1122
+ *
1123
+ * `docCount`/`vecCount` are live reads off the single writer handle (no
1124
+ * cache); `indexedFiles` is the size of the `ctx:registry` KV list maintained
1125
+ * by the indexer; `embedder` describes the active provider; `degraded` is the
1126
+ * persistent flag (read-only store OR `kind:'none'`).
1127
+ */
1128
+ status() {
1129
+ const registry = this.store.getState(CTX_REGISTRY_KEY) ?? [];
1130
+ return {
1131
+ ok: true,
1132
+ projectId: this.store.projectId,
1133
+ docCount: this.store.countDocs(),
1134
+ vecCount: this.store.countVecs(),
1135
+ indexedFiles: registry.length,
1136
+ embedder: this.embedder,
1137
+ degraded: this.degraded
1138
+ };
1139
+ }
1140
+ };
1141
+
1142
+ // src/types.ts
1143
+ var SOURCES = ["codebase", "docs", "spec", "memory"];
1144
+ export {
1145
+ CTX_EMBEDDER_KEY,
1146
+ CTX_FILE_PREFIX,
1147
+ CTX_REGISTRY_KEY,
1148
+ ContextEngine,
1149
+ DEFAULT_BUDGET_TOKENS,
1150
+ DEFAULT_CHUNK_MAX_TOKENS,
1151
+ DEFAULT_CHUNK_OVERLAP,
1152
+ DEFAULT_LOCAL_MODEL,
1153
+ DEFAULT_RRF_K,
1154
+ DEFAULT_RRF_WEIGHTS,
1155
+ DEFAULT_SEARCH_LIMIT,
1156
+ DEFAULT_SNIPPET_WINDOW_TOKENS,
1157
+ EMBED_DIM,
1158
+ MODELS_DIR,
1159
+ SKIP_DIRS,
1160
+ SOURCES,
1161
+ TOKEN_ESTIMATE_FACTOR,
1162
+ chunkFile,
1163
+ createEmbedFn,
1164
+ createIndexer,
1165
+ createRetriever,
1166
+ ctxFileKey,
1167
+ estimateTokens,
1168
+ explodeIdentifiers,
1169
+ fakeEmbedFn,
1170
+ fuseRrf,
1171
+ inferLanguage,
1172
+ isBinaryExt,
1173
+ isSensitive,
1174
+ l2normalize,
1175
+ localEmbedder,
1176
+ ollamaEmbedder,
1177
+ remoteEmbedder,
1178
+ resolveEmbedderConfig,
1179
+ windowSnippet,
1180
+ withIdentifierExplosion
1181
+ };
1182
+ //# sourceMappingURL=index.js.map