polite-crawler 0.1.2 → 0.1.4

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.
Files changed (46) hide show
  1. package/README.md +555 -8
  2. package/build/cli-daemon-ZYRdy6EW.mjs +38 -0
  3. package/build/cli-daemon-ZYRdy6EW.mjs.map +1 -0
  4. package/build/cli-recipes-Bci6HREA.mjs +59 -0
  5. package/build/cli-recipes-Bci6HREA.mjs.map +1 -0
  6. package/build/cli.mjs +673 -26
  7. package/build/cli.mjs.map +1 -1
  8. package/build/daemon-DK6U51_v.mjs +515 -0
  9. package/build/daemon-DK6U51_v.mjs.map +1 -0
  10. package/build/{open-Ca1lcWR3.d.mts → daemon-wwIWsUmU.d.mts} +919 -17
  11. package/build/{diff-Dk2eSvi-.mjs → diff-BUM91Rev.mjs} +2 -2
  12. package/build/{diff-Dk2eSvi-.mjs.map → diff-BUM91Rev.mjs.map} +1 -1
  13. package/build/{extract-DcwNrTK7.mjs → extract-CefB5Vfn.mjs} +2 -2
  14. package/build/{extract-DcwNrTK7.mjs.map → extract-CefB5Vfn.mjs.map} +1 -1
  15. package/build/hours.d.mts +26 -0
  16. package/build/hours.mjs +48 -1
  17. package/build/hours.mjs.map +1 -1
  18. package/build/index.d.mts +491 -11
  19. package/build/index.mjs +14 -9
  20. package/build/loopback-C_QYowSf.mjs +22 -0
  21. package/build/loopback-C_QYowSf.mjs.map +1 -0
  22. package/build/mcp.d.mts +158 -2
  23. package/build/mcp.mjs +567 -18
  24. package/build/mcp.mjs.map +1 -1
  25. package/build/ollama-BJxkjny4.mjs +160 -0
  26. package/build/ollama-BJxkjny4.mjs.map +1 -0
  27. package/build/{open-DnrWgkC0.mjs → open-CbpSCEZc.mjs} +1127 -16
  28. package/build/open-CbpSCEZc.mjs.map +1 -0
  29. package/build/recipe-GiFQojmo.mjs +279 -0
  30. package/build/recipe-GiFQojmo.mjs.map +1 -0
  31. package/build/recipe-product-Dellqudu.mjs +892 -0
  32. package/build/recipe-product-Dellqudu.mjs.map +1 -0
  33. package/build/{runtime-k8mo1_7U.mjs → runtime-D_r2SYge.mjs} +3 -3
  34. package/build/{runtime-k8mo1_7U.mjs.map → runtime-D_r2SYge.mjs.map} +1 -1
  35. package/build/transformers-BQMlaGj4.mjs +133 -0
  36. package/build/transformers-BQMlaGj4.mjs.map +1 -0
  37. package/build/{duration-iIuMMofi.mjs → version-BPem-XZd.mjs} +10 -10
  38. package/build/version-BPem-XZd.mjs.map +1 -0
  39. package/build/{wacz-sfOLT8e9.mjs → wacz-QyzLp4mC.mjs} +3 -3
  40. package/build/{wacz-sfOLT8e9.mjs.map → wacz-QyzLp4mC.mjs.map} +1 -1
  41. package/build/{warc-VIPscWxY.mjs → warc-BHcaRKHw.mjs} +2 -2
  42. package/build/{warc-VIPscWxY.mjs.map → warc-BHcaRKHw.mjs.map} +1 -1
  43. package/build/webbotauth-CjJdTAfE.mjs.map +1 -1
  44. package/package.json +10 -2
  45. package/build/duration-iIuMMofi.mjs.map +0 -1
  46. package/build/open-DnrWgkC0.mjs.map +0 -1
@@ -2,14 +2,15 @@ import { t as __exportAll } from "./rolldown-runtime-D7D4PA-g.mjs";
2
2
  import { n as FileBlobStore, t as CaptureLog } from "./log-Dx-wXjJl.mjs";
3
3
  import { r as playwrightRenderer } from "./render-C1CUraWF.mjs";
4
4
  import { r as checkedRenderer } from "./browser-CKRNbIDT.mjs";
5
- import { A as Crawler, D as parseQuery, F as sha256, I as stableStringify, O as snippetOf, a as fetchPage, c as isHeldBack, f as readDocument, g as verifyQuote, i as fetchAndIndex, j as DEADLINE_NOTE, m as reindex, o as findDocuments, r as catchUpIndex, t as Extractors, w as fold } from "./runtime-k8mo1_7U.mjs";
5
+ import { i as durationMs, n as DEFAULT_TOKEN, r as VERSION, t as BOT_PAGE } from "./version-BPem-XZd.mjs";
6
+ import { A as snippetOf, E as fold, L as sha256$1, M as Crawler, N as DEADLINE_NOTE, R as stableStringify, S as archivedOf, a as fetchPage, c as isHeldBack, f as readDocument, g as verifyQuote, i as fetchAndIndex, k as parseQuery, m as reindex, o as findDocuments, r as catchUpIndex, t as Extractors } from "./runtime-D_r2SYge.mjs";
6
7
  import { MemoryHostStateStore, nextValidatorMemo, realClock } from "./politeness.mjs";
7
8
  import { matchesPattern } from "./robots.mjs";
8
- import { i as VERSION, n as BOT_PAGE, r as DEFAULT_TOKEN, t as durationMs } from "./duration-iIuMMofi.mjs";
9
9
  import { n as canonicalUrl, r as scopeTest, t as cacheKeyOf } from "./urls-BHFeSyEK.mjs";
10
10
  import { o as decodeBody, t as extractPage } from "./html-DkQUAE_d.mjs";
11
- import { n as diffDocument, r as diffTexts } from "./diff-Dk2eSvi-.mjs";
12
- import { r as extractDocument } from "./extract-DcwNrTK7.mjs";
11
+ import { n as diffDocument, r as diffTexts } from "./diff-BUM91Rev.mjs";
12
+ import { r as extractDocument } from "./extract-CefB5Vfn.mjs";
13
+ import { o as registerRecipe } from "./recipe-GiFQojmo.mjs";
13
14
  import { createHash, randomUUID } from "node:crypto";
14
15
  import { dirname, join } from "node:path";
15
16
  import { existsSync, lstatSync, mkdirSync, realpathSync, statSync } from "node:fs";
@@ -19,10 +20,13 @@ import { DatabaseSync } from "node:sqlite";
19
20
  const MAX_INDEX_CHARS = 2e5;
20
21
  /** In the mode `fast`, a part is common when more than this share of the documents hold it. */
21
22
  const FAST_RANK_SHARE = .2;
23
+ /** The text of the error of hybrid search with no embedder. */
24
+ const NO_EMBEDDER = "hybrid search needs an embedder: open the crawler with the option embedder (transformersEmbedder or ollamaEmbedder), or give --embedder transformers or ollama to the command line";
22
25
  /** This function gives the rank mode of an option. A value that is not a rank mode throws a `RangeError`. */
23
26
  const rankModeOf = (value) => {
24
27
  if (value === void 0 || value === "exact") return "exact";
25
28
  if (value === "fast") return "fast";
29
+ if (value === "hybrid") throw new RangeError("the index ranks with \"exact\" and \"fast\" only: rank \"hybrid\" needs the vectors of an embedder, so use find or rankWithin of a crawler with the option embedder");
26
30
  throw new RangeError(`rank must be "exact" or "fast", not ${JSON.stringify(value) ?? "undefined"}`);
27
31
  };
28
32
  const namesOf = (generation) => ({
@@ -539,6 +543,20 @@ var DocIndex = class {
539
543
  hasVersion(capture) {
540
544
  return this.live((n) => this.stmt(`SELECT 1 AS found FROM ${n.versions} WHERE capture = ? LIMIT 1`).get(capture) !== void 0);
541
545
  }
546
+ /**
547
+ * This method gives the hash of the current text of documents, with no text. It gives the
548
+ * documents of `docs`, or each document after `after` in the sequence of the ids, to `limit`.
549
+ */
550
+ digests(options = {}) {
551
+ return this.live((n) => {
552
+ if (options.docs !== void 0) {
553
+ if (options.docs.length === 0) return [];
554
+ return this.stmt(`SELECT doc, kind, ai_input, text_sha FROM ${n.docs} WHERE doc IN (SELECT value FROM json_each(?))`).all(JSON.stringify(options.docs));
555
+ }
556
+ const limit = Math.max(1, Math.floor(options.limit ?? 500));
557
+ return this.stmt(`SELECT doc, kind, ai_input, text_sha FROM ${n.docs} WHERE doc > ? ORDER BY doc LIMIT ?`).all(options.after ?? "", limit);
558
+ });
559
+ }
542
560
  /** This method gives the newest fetch time of the cache entries, or undefined for an empty index. */
543
561
  newestFetch() {
544
562
  return this.live((n) => this.stmt(`SELECT max(fetched_at) AS newest FROM ${n.latest}`).get()?.newest ?? void 0);
@@ -2332,6 +2350,1053 @@ var SqliteHostStateStore = class {
2332
2350
  }
2333
2351
  };
2334
2352
  //#endregion
2353
+ //#region src/embed.ts
2354
+ /** These are the limits of the chunks of a text. */
2355
+ const CHUNK_LIMITS = {
2356
+ maxTokens: 512,
2357
+ defaultTokens: 256,
2358
+ fill: .9,
2359
+ overlap: .1,
2360
+ maxChunks: 64
2361
+ };
2362
+ /** These scripts write no space between words, so the estimate counts each character as a token. */
2363
+ const SEGMENTED = /[\p{Script=Han}\p{Script=Hiragana}\p{Script=Katakana}\p{Script=Thai}\p{Script=Lao}\p{Script=Khmer}\p{Script=Myanmar}]/u;
2364
+ const WORD = /[\p{L}\p{N}\p{M}]/u;
2365
+ const SPACE = /\s/u;
2366
+ /** A word of this number of letters or fewer is one token in the estimate. A longer word is more tokens. */
2367
+ const LETTERS_PER_TOKEN = 6;
2368
+ const isAsciiWord = (code) => code >= 48 && code <= 57 || code >= 65 && code <= 90 || code >= 97 && code <= 122;
2369
+ const isAsciiSpace = (code) => code === 32 || code >= 9 && code <= 13;
2370
+ const kindOf = (point) => {
2371
+ if (point < 128) return isAsciiWord(point) ? "word" : isAsciiSpace(point) ? "space" : "other";
2372
+ const char = String.fromCodePoint(point);
2373
+ if (SEGMENTED.test(char)) return "segmented";
2374
+ if (WORD.test(char)) return "word";
2375
+ return SPACE.test(char) ? "space" : "other";
2376
+ };
2377
+ /**
2378
+ * This function estimates the number of tokens of a part of a text. A word is one token for each
2379
+ * six letters. A Chinese, Japanese or Thai character is one token. Each other character that is
2380
+ * not a space is one token. The estimate is a little high for English text, so a chunk fits.
2381
+ */
2382
+ const estimateTokens = (text, start = 0, end = text.length) => {
2383
+ let tokens = 0;
2384
+ let run = 0;
2385
+ for (let at = start; at < end;) {
2386
+ const point = text.codePointAt(at);
2387
+ at += point > 65535 ? 2 : 1;
2388
+ const kind = kindOf(point);
2389
+ if (kind === "word") {
2390
+ run += 1;
2391
+ continue;
2392
+ }
2393
+ if (run > 0) tokens += Math.ceil(run / LETTERS_PER_TOKEN);
2394
+ run = 0;
2395
+ if (kind !== "space") tokens += 1;
2396
+ }
2397
+ if (run > 0) tokens += Math.ceil(run / LETTERS_PER_TOKEN);
2398
+ return tokens;
2399
+ };
2400
+ /** These characters end a sentence when a space follows them. */
2401
+ const STOP = /[.!?…]+["'”’)\]]*(?=\s|$)|[。!?]+/gu;
2402
+ /** This function gives the ranges of the sentences of a part of a text, with no space at the ends. */
2403
+ const sentencesOf = (text, start, end) => {
2404
+ const part = text.slice(start, end);
2405
+ const out = [];
2406
+ let from = 0;
2407
+ for (const match of part.matchAll(STOP)) {
2408
+ const to = match.index + match[0].length;
2409
+ if (to > from) out.push([from, to]);
2410
+ from = to;
2411
+ }
2412
+ if (from < part.length) out.push([from, part.length]);
2413
+ return out.map(([a, b]) => {
2414
+ let first = a;
2415
+ let last = b;
2416
+ while (first < last && SPACE.test(part[first])) first += 1;
2417
+ while (last > first && SPACE.test(part[last - 1])) last -= 1;
2418
+ return [start + first, start + last];
2419
+ }).filter(([a, b]) => b > a);
2420
+ };
2421
+ /** This function cuts a part of a text that is longer than the limit. It cuts at the spaces, and in a word that is too long. */
2422
+ const cutLong = (text, start, end, size) => {
2423
+ const out = [];
2424
+ let from = start;
2425
+ let tokens = 0;
2426
+ let run = 0;
2427
+ let lastSpace = -1;
2428
+ let at = start;
2429
+ const emit = (to) => {
2430
+ let first = from;
2431
+ while (first < to && SPACE.test(text[first])) first += 1;
2432
+ let last = to;
2433
+ while (last > first && SPACE.test(text[last - 1])) last -= 1;
2434
+ if (last > first) out.push({
2435
+ start: first,
2436
+ end: last,
2437
+ tokens: estimateTokens(text, first, last),
2438
+ paragraph: false
2439
+ });
2440
+ from = to;
2441
+ tokens = 0;
2442
+ run = 0;
2443
+ lastSpace = -1;
2444
+ };
2445
+ while (at < end) {
2446
+ const point = text.codePointAt(at);
2447
+ const width = point > 65535 ? 2 : 1;
2448
+ const kind = kindOf(point);
2449
+ let cost = 0;
2450
+ if (kind === "word") {
2451
+ run += 1;
2452
+ if (run % LETTERS_PER_TOKEN === 1) cost = 1;
2453
+ } else {
2454
+ run = 0;
2455
+ if (kind !== "space") cost = 1;
2456
+ }
2457
+ if (tokens + cost > size && at > from) {
2458
+ if (lastSpace > from) {
2459
+ const to = lastSpace;
2460
+ emit(to);
2461
+ at = to;
2462
+ continue;
2463
+ }
2464
+ emit(at);
2465
+ }
2466
+ if (kind === "space") lastSpace = at;
2467
+ tokens += cost;
2468
+ at += width;
2469
+ }
2470
+ emit(end);
2471
+ return out;
2472
+ };
2473
+ /** This function gives the pieces of a text: its sentences, in paragraphs, and the parts of a sentence that is longer than the limit. */
2474
+ const piecesOf = (text, size) => {
2475
+ const pieces = [];
2476
+ for (const line of text.matchAll(/[^\r\n]+/g)) {
2477
+ let first = true;
2478
+ for (const [start, end] of sentencesOf(text, line.index, line.index + line[0].length)) {
2479
+ const tokens = estimateTokens(text, start, end);
2480
+ const parts = tokens <= size ? [{
2481
+ start,
2482
+ end,
2483
+ tokens,
2484
+ paragraph: false
2485
+ }] : cutLong(text, start, end, size);
2486
+ for (const part of parts) {
2487
+ pieces.push({
2488
+ ...part,
2489
+ paragraph: first
2490
+ });
2491
+ first = false;
2492
+ }
2493
+ }
2494
+ }
2495
+ return pieces;
2496
+ };
2497
+ const positive = (name, value) => {
2498
+ if (!Number.isFinite(value) || value < 1) throw new RangeError(`${name} must be a number of 1 or more, not ${String(value)}`);
2499
+ return Math.floor(value);
2500
+ };
2501
+ /**
2502
+ * This function cuts a text into chunks for an embedder. A chunk is a window of whole sentences.
2503
+ * It ends at the end of a paragraph when the paragraph fills half of the window or more. A
2504
+ * sentence that is longer than the window is cut at its spaces. Two chunks next to each other share
2505
+ * a few sentences (`overlapTokens`), so a passage at a cut is in one chunk. Each chunk keeps its
2506
+ * position in the text, so a hit points at a passage.
2507
+ */
2508
+ const chunkText = (text, options) => {
2509
+ const size = positive("maxTokens", options.maxTokens);
2510
+ const overlap = Math.min(Math.floor(size / 2), Math.max(0, Math.floor(options.overlapTokens ?? size * CHUNK_LIMITS.overlap)));
2511
+ const maxChunks = positive("maxChunks", options.maxChunks ?? CHUNK_LIMITS.maxChunks);
2512
+ const pieces = piecesOf(text, size);
2513
+ const chunks = [];
2514
+ let first = 0;
2515
+ let fresh = 0;
2516
+ while (first < pieces.length && chunks.length < maxChunks) {
2517
+ let last = first;
2518
+ let tokens = pieces[first].tokens;
2519
+ while (last + 1 < pieces.length && tokens + pieces[last + 1].tokens <= size) {
2520
+ last += 1;
2521
+ tokens += pieces[last].tokens;
2522
+ }
2523
+ if (last + 1 < pieces.length && !pieces[last + 1].paragraph) {
2524
+ let filled = 0;
2525
+ let cut = -1;
2526
+ for (let at = first; at < last; at += 1) {
2527
+ filled += pieces[at].tokens;
2528
+ if (at >= fresh && pieces[at + 1].paragraph && filled * 2 >= size) cut = at;
2529
+ }
2530
+ if (cut !== -1) last = cut;
2531
+ }
2532
+ fresh = last + 1;
2533
+ const start = pieces[first].start;
2534
+ const end = pieces[last].end;
2535
+ chunks.push({
2536
+ index: chunks.length,
2537
+ start,
2538
+ end,
2539
+ text: text.slice(start, end)
2540
+ });
2541
+ if (last + 1 >= pieces.length) break;
2542
+ let next = last + 1;
2543
+ let shared = 0;
2544
+ while (next - 1 > first && shared + pieces[next - 1].tokens <= overlap) {
2545
+ next -= 1;
2546
+ shared += pieces[next].tokens;
2547
+ }
2548
+ if (shared + pieces[last + 1].tokens > size) next = last + 1;
2549
+ first = next;
2550
+ }
2551
+ return chunks;
2552
+ };
2553
+ /**
2554
+ * This function gives the number of tokens of a chunk for an embedder. It is the input limit with a
2555
+ * margin for the estimate, and `CHUNK_LIMITS.maxTokens` at most.
2556
+ */
2557
+ const chunkTokensOf = (embedder) => {
2558
+ const limit = embedder.maxInputTokens;
2559
+ const tokens = limit === void 0 || !Number.isFinite(limit) || limit < 1 ? CHUNK_LIMITS.defaultTokens : Math.min(limit, CHUNK_LIMITS.maxTokens);
2560
+ return Math.max(8, Math.floor(tokens * CHUNK_LIMITS.fill));
2561
+ };
2562
+ /** This function gives a copy of a vector with a length of 1. A vector of zeros stays zeros. A value that is not finite throws a `RangeError`. */
2563
+ const unitVector = (vector) => {
2564
+ const out = Float32Array.from(vector);
2565
+ let sum = 0;
2566
+ for (const value of out) {
2567
+ if (!Number.isFinite(value)) throw new RangeError("the embedder gave a value that is not finite");
2568
+ sum += value * value;
2569
+ }
2570
+ if (sum === 0) return out;
2571
+ const scale = 1 / Math.sqrt(sum);
2572
+ for (let at = 0; at < out.length; at += 1) out[at] = out[at] * scale;
2573
+ return out;
2574
+ };
2575
+ /** These are the defaults of a hybrid search. */
2576
+ const HYBRID_DEFAULTS = {
2577
+ candidates: 50,
2578
+ neighbors: 50,
2579
+ fusionK: 60
2580
+ };
2581
+ /**
2582
+ * This function fuses ranked lists with reciprocal rank fusion. Each item gets the sum of
2583
+ * 1 / (k + rank) over the lists that hold it, with the rank from 1. The best item is first. A tie goes to
2584
+ * the item with the best rank in a list, then to the earlier list, then to the smaller id. An item
2585
+ * that is two times in one list counts at its first place.
2586
+ */
2587
+ const fuseRanks = (lists, k = HYBRID_DEFAULTS.fusionK) => {
2588
+ if (!Number.isFinite(k) || k < 0) throw new RangeError(`the fusion constant k must be a number of 0 or more, not ${String(k)}`);
2589
+ const ranks = /* @__PURE__ */ new Map();
2590
+ lists.forEach((list, which) => {
2591
+ list.forEach((id, at) => {
2592
+ let entry = ranks.get(id);
2593
+ if (entry === void 0) {
2594
+ entry = lists.map(() => null);
2595
+ ranks.set(id, entry);
2596
+ }
2597
+ entry[which] ??= at + 1;
2598
+ });
2599
+ });
2600
+ const keyed = [...ranks].map(([id, entry]) => {
2601
+ let score = 0;
2602
+ let best = Number.POSITIVE_INFINITY;
2603
+ for (const rank of entry) {
2604
+ if (rank === null) continue;
2605
+ score += 1 / (k + rank);
2606
+ best = Math.min(best, rank);
2607
+ }
2608
+ return {
2609
+ id,
2610
+ score,
2611
+ ranks: entry,
2612
+ best,
2613
+ first: entry.findIndex((rank) => rank !== null)
2614
+ };
2615
+ });
2616
+ keyed.sort((a, b) => b.score - a.score || a.best - b.best || a.first - b.first || (a.id < b.id ? -1 : a.id > b.id ? 1 : 0));
2617
+ return keyed.map(({ id, score, ranks: entry }) => ({
2618
+ id,
2619
+ score,
2620
+ ranks: entry
2621
+ }));
2622
+ };
2623
+ //#endregion
2624
+ //#region src/hybrid.ts
2625
+ /** The embedder gets this number of texts in one call at most. */
2626
+ const BATCH = 32;
2627
+ /** The search keeps the vectors of this number of questions. */
2628
+ const QUERY_CACHE = 32;
2629
+ /** The search checks this number of texts of the vector search against the index at one time. */
2630
+ const PAGE = 64;
2631
+ const sha256 = (text) => createHash("sha256").update(text, "utf8").digest("hex");
2632
+ const count = (name, value, fallback, max) => {
2633
+ if (value === void 0) return fallback;
2634
+ if (!Number.isFinite(value) || value < 1) throw new RangeError(`${name} must be a number of 1 or more, not ${String(value)}`);
2635
+ return Math.min(max, Math.floor(value));
2636
+ };
2637
+ /** This function checks the constant k of reciprocal rank fusion. */
2638
+ const fusionKOf = (value) => {
2639
+ if (value === void 0) return HYBRID_DEFAULTS.fusionK;
2640
+ if (!Number.isFinite(value) || value < 0) throw new RangeError(`fusionK must be a number of 0 or more, not ${String(value)}`);
2641
+ return value;
2642
+ };
2643
+ /** This function gives a promise with its resolve and its reject. A rejection with no listener is not an error of the process. */
2644
+ const deferred = () => {
2645
+ let resolve = () => void 0;
2646
+ let reject = () => void 0;
2647
+ const promise = new Promise((yes, no) => {
2648
+ resolve = yes;
2649
+ reject = no;
2650
+ });
2651
+ promise.catch(() => void 0);
2652
+ return {
2653
+ promise,
2654
+ resolve,
2655
+ reject
2656
+ };
2657
+ };
2658
+ const round = (value, digits) => {
2659
+ const scale = 10 ** digits;
2660
+ return Math.round(value * scale) / scale;
2661
+ };
2662
+ /** This function makes a hybrid search over a runtime. The vectors go into the store, with the id of the embedder. */
2663
+ const createHybridSearch = (runtime, options) => {
2664
+ const { embedder, store } = options;
2665
+ const tokens = chunkTokensOf(embedder);
2666
+ const plan = `chunks-1/${String(tokens)}/${String(Math.floor(tokens * CHUNK_LIMITS.overlap))}/${String(CHUNK_LIMITS.maxChunks)}`;
2667
+ const working = /* @__PURE__ */ new Map();
2668
+ const queries = /* @__PURE__ */ new Map();
2669
+ let dimensions = embedder.dimensions > 0 ? embedder.dimensions : store.dimensions(embedder.id);
2670
+ /** This function gives the heads of documents, from `digests` when the index has it. */
2671
+ const headsOf = (docs) => {
2672
+ const heads = /* @__PURE__ */ new Map();
2673
+ if (docs.length === 0) return heads;
2674
+ const index = runtime.index;
2675
+ if (index.digests !== void 0) {
2676
+ for (const head of index.digests({ docs })) heads.set(head.doc, head);
2677
+ return heads;
2678
+ }
2679
+ for (const doc of docs) {
2680
+ const found = index.get(doc);
2681
+ if (found !== void 0) heads.set(doc, headOfDoc(found));
2682
+ }
2683
+ return heads;
2684
+ };
2685
+ const headOfDoc = (doc) => ({
2686
+ doc: doc.doc,
2687
+ kind: doc.kind,
2688
+ ai_input: doc.ai_input,
2689
+ text_sha: sha256(doc.text)
2690
+ });
2691
+ /** This function asks the embedder for the vectors of texts, in batches, and checks them. */
2692
+ const vectorsOf = async (texts, purpose, signal) => {
2693
+ const out = [];
2694
+ for (let at = 0; at < texts.length; at += BATCH) {
2695
+ signal?.throwIfAborted();
2696
+ const part = texts.slice(at, at + BATCH);
2697
+ const given = await embedder.embed(part, {
2698
+ signal,
2699
+ purpose
2700
+ });
2701
+ if (!Array.isArray(given) || given.length !== part.length) throw new Error(`the embedder ${embedder.id} gave ${String(Array.isArray(given) ? given.length : 0)} vectors for ${String(part.length)} texts`);
2702
+ for (const vector of given) {
2703
+ const unit = unitVector(vector);
2704
+ dimensions ??= unit.length;
2705
+ if (unit.length !== dimensions || unit.length === 0) throw new Error(`the embedder ${embedder.id} gave a vector of ${String(unit.length)} dimensions, not ${String(dimensions)}`);
2706
+ out.push(unit);
2707
+ }
2708
+ }
2709
+ return out;
2710
+ };
2711
+ const queryVector = async (q, signal) => {
2712
+ const text = q.replace(/["*]/g, " ").replace(/\s+/g, " ").trim();
2713
+ const known = queries.get(text);
2714
+ if (known !== void 0) {
2715
+ queries.delete(text);
2716
+ queries.set(text, known);
2717
+ return known;
2718
+ }
2719
+ const [vector] = await vectorsOf([text], "query", signal);
2720
+ queries.set(text, vector);
2721
+ if (queries.size > QUERY_CACHE) queries.delete(queries.keys().next().value);
2722
+ return vector;
2723
+ };
2724
+ /**
2725
+ * This function gives vectors to the texts of documents that have none, and points each document
2726
+ * at its current text. It reads each text from the index, and it does not send the text
2727
+ * of a held-back document. Two calls for one text share the work. It gives the number of new vectors.
2728
+ */
2729
+ const ensure = async (docs, signal) => {
2730
+ const tally = {
2731
+ embedded: 0,
2732
+ reused: 0,
2733
+ held: 0,
2734
+ chunks: 0
2735
+ };
2736
+ const now = runtime.now();
2737
+ const heads = headsOf(docs);
2738
+ const waiting = /* @__PURE__ */ new Map();
2739
+ const shared = [];
2740
+ for (const id of docs) {
2741
+ const head = heads.get(id);
2742
+ if (head === void 0) continue;
2743
+ if (isHeldBack(head)) {
2744
+ tally.held += 1;
2745
+ if (store.pointed(embedder.id, id) !== void 0) store.forget(embedder.id, id);
2746
+ continue;
2747
+ }
2748
+ if (store.plan(embedder.id, head.text_sha) === plan) {
2749
+ tally.reused += 1;
2750
+ if (store.pointed(embedder.id, id) !== head.text_sha) store.point(embedder.id, id, head.text_sha, now);
2751
+ continue;
2752
+ }
2753
+ const doc = runtime.index.get(id);
2754
+ if (doc === void 0 || isHeldBack(doc)) continue;
2755
+ const sha = sha256(doc.text);
2756
+ const entry = waiting.get(sha);
2757
+ if (entry !== void 0) {
2758
+ entry.docs.push(id);
2759
+ continue;
2760
+ }
2761
+ const busy = working.get(sha);
2762
+ if (busy !== void 0) {
2763
+ shared.push(busy.then((made) => {
2764
+ store.point(embedder.id, id, sha, now);
2765
+ return made;
2766
+ }));
2767
+ continue;
2768
+ }
2769
+ waiting.set(sha, {
2770
+ text: doc.text,
2771
+ docs: [id]
2772
+ });
2773
+ }
2774
+ const jobs = [...waiting].map(([sha, entry]) => {
2775
+ const job = {
2776
+ sha,
2777
+ entry,
2778
+ chunks: chunkText(entry.text, { maxTokens: tokens }),
2779
+ ...deferred()
2780
+ };
2781
+ working.set(sha, job.promise);
2782
+ return job;
2783
+ });
2784
+ try {
2785
+ for (let at = 0; at < jobs.length;) {
2786
+ const batch = [];
2787
+ let size = 0;
2788
+ while (at < jobs.length && (batch.length === 0 || size + jobs[at].chunks.length <= BATCH)) {
2789
+ batch.push(jobs[at]);
2790
+ size += jobs[at].chunks.length;
2791
+ at += 1;
2792
+ }
2793
+ const vectors = await vectorsOf(batch.flatMap((job) => job.chunks.map((chunk) => chunk.text)), "document", signal);
2794
+ let next = 0;
2795
+ for (const job of batch) {
2796
+ const chunks = job.chunks.map((chunk) => ({
2797
+ start: chunk.start,
2798
+ end: chunk.end,
2799
+ vector: vectors[next++]
2800
+ }));
2801
+ store.put({
2802
+ embedder: embedder.id,
2803
+ dimensions: dimensions ?? chunks[0]?.vector.length ?? 0,
2804
+ sha: job.sha,
2805
+ plan,
2806
+ chars: job.entry.text.length,
2807
+ chunks,
2808
+ docs: job.entry.docs,
2809
+ at: now
2810
+ });
2811
+ tally.embedded += 1;
2812
+ tally.chunks += chunks.length;
2813
+ working.delete(job.sha);
2814
+ job.resolve(chunks.length);
2815
+ }
2816
+ }
2817
+ } catch (error) {
2818
+ for (const job of jobs) {
2819
+ if (working.get(job.sha) === job.promise) working.delete(job.sha);
2820
+ job.reject(error);
2821
+ }
2822
+ throw error;
2823
+ }
2824
+ await Promise.all(shared);
2825
+ return tally;
2826
+ };
2827
+ /**
2828
+ * This function gives the documents of the vector search, the best first, to `limit`. It checks
2829
+ * each document against the index. Its current text must be the text of the hit, it must not be
2830
+ * held back, and it must have the kind. With `within`, it ranks only those documents.
2831
+ */
2832
+ const neighborsOf = (query, limit, kind, within) => {
2833
+ const hits = store.search(embedder.id, query, within === void 0 ? {} : { within });
2834
+ const out = [];
2835
+ for (let at = 0; at < hits.length && out.length < limit; at += PAGE) {
2836
+ const page = hits.slice(at, at + PAGE);
2837
+ const docs = [...new Set(page.flatMap((hit) => within === void 0 ? hit.docs : hit.docs.filter((doc) => within.has(doc))))];
2838
+ const heads = headsOf(docs);
2839
+ for (const hit of page) for (const doc of hit.docs) {
2840
+ if (out.length >= limit) break;
2841
+ if (within !== void 0 && !within.has(doc)) continue;
2842
+ const head = heads.get(doc);
2843
+ if (head === void 0 || head.text_sha !== hit.sha || isHeldBack(head)) continue;
2844
+ if (kind !== void 0 && kind !== null && head.kind !== kind) continue;
2845
+ out.push({
2846
+ doc,
2847
+ hit
2848
+ });
2849
+ }
2850
+ }
2851
+ return out;
2852
+ };
2853
+ const passageOf = (hit) => ({
2854
+ start: hit.start,
2855
+ end: hit.end,
2856
+ similarity: round(hit.similarity, 4)
2857
+ });
2858
+ return {
2859
+ embedder,
2860
+ async find(q, findOptions = {}) {
2861
+ const query = parseQuery(q);
2862
+ if (query === void 0) return [];
2863
+ const limit = Math.max(1, Math.min(1e3, Math.floor(findOptions.limit ?? 10) || 10));
2864
+ const candidates = count("candidates", findOptions.candidates, HYBRID_DEFAULTS.candidates, 1e3);
2865
+ const neighbors = count("neighbors", findOptions.neighbors, HYBRID_DEFAULTS.neighbors, 1e3);
2866
+ const k = fusionKOf(findOptions.fusionK);
2867
+ const signal = findOptions.signal;
2868
+ const vector = await queryVector(q, signal);
2869
+ const kind = findOptions.kind ?? null;
2870
+ const lexical = runtime.index.find(q, {
2871
+ limit: candidates,
2872
+ kind,
2873
+ rank: "exact"
2874
+ });
2875
+ await ensure(lexical.map((hit) => hit.doc), signal);
2876
+ const near = neighborsOf(vector, neighbors, kind, void 0);
2877
+ const fused = fuseRanks([lexical.map((hit) => hit.doc), near.map((one) => one.doc)], k).slice(0, limit);
2878
+ const byDoc = new Map(lexical.map((hit) => [hit.doc, hit]));
2879
+ const nearBy = new Map(near.map((one) => [one.doc, one.hit]));
2880
+ const results = [];
2881
+ for (const one of fused) {
2882
+ const doc = runtime.index.get(one.id);
2883
+ if (doc === void 0 || isHeldBack(doc)) continue;
2884
+ const best = nearBy.get(one.id) ?? store.best(embedder.id, sha256(doc.text), vector);
2885
+ const lexicalHit = byDoc.get(one.id);
2886
+ const archived = doc.kind === "archived" ? archivedOf(doc.meta) : void 0;
2887
+ results.push({
2888
+ doc: doc.doc,
2889
+ kind: doc.kind,
2890
+ title: doc.title,
2891
+ url: doc.url,
2892
+ snippet: best === void 0 ? lexicalHit?.snippet ?? snippetOf(doc.text, query.terms) : snippetOf(doc.text.slice(best.start, best.end), query.terms),
2893
+ score: round(one.score, 6),
2894
+ ai_input: doc.ai_input,
2895
+ fetched_at: doc.fetched_at,
2896
+ capture: doc.capture,
2897
+ ...archived === void 0 ? {} : { archived },
2898
+ ...best === void 0 ? {} : { passage: passageOf(best) }
2899
+ });
2900
+ }
2901
+ return results;
2902
+ },
2903
+ async rankWithin(q, docs, rankOptions = {}) {
2904
+ if (parseQuery(q) === void 0 || docs.length === 0) return [];
2905
+ const candidates = count("candidates", rankOptions.candidates, HYBRID_DEFAULTS.candidates, 1e3);
2906
+ const neighbors = count("neighbors", rankOptions.neighbors, HYBRID_DEFAULTS.neighbors, 1e5);
2907
+ const k = fusionKOf(rankOptions.fusionK);
2908
+ const signal = rankOptions.signal;
2909
+ const vector = await queryVector(q, signal);
2910
+ const lexical = runtime.index.rankWithin(q, docs, { rank: "exact" });
2911
+ const first = [.../* @__PURE__ */ new Set([...lexical.map((hit) => hit.doc), ...docs])].slice(0, candidates);
2912
+ await ensure(first, signal);
2913
+ const within = new Set(docs);
2914
+ const near = neighborsOf(vector, neighbors, void 0, within);
2915
+ const nearBy = new Map(near.map((one) => [one.doc, one.hit]));
2916
+ const heads = headsOf(lexical.map((hit) => hit.doc).filter((doc) => !nearBy.has(doc)));
2917
+ return fuseRanks([lexical.map((hit) => hit.doc), near.map((one) => one.doc)], k).map((one) => {
2918
+ const sha = heads.get(one.id)?.text_sha;
2919
+ const best = nearBy.get(one.id) ?? (sha === void 0 ? void 0 : store.best(embedder.id, sha, vector));
2920
+ return {
2921
+ doc: one.id,
2922
+ score: round(one.score, 6),
2923
+ ...best === void 0 ? {} : { passage: passageOf(best) }
2924
+ };
2925
+ });
2926
+ },
2927
+ async embed(embedOptions = {}) {
2928
+ const started = Date.now();
2929
+ const budgetMs = durationMs(embedOptions.budget ?? "10m");
2930
+ const maxDocs = embedOptions.maxDocs === void 0 ? Number.POSITIVE_INFINITY : count("maxDocs", embedOptions.maxDocs, 1, Number.MAX_SAFE_INTEGER);
2931
+ const kind = embedOptions.kind ?? null;
2932
+ const timer = new AbortController();
2933
+ const clock = setTimeout(() => timer.abort(/* @__PURE__ */ new Error("the budget of embed is over")), Math.min(budgetMs, 2147483647));
2934
+ clock.unref();
2935
+ const signal = embedOptions.signal === void 0 ? timer.signal : AbortSignal.any([embedOptions.signal, timer.signal]);
2936
+ const tally = {
2937
+ docs: 0,
2938
+ embedded: 0,
2939
+ reused: 0,
2940
+ held_back: 0,
2941
+ chunks: 0
2942
+ };
2943
+ let stopped = "done";
2944
+ const stopOf = () => embedOptions.signal?.aborted === true ? "signal" : timer.signal.aborted || Date.now() - started >= budgetMs ? "budget" : tally.embedded >= maxDocs ? "max-docs" : void 0;
2945
+ try {
2946
+ for await (const page of pagesOf(runtime)) {
2947
+ const docs = page.filter((head) => kind === null || head.kind === kind);
2948
+ for (let at = 0; at < docs.length;) {
2949
+ const stop = stopOf();
2950
+ if (stop !== void 0) {
2951
+ stopped = stop;
2952
+ break;
2953
+ }
2954
+ const room = Math.max(1, Math.min(BATCH, maxDocs - tally.embedded));
2955
+ const group = docs.slice(at, at + room).map((head) => head.doc);
2956
+ at += group.length;
2957
+ try {
2958
+ const done = await ensure(group, signal);
2959
+ tally.docs += group.length;
2960
+ tally.embedded += done.embedded;
2961
+ tally.reused += done.reused;
2962
+ tally.held_back += done.held;
2963
+ tally.chunks += done.chunks;
2964
+ } catch (error) {
2965
+ const stop = stopOf();
2966
+ if (stop === void 0) throw error;
2967
+ stopped = stop;
2968
+ break;
2969
+ }
2970
+ }
2971
+ if (stopped !== "done") break;
2972
+ }
2973
+ } finally {
2974
+ clearTimeout(clock);
2975
+ }
2976
+ return {
2977
+ embedder: embedder.id,
2978
+ ...tally,
2979
+ stopped,
2980
+ ms: Date.now() - started
2981
+ };
2982
+ }
2983
+ };
2984
+ };
2985
+ /**
2986
+ * This function gives the heads of each stored document, in pages. It uses `digests` when the
2987
+ * index has it. Else it reads the capture log and asks the index for the documents of each capture.
2988
+ */
2989
+ async function* pagesOf(runtime) {
2990
+ const index = runtime.index;
2991
+ if (index.digests !== void 0) {
2992
+ let after = "";
2993
+ for (;;) {
2994
+ const page = index.digests({
2995
+ after,
2996
+ limit: 500
2997
+ });
2998
+ if (page.length === 0) return;
2999
+ yield page;
3000
+ after = page.at(-1).doc;
3001
+ }
3002
+ }
3003
+ const seen = /* @__PURE__ */ new Set();
3004
+ let page = [];
3005
+ for await (const envelope of runtime.captures.scan({ source: "fetch" })) for (const doc of index.docsOfCapture(envelope.id)) {
3006
+ if (seen.has(doc.doc)) continue;
3007
+ seen.add(doc.doc);
3008
+ page.push({
3009
+ doc: doc.doc,
3010
+ kind: doc.kind,
3011
+ ai_input: doc.ai_input,
3012
+ text_sha: sha256(doc.text)
3013
+ });
3014
+ if (page.length >= 500) {
3015
+ yield page;
3016
+ page = [];
3017
+ }
3018
+ }
3019
+ if (page.length > 0) yield page;
3020
+ }
3021
+ //#endregion
3022
+ //#region src/vectors.ts
3023
+ /**
3024
+ * The vector store keeps the vectors of hybrid search in `vectors.sqlite`, beside the index. It is
3025
+ * a projection, as the index is. Delete the file, and the next hybrid search or `embed` makes the
3026
+ * vectors again from the stored texts.
3027
+ *
3028
+ * - A row is one chunk of one text for one embedder. Its key is the SHA-256 of the text, the
3029
+ * number of the chunk and the id of the embedder. Thus a reindex keeps each vector, and the same
3030
+ * text gets its vectors one time for each model, also when two documents hold it.
3031
+ * - A text is complete when the table `texts` has it, with the plan of its chunks. A text with a
3032
+ * different plan gets new chunks.
3033
+ * - The table `docs` points each document at the text that has its vectors. A search uses only
3034
+ * the texts that a document points at, and the caller checks each document against the index.
3035
+ * - A search compares the vector of the question with each stored vector (a brute-force cosine).
3036
+ * The store keeps the vectors of each embedder in memory after the first search. It reads them
3037
+ * again when a different process writes the file.
3038
+ *
3039
+ * Each connection waits for the lock of a different process (`busy_timeout`), and each write is
3040
+ * one transaction (`BEGIN IMMEDIATE`). The file has a schema version (`VECTORS_SCHEMA`). A file of
3041
+ * a different version opens empty.
3042
+ */
3043
+ /** This value is the version of the schema of the vector file. */
3044
+ const VECTORS_SCHEMA = 1;
3045
+ const DDL = `
3046
+ CREATE TABLE IF NOT EXISTS vectors_meta (key TEXT PRIMARY KEY, value TEXT NOT NULL);
3047
+ CREATE TABLE IF NOT EXISTS embedders (
3048
+ id TEXT PRIMARY KEY,
3049
+ dimensions INTEGER NOT NULL,
3050
+ first_at TEXT NOT NULL
3051
+ );
3052
+ CREATE TABLE IF NOT EXISTS texts (
3053
+ sha TEXT NOT NULL,
3054
+ embedder TEXT NOT NULL,
3055
+ plan TEXT NOT NULL,
3056
+ chunks INTEGER NOT NULL,
3057
+ chars INTEGER NOT NULL,
3058
+ embedded_at TEXT NOT NULL,
3059
+ PRIMARY KEY (sha, embedder)
3060
+ );
3061
+ CREATE TABLE IF NOT EXISTS chunks (
3062
+ sha TEXT NOT NULL,
3063
+ chunk INTEGER NOT NULL,
3064
+ embedder TEXT NOT NULL,
3065
+ char_start INTEGER NOT NULL,
3066
+ char_end INTEGER NOT NULL,
3067
+ vector BLOB NOT NULL,
3068
+ PRIMARY KEY (sha, chunk, embedder)
3069
+ );
3070
+ CREATE INDEX IF NOT EXISTS chunks_embedder ON chunks (embedder, sha, chunk);
3071
+ CREATE TABLE IF NOT EXISTS docs (
3072
+ doc TEXT NOT NULL,
3073
+ embedder TEXT NOT NULL,
3074
+ sha TEXT NOT NULL,
3075
+ pointed_at TEXT NOT NULL,
3076
+ PRIMARY KEY (doc, embedder)
3077
+ );
3078
+ CREATE INDEX IF NOT EXISTS docs_sha ON docs (embedder, sha);
3079
+ `;
3080
+ /** This function gives the bytes of a vector for a BLOB. The bytes are in the order of the platform (little-endian on each common platform). */
3081
+ const bytesOf = (vector) => new Uint8Array(vector.buffer, vector.byteOffset, vector.byteLength);
3082
+ /** This function gives the vector of the bytes of a BLOB. It copies the bytes, so an odd offset of the BLOB is not a problem. */
3083
+ const vectorOf = (bytes) => {
3084
+ const out = new Float32Array(Math.floor(bytes.byteLength / 4));
3085
+ new Uint8Array(out.buffer).set(bytes.subarray(0, out.length * 4));
3086
+ return out;
3087
+ };
3088
+ const dot = (data, row, query, dims) => {
3089
+ let sum = 0;
3090
+ const base = row * dims;
3091
+ for (let at = 0; at < dims; at += 1) sum += data[base + at] * query[at];
3092
+ return sum;
3093
+ };
3094
+ var VectorStore = class {
3095
+ path;
3096
+ db;
3097
+ statements = /* @__PURE__ */ new Map();
3098
+ matrices = /* @__PURE__ */ new Map();
3099
+ closed = false;
3100
+ constructor(path, options = {}) {
3101
+ this.path = path;
3102
+ if (path !== ":memory:") mkdirSync(dirname(path), {
3103
+ recursive: true,
3104
+ mode: 448
3105
+ });
3106
+ this.db = new DatabaseSync(path);
3107
+ const wait = Math.max(0, Math.floor(options.busyTimeoutMs ?? 1e4));
3108
+ this.db.exec(`PRAGMA busy_timeout = ${String(wait)};`);
3109
+ if (path !== ":memory:") this.db.exec("PRAGMA journal_mode = WAL;");
3110
+ this.db.exec("PRAGMA synchronous = NORMAL;");
3111
+ this.write(() => {
3112
+ if (this.db.prepare("PRAGMA user_version").get().user_version !== 1) {
3113
+ const tables = this.db.prepare("SELECT name FROM sqlite_master WHERE type = 'table' AND name NOT LIKE 'sqlite_%'").all();
3114
+ for (const { name } of tables) this.db.exec(`DROP TABLE IF EXISTS "${name}"`);
3115
+ }
3116
+ this.db.exec(DDL);
3117
+ this.db.exec(`PRAGMA user_version = ${String(1)}`);
3118
+ });
3119
+ }
3120
+ stmt(sql) {
3121
+ let statement = this.statements.get(sql);
3122
+ if (statement === void 0) {
3123
+ statement = this.db.prepare(sql);
3124
+ this.statements.set(sql, statement);
3125
+ }
3126
+ return statement;
3127
+ }
3128
+ write(work) {
3129
+ if (this.closed) throw new Error("the vector store is closed");
3130
+ this.db.exec("BEGIN IMMEDIATE");
3131
+ try {
3132
+ const result = work();
3133
+ this.db.exec("COMMIT");
3134
+ return result;
3135
+ } catch (error) {
3136
+ if (this.db.isTransaction) this.db.exec("ROLLBACK");
3137
+ throw error;
3138
+ }
3139
+ }
3140
+ live() {
3141
+ if (this.closed) throw new Error("the vector store is closed");
3142
+ }
3143
+ /** This method gives the plan of the chunks of a text when the store has each of its vectors, or undefined. */
3144
+ plan(embedder, sha) {
3145
+ this.live();
3146
+ return this.stmt("SELECT plan FROM texts WHERE sha = ? AND embedder = ?").get(sha, embedder)?.plan;
3147
+ }
3148
+ /** This method gives the text that a document points at, or undefined. */
3149
+ pointed(embedder, doc) {
3150
+ this.live();
3151
+ return this.stmt("SELECT sha FROM docs WHERE doc = ? AND embedder = ?").get(doc, embedder)?.sha;
3152
+ }
3153
+ /** This method gives the dimensions that the store keeps for an embedder, or undefined. */
3154
+ dimensions(embedder) {
3155
+ this.live();
3156
+ return this.stmt("SELECT dimensions FROM embedders WHERE id = ?").get(embedder)?.dimensions;
3157
+ }
3158
+ /**
3159
+ * This method writes the chunks of one text and points its documents at it, in one transaction.
3160
+ * A vector with a length that is different from the dimensions of the embedder throws an error.
3161
+ */
3162
+ put(input) {
3163
+ const dims = input.dimensions;
3164
+ for (const chunk of input.chunks) if (chunk.vector.length !== dims) throw new Error(`the embedder ${input.embedder} gave a vector of ${String(chunk.vector.length)} dimensions, not ${String(dims)}`);
3165
+ let replaced = false;
3166
+ this.write(() => {
3167
+ const known = this.stmt("SELECT dimensions FROM embedders WHERE id = ?").get(input.embedder);
3168
+ if (known === void 0 && input.chunks.length > 0) this.stmt("INSERT INTO embedders (id, dimensions, first_at) VALUES (?, ?, ?)").run(input.embedder, dims, input.at);
3169
+ else if (known !== void 0 && input.chunks.length > 0 && known.dimensions !== dims) throw new Error(`vectors.sqlite holds vectors of ${String(known.dimensions)} dimensions for the embedder ${input.embedder}, and it gave ${String(dims)}: give the embedder a new id, or delete vectors.sqlite`);
3170
+ const before = this.stmt("SELECT plan FROM texts WHERE sha = ? AND embedder = ?").get(input.sha, input.embedder);
3171
+ replaced = before !== void 0 && before.plan !== input.plan;
3172
+ this.stmt("DELETE FROM chunks WHERE sha = ? AND embedder = ?").run(input.sha, input.embedder);
3173
+ const insert = this.stmt("INSERT INTO chunks (sha, chunk, embedder, char_start, char_end, vector) VALUES (?, ?, ?, ?, ?, ?)");
3174
+ input.chunks.forEach((chunk, at) => {
3175
+ insert.run(input.sha, at, input.embedder, chunk.start, chunk.end, bytesOf(chunk.vector));
3176
+ });
3177
+ this.stmt(`INSERT INTO texts (sha, embedder, plan, chunks, chars, embedded_at) VALUES (?, ?, ?, ?, ?, ?)
3178
+ ON CONFLICT(sha, embedder) DO UPDATE SET plan = excluded.plan, chunks = excluded.chunks,
3179
+ chars = excluded.chars, embedded_at = excluded.embedded_at`).run(input.sha, input.embedder, input.plan, input.chunks.length, input.chars, input.at);
3180
+ for (const doc of input.docs) this.pointRow(input.embedder, doc, input.sha, input.at);
3181
+ });
3182
+ const matrix = this.matrices.get(input.embedder);
3183
+ if (matrix === void 0) return;
3184
+ if (replaced || matrix.texts.has(input.sha)) {
3185
+ this.matrices.delete(input.embedder);
3186
+ return;
3187
+ }
3188
+ this.append(matrix, input.sha, input.chunks.map((chunk, at) => ({
3189
+ ...chunk,
3190
+ chunk: at
3191
+ })));
3192
+ for (const doc of input.docs) this.pointMatrix(matrix, doc, input.sha);
3193
+ }
3194
+ pointRow(embedder, doc, sha, at) {
3195
+ this.stmt(`INSERT INTO docs (doc, embedder, sha, pointed_at) VALUES (?, ?, ?, ?)
3196
+ ON CONFLICT(doc, embedder) DO UPDATE SET sha = excluded.sha, pointed_at = excluded.pointed_at`).run(doc, embedder, sha, at);
3197
+ }
3198
+ /** This method points a document at a text that the store has. */
3199
+ point(embedder, doc, sha, at) {
3200
+ this.write(() => this.pointRow(embedder, doc, sha, at));
3201
+ const matrix = this.matrices.get(embedder);
3202
+ if (matrix === void 0) return;
3203
+ if (!matrix.texts.has(sha)) this.append(matrix, sha, this.chunks(embedder, sha));
3204
+ this.pointMatrix(matrix, doc, sha);
3205
+ }
3206
+ /** This method removes the pointer of a document, for example when the document is held back. Its text keeps its vectors. */
3207
+ forget(embedder, doc) {
3208
+ this.write(() => this.stmt("DELETE FROM docs WHERE doc = ? AND embedder = ?").run(doc, embedder));
3209
+ const matrix = this.matrices.get(embedder);
3210
+ if (matrix === void 0) return;
3211
+ const sha = matrix.shaOf.get(doc);
3212
+ if (sha !== void 0) matrix.docsOf.get(sha)?.delete(doc);
3213
+ matrix.shaOf.delete(doc);
3214
+ }
3215
+ /** This method gives the chunks of a text, in their sequence. */
3216
+ chunks(embedder, sha) {
3217
+ this.live();
3218
+ return this.stmt("SELECT chunk, char_start, char_end, vector FROM chunks WHERE sha = ? AND embedder = ? ORDER BY chunk").all(sha, embedder).map((row) => ({
3219
+ chunk: row.chunk,
3220
+ start: row.char_start,
3221
+ end: row.char_end,
3222
+ vector: vectorOf(row.vector)
3223
+ }));
3224
+ }
3225
+ pointMatrix(matrix, doc, sha) {
3226
+ const before = matrix.shaOf.get(doc);
3227
+ if (before !== void 0 && before !== sha) matrix.docsOf.get(before)?.delete(doc);
3228
+ matrix.shaOf.set(doc, sha);
3229
+ let docs = matrix.docsOf.get(sha);
3230
+ if (docs === void 0) {
3231
+ docs = /* @__PURE__ */ new Set();
3232
+ matrix.docsOf.set(sha, docs);
3233
+ }
3234
+ docs.add(doc);
3235
+ }
3236
+ append(matrix, sha, chunks) {
3237
+ const dims = matrix.dimensions;
3238
+ const need = matrix.rows + chunks.length;
3239
+ if (need > matrix.chunk.length) {
3240
+ const size = Math.max(need, matrix.chunk.length * 2, 64);
3241
+ const data = new Float32Array(size * dims);
3242
+ data.set(matrix.data.subarray(0, matrix.rows * dims));
3243
+ const grow = (old) => {
3244
+ const next = new Int32Array(size);
3245
+ next.set(old.subarray(0, matrix.rows));
3246
+ return next;
3247
+ };
3248
+ matrix.data = data;
3249
+ matrix.chunk = grow(matrix.chunk);
3250
+ matrix.start = grow(matrix.start);
3251
+ matrix.end = grow(matrix.end);
3252
+ }
3253
+ const from = matrix.rows;
3254
+ for (const chunk of chunks) {
3255
+ const row = matrix.rows;
3256
+ if (chunk.vector.length === dims) matrix.data.set(chunk.vector, row * dims);
3257
+ matrix.chunk[row] = chunk.chunk;
3258
+ matrix.start[row] = chunk.start;
3259
+ matrix.end[row] = chunk.end;
3260
+ matrix.rows += 1;
3261
+ }
3262
+ matrix.texts.set(sha, {
3263
+ from,
3264
+ to: matrix.rows
3265
+ });
3266
+ }
3267
+ /** This method gives the vectors of an embedder in memory. It reads them again when a different process wrote the file. */
3268
+ matrix(embedder) {
3269
+ this.live();
3270
+ const version = this.db.prepare("PRAGMA data_version").get().data_version;
3271
+ const cached = this.matrices.get(embedder);
3272
+ if (cached !== void 0 && cached.version === version) return cached;
3273
+ const dims = this.dimensions(embedder);
3274
+ if (dims === void 0) return void 0;
3275
+ this.db.exec("BEGIN");
3276
+ try {
3277
+ const matrix = this.load(embedder, dims, version);
3278
+ this.matrices.set(embedder, matrix);
3279
+ return matrix;
3280
+ } finally {
3281
+ if (this.db.isTransaction) this.db.exec("COMMIT");
3282
+ }
3283
+ }
3284
+ load(embedder, dims, version) {
3285
+ const count = this.db.prepare("SELECT count(*) AS n FROM chunks WHERE embedder = ? AND sha IN (SELECT sha FROM docs WHERE embedder = ?)").get(embedder, embedder).n;
3286
+ const matrix = {
3287
+ dimensions: dims,
3288
+ rows: 0,
3289
+ data: new Float32Array(Math.max(1, count) * dims),
3290
+ chunk: new Int32Array(Math.max(1, count)),
3291
+ start: new Int32Array(Math.max(1, count)),
3292
+ end: new Int32Array(Math.max(1, count)),
3293
+ texts: /* @__PURE__ */ new Map(),
3294
+ docsOf: /* @__PURE__ */ new Map(),
3295
+ shaOf: /* @__PURE__ */ new Map(),
3296
+ version
3297
+ };
3298
+ const rows = this.db.prepare(`SELECT sha, chunk, char_start, char_end, vector FROM chunks
3299
+ WHERE embedder = ? AND sha IN (SELECT sha FROM docs WHERE embedder = ?)
3300
+ ORDER BY sha, chunk`).iterate(embedder, embedder);
3301
+ let sha;
3302
+ let from = 0;
3303
+ for (const row of rows) {
3304
+ if (row.sha !== sha) {
3305
+ if (sha !== void 0) matrix.texts.set(sha, {
3306
+ from,
3307
+ to: matrix.rows
3308
+ });
3309
+ sha = row.sha;
3310
+ from = matrix.rows;
3311
+ }
3312
+ if (matrix.rows >= matrix.chunk.length) break;
3313
+ const at = matrix.rows;
3314
+ if (row.vector.byteLength === dims * 4) new Uint8Array(matrix.data.buffer, at * dims * 4, dims * 4).set(row.vector);
3315
+ matrix.chunk[at] = row.chunk;
3316
+ matrix.start[at] = row.char_start;
3317
+ matrix.end[at] = row.char_end;
3318
+ matrix.rows += 1;
3319
+ }
3320
+ if (sha !== void 0) matrix.texts.set(sha, {
3321
+ from,
3322
+ to: matrix.rows
3323
+ });
3324
+ for (const row of this.db.prepare("SELECT doc, sha FROM docs WHERE embedder = ?").all(embedder)) this.pointMatrix(matrix, row.doc, row.sha);
3325
+ return matrix;
3326
+ }
3327
+ /** This method gives the best chunk of one text for a question, or undefined when the store has no vector of the text. */
3328
+ best(embedder, sha, query) {
3329
+ const matrix = this.matrix(embedder);
3330
+ const range = matrix?.texts.get(sha);
3331
+ if (matrix === void 0 || range === void 0 || range.to === range.from) return void 0;
3332
+ return this.hitOf(matrix, sha, range, query);
3333
+ }
3334
+ hitOf(matrix, sha, range, query) {
3335
+ let bestRow = range.from;
3336
+ let bestScore = Number.NEGATIVE_INFINITY;
3337
+ for (let row = range.from; row < range.to; row += 1) {
3338
+ const score = dot(matrix.data, row, query, matrix.dimensions);
3339
+ if (score > bestScore) {
3340
+ bestScore = score;
3341
+ bestRow = row;
3342
+ }
3343
+ }
3344
+ return {
3345
+ sha,
3346
+ chunk: matrix.chunk[bestRow],
3347
+ start: matrix.start[bestRow],
3348
+ end: matrix.end[bestRow],
3349
+ similarity: bestScore,
3350
+ docs: [...matrix.docsOf.get(sha) ?? []]
3351
+ };
3352
+ }
3353
+ /**
3354
+ * This method compares a question with each stored vector of an embedder (a brute-force cosine).
3355
+ * It gives the best chunk of each text that a document points at, the best first. With `within`,
3356
+ * it compares only the texts of those documents.
3357
+ */
3358
+ search(embedder, query, options = {}) {
3359
+ const matrix = this.matrix(embedder);
3360
+ if (matrix === void 0) return [];
3361
+ if (query.length !== matrix.dimensions) throw new Error(`the question has a vector of ${String(query.length)} dimensions, and the store holds ${String(matrix.dimensions)} for the embedder ${embedder}`);
3362
+ const shas = /* @__PURE__ */ new Set();
3363
+ if (options.within === void 0) {
3364
+ for (const [sha, docs] of matrix.docsOf) if (docs.size > 0) shas.add(sha);
3365
+ } else for (const doc of options.within) {
3366
+ const sha = matrix.shaOf.get(doc);
3367
+ if (sha !== void 0) shas.add(sha);
3368
+ }
3369
+ const hits = [];
3370
+ for (const sha of shas) {
3371
+ const range = matrix.texts.get(sha);
3372
+ if (range === void 0 || range.to === range.from) continue;
3373
+ hits.push(this.hitOf(matrix, sha, range, query));
3374
+ }
3375
+ return hits.sort((a, b) => b.similarity - a.similarity || (a.sha < b.sha ? -1 : 1));
3376
+ }
3377
+ /** This method gives the counts of the store, for one embedder or for each embedder. */
3378
+ counts(embedder) {
3379
+ this.live();
3380
+ const where = embedder === void 0 ? "" : " WHERE embedder = ?";
3381
+ const params = embedder === void 0 ? [] : [embedder];
3382
+ const one = (sql) => this.stmt(sql).get(...params).n ?? 0;
3383
+ return {
3384
+ texts: one(`SELECT count(*) AS n FROM texts${where}`),
3385
+ chunks: one(`SELECT count(*) AS n FROM chunks${where}`),
3386
+ docs: one(`SELECT count(*) AS n FROM docs${where}`),
3387
+ bytes: one(`SELECT sum(length(vector)) AS n FROM chunks${where}`)
3388
+ };
3389
+ }
3390
+ /** This method closes the file. A second close does nothing. */
3391
+ close() {
3392
+ if (this.closed) return;
3393
+ this.closed = true;
3394
+ this.statements.clear();
3395
+ this.matrices.clear();
3396
+ this.db.close();
3397
+ }
3398
+ };
3399
+ //#endregion
2335
3400
  //#region src/watch.ts
2336
3401
  /**
2337
3402
  * A watch list keeps the pages that the crawler checks again on a schedule. `watch.run` fetches the
@@ -2349,7 +3414,7 @@ var SqliteHostStateStore = class {
2349
3414
  * - The list is in `watch.sqlite` in the store, so it stays after a restart. A run claims each entry
2350
3415
  * with a lease in that file, so two processes do not check one entry two times.
2351
3416
  *
2352
- * The package has no daemon. A scheduler of the operator starts `watch.run`.
3417
+ * A scheduler of the operator starts `watch.run`, or the daemon (`startDaemon`) starts it on a schedule.
2353
3418
  */
2354
3419
  /** This value is the version of the schema of `watch.sqlite`. */
2355
3420
  const WATCH_SCHEMA = 1;
@@ -2619,7 +3684,7 @@ const REFUSALS = /* @__PURE__ */ new Set([
2619
3684
  "payment-required"
2620
3685
  ]);
2621
3686
  /** This function gives the fingerprint of a version: a hash of its title, its text and its JSON-LD. */
2622
- const watchFingerprint = (version) => sha256(stableStringify([
3687
+ const watchFingerprint = (version) => sha256$1(stableStringify([
2623
3688
  version.title,
2624
3689
  version.text,
2625
3690
  version.meta["jsonld"] ?? null
@@ -2836,6 +3901,10 @@ const runWatch = (runtime, source, options = {}) => {
2836
3901
  const deadline = startedAt + budgetMs;
2837
3902
  for (;;) {
2838
3903
  if (stopping === void 0 && abort.signal.aborted) stopping = "cancelled";
3904
+ if (stopping === void 0 && options.drain?.aborted === true) {
3905
+ stopping = "cancelled";
3906
+ reason = "the run stopped for a shutdown: the checks in progress ended first";
3907
+ }
2839
3908
  if (stopping === void 0 && now() >= deadline) stopping = "budget";
2840
3909
  while (stopping === void 0 && inflight.size < WATCH_LIMITS.hostsAtOnce && started < maxPages) {
2841
3910
  const claimed = store.claim(now(), owner, Math.max(now(), deadline) + WATCH_LIMITS.leaseMs, [...inflight.keys()]);
@@ -2926,7 +3995,8 @@ const STORE_PARTS = [
2926
3995
  "blobs",
2927
3996
  "index.sqlite",
2928
3997
  "hosts.sqlite",
2929
- "watch.sqlite"
3998
+ "watch.sqlite",
3999
+ "vectors.sqlite"
2930
4000
  ];
2931
4001
  /**
2932
4002
  * This function makes the folder of a store for the owner only, where the system has modes. It
@@ -2960,6 +4030,9 @@ const createRuntime = (options) => {
2960
4030
  if (dir === void 0 && (options.log === void 0 || options.blobs === void 0 || options.index === void 0)) throw new Error("a runtime needs dir, or a log, a blob store and an index");
2961
4031
  const contact = options.contact;
2962
4032
  if (contact !== void 0 && !/^[\t -~]*$/.test(contact)) throw new Error(`contact "${contact}" has a character that an HTTP header cannot hold: use printable ASCII`);
4033
+ const extractors = new Extractors();
4034
+ for (const [recipe, extractor] of Object.entries(options.extractors ?? {})) extractors.register(recipe, extractor);
4035
+ for (const recipe of options.recipes ?? []) registerRecipe(extractors, recipe);
2963
4036
  if (dir !== void 0) guardStore(dir);
2964
4037
  const owned = [];
2965
4038
  const log = options.log ?? new CaptureLog(join(dir, "captures"));
@@ -3003,8 +4076,6 @@ const createRuntime = (options) => {
3003
4076
  onEvent: options.onEvent,
3004
4077
  signer: options.signer
3005
4078
  });
3006
- const extractors = new Extractors();
3007
- for (const [recipe, extractor] of Object.entries(options.extractors ?? {})) extractors.register(recipe, extractor);
3008
4079
  let closing;
3009
4080
  return {
3010
4081
  crawler,
@@ -3057,6 +4128,26 @@ const openCrawler = (options) => {
3057
4128
  return watches;
3058
4129
  };
3059
4130
  const clockNow = () => Date.parse(runtime.now());
4131
+ const embedder = options.embedder;
4132
+ let vectors;
4133
+ let search;
4134
+ const hybridSearch = async () => {
4135
+ await live();
4136
+ if (embedder === void 0) throw new Error(NO_EMBEDDER);
4137
+ vectors ??= new VectorStore(join(options.dir, "vectors.sqlite"));
4138
+ search ??= createHybridSearch(runtime, {
4139
+ embedder,
4140
+ store: vectors
4141
+ });
4142
+ return search;
4143
+ };
4144
+ const hybrid = embedder === void 0 ? void 0 : {
4145
+ embedder,
4146
+ find: async (q, findOptions) => (await hybridSearch()).find(q, findOptions),
4147
+ rankWithin: async (q, docs, rankOptions) => (await hybridSearch()).rankWithin(q, docs, rankOptions),
4148
+ embed: async (embedOptions) => (await hybridSearch()).embed(embedOptions)
4149
+ };
4150
+ let closing;
3060
4151
  return {
3061
4152
  runtime,
3062
4153
  async fetch(url, fetchOptions = {}) {
@@ -3076,7 +4167,22 @@ const openCrawler = (options) => {
3076
4167
  },
3077
4168
  async find(q, findOptions = {}) {
3078
4169
  await live();
3079
- return findDocuments(runtime, q, findOptions);
4170
+ const { rank, ...rest } = findOptions;
4171
+ if (rank === "hybrid") return (await hybridSearch()).find(q, rest);
4172
+ return findDocuments(runtime, q, {
4173
+ ...rest,
4174
+ ...rank === void 0 ? {} : { rank }
4175
+ });
4176
+ },
4177
+ hybrid,
4178
+ async embed(embedOptions = {}) {
4179
+ return (await hybridSearch()).embed(embedOptions);
4180
+ },
4181
+ async rankWithin(q, docs, rankOptions = {}) {
4182
+ await live();
4183
+ const { rank, ...rest } = rankOptions;
4184
+ if (rank === "hybrid") return (await hybridSearch()).rankWithin(q, docs, rest);
4185
+ return runtime.index.rankWithin(q, docs, rank === void 0 ? {} : { rank });
3080
4186
  },
3081
4187
  async read(id, readOptions = {}) {
3082
4188
  await live();
@@ -3147,15 +4253,20 @@ const openCrawler = (options) => {
3147
4253
  });
3148
4254
  }
3149
4255
  },
3150
- async close() {
4256
+ close() {
3151
4257
  closed = true;
3152
- await ready;
3153
- await runtime.close();
3154
- watches?.close();
4258
+ closing ??= (async () => {
4259
+ await ready;
4260
+ await runtime.close();
4261
+ watches?.close();
4262
+ vectors?.close();
4263
+ await embedder?.close?.();
4264
+ })();
4265
+ return closing;
3155
4266
  }
3156
4267
  };
3157
4268
  };
3158
4269
  //#endregion
3159
- export { readSitemap as _, open_exports as a, WatchStore as c, CRAWL_LIMITS as d, crawlSite as f, feedDate as g, SITEMAP_LIMITS as h, openCrawler as i, runWatch as l, discoverSite as m, defaultUserAgent as n, WATCH_LIMITS as o, DISCOVER_LIMITS as p, isMailbox as r, WATCH_SCHEMA as s, createRuntime as t, SqliteHostStateStore as u, DocIndex as v, FAST_RANK_SHARE as y };
4270
+ export { NO_EMBEDDER as A, DISCOVER_LIMITS as C, readSitemap as D, feedDate as E, DocIndex as O, crawlSite as S, SITEMAP_LIMITS as T, estimateTokens as _, open_exports as a, SqliteHostStateStore as b, WatchStore as c, VectorStore as d, createHybridSearch as f, chunkTokensOf as g, chunkText as h, openCrawler as i, FAST_RANK_SHARE as k, runWatch as l, HYBRID_DEFAULTS as m, defaultUserAgent as n, WATCH_LIMITS as o, CHUNK_LIMITS as p, isMailbox as r, WATCH_SCHEMA as s, createRuntime as t, VECTORS_SCHEMA as u, fuseRanks as v, discoverSite as w, CRAWL_LIMITS as x, unitVector as y };
3160
4271
 
3161
- //# sourceMappingURL=open-DnrWgkC0.mjs.map
4272
+ //# sourceMappingURL=open-CbpSCEZc.mjs.map