@bootstrapware/answers 0.2.11 → 0.2.13

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/AGENTS.md CHANGED
@@ -227,18 +227,18 @@ Questions are limited to 1,000 Unicode code points. A longer draft stays in the
227
227
  Hosted indexes the main body of each page, split by headings; navigation, headers, footers, and cookie banners are dropped. The model writes a short answer from the best-matching passages and must cite them. It does not use outside knowledge. `list_answers_capabilities` returns `sourceAdvice` with the same rules.
228
228
 
229
229
  1. Make sure the fact is stated in a page body, not only implied by a homepage card. A pricing page, a support page, and one page per product work well.
230
- 2. Keep each figure next to what it prices (`Hosted is $19.99/month`). An answer whose price or number is not in a cited passage is rejected unless it follows from cited figures (a single-step percent-off or percent-of of a cited amount, rounded to the nearest cent, or a sequential stack total with each line rounded to the nearest cent).
230
+ 2. Keep each figure next to what it prices (`Hosted is $19.99/month`). An answer whose price or number is not in a cited passage is rejected unless it follows from cited figures (a single-step percent-off or percent-of of a cited amount, rounded to the nearest cent, or a sequential stack total with each line rounded to the nearest cent). The same number written with a comma, dot, space, or apostrophe as the thousands separator is that figure (`2,500`, `2.500`, and `2 500`). A comma with one or two decimal digits is that decimal (`19,99` and `$19.99`).
231
231
  3. After `add_answers_url_source`, wait for the index job, then use the dashboard retrieval check to see which passages a question finds. Ask through the widget with a test key to see the written answer.
232
232
  4. For facts you want stated exactly (prices, refund policy, support contact), add a custom Q&A in the dashboard. Custom Q&A is always considered, and a matching entry is answered first. Hosted entries are indexed on save, usually within about a minute; the dashboard knowledge list shows when each is indexed. With BYO, an entry changed through `upsertQa`, `removeQa`, or `setQa` applies from the next question.
233
233
  5. When the pages are silent on a yes/no question (certification, compliance, encryption), the answer says the site does not state it and gives the support contact if a source has one. Add the support contact as a custom Q&A so that works. Unanswered with the fallback is the correct outcome when nothing covers the question. Add a URL or a custom Q&A. Do not send the question through MCP.
234
234
 
235
235
  ## Knowledge, grounding, and provider
236
236
 
237
- Hosted knowledge kinds are website root, sitemap, single public URL, PDF, DOCX, TXT, Markdown, and custom Q&A (a question and its answer). MCP can add a public URL and reindex it. Files, custom Q&A, conversations, and unanswered question text stay on the dashboard.
237
+ Hosted knowledge kinds are website root, sitemap, single public URL, PDF, DOCX, TXT, Markdown, and custom Q&A (a question and its answer). A website or sitemap crawl also fetches `/llms.txt` and `/llms-full.txt` on that host when `robots.txt` allows them. Text and Markdown are indexed. A missing file is ignored and is not an index error. BYO `refresh()` does the same for each origin in `urls` or the sitemap. These agent indexes stay below product pages when both match a question. MCP can add a public URL and reindex it. Files, custom Q&A, conversations, and unanswered question text stay on the dashboard.
238
238
 
239
239
  Every Hosted source is visitor-readable. Do not upload private runbooks or secrets. Crawl is public `http` and `https` only. Private hosts are rejected. `robots.txt` is honored. A failed crawl leaves the last good index in place. A page that is only navigation chrome is rejected and does not become a citation.
240
240
 
241
- Retrieval combines keyword and embedding search over the active index. When the question names a product whose pages live under a URL path, those pages are preferred. Custom Q&A is always considered, and a matching entry is answered first. The model answers in 1 to 4 sentences in the visitor's language, citing passages it was given; citations are ids from the retrieved index, and the model does not invent URLs. A draft that declines, cites an unretrieved passage, states a figure that is not in a cited passage and does not follow from cited figures by the rules above, or writes code not quoted from a passage is retried once and then returns `unanswered` with the configured fallback. A derived total that depends on billing order is rejected unless the visitor set the order, the answer says order matters, or the answer itemizes list and discounted line items that sum to the total. Off-topic questions and prompt-injection attempts are declined before retrieval. How-to questions get the documented steps and a citation to the docs page; the model does not write new code. There is no model-memory fallback. Earlier turns are used only for clear follow-ups ("what about Chat?"); a standalone question is answered fresh.
241
+ Retrieval combines keyword and embedding search over the active index. When the question names a product whose pages live under a URL path, those pages are preferred, and passages on that path that mention the distinctive terms in the question are reserved for the model. Custom Q&A is always considered, and a matching entry is answered first. The model answers in 1 to 4 sentences in the visitor's language, citing passages it was given; citations are ids from the retrieved index, and the model does not invent URLs. A draft that declines, cites an unretrieved passage, states a figure that is not in a cited passage and does not follow from cited figures by the rules above, or writes code not quoted from a passage is retried once and then returns `unanswered` with the configured fallback. A derived total that depends on billing order is rejected unless the visitor set the order, the answer says order matters, or the answer itemizes list and discounted line items that sum to the total. Off-topic questions and prompt-injection attempts are declined before retrieval. How-to questions get the documented steps and a citation to the docs page; the model does not write new code. There is no model-memory fallback. Earlier turns are used only for clear follow-ups ("what about Chat?"); a standalone question is answered fresh.
242
242
 
243
243
  Unanswered observations store normalized question text even when conversation history is off, so the dashboard can group them. MCP does not return that text.
244
244
 
package/CHANGELOG.md CHANGED
@@ -1,5 +1,16 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.2.13
4
+
5
+ - Grounding treats a thousands group as one figure. `2,500`, `2.500`, `2 500` (including a narrow no-break space), and `2'500` are the same number, and `19,99` is the same decimal as `19.99`. A one-digit group no longer matches a different grouped number that happens to end in the same digits (`1,500` is not `2,500`).
6
+ - Citation markers in an answer are removed when the comma inside the brackets is an Arabic comma, an ideographic comma, or a fullwidth comma (`[2، 5]`), including Arabic-Indic digits.
7
+ - A website or sitemap crawl, and BYO `refresh()` for each origin already being fetched, also indexes `/llms.txt` and `/llms-full.txt` when the host publishes them as text or Markdown. A missing file is ignored and is not listed as a skipped page.
8
+
9
+ ## 0.2.12
10
+
11
+ - Hosted and BYO retrieval now reserve named-path passages that mention the distinctive terms in the question (`stream` on an Answers page), so a generic product-page crowd cannot hide a documented yes/no.
12
+ - The custom Q&A lane no longer treats “does X support…” as a match for a contact-support entry, and embedding-only Q&A needs a closer match than before.
13
+
3
14
  ## 0.2.11
4
15
 
5
16
  - Retrieval prefers pages under a product path the question named, so another product's similar vocabulary cannot crowd out a documented yes/no (Chat SSE vs an Answers capability page).
package/README.md CHANGED
@@ -59,7 +59,7 @@ export const POST = (request: Request) => answers.handler(request);
59
59
 
60
60
  The index is built on the first question and kept in memory. Call `answers.refresh()` after your content changes (it returns `{ pages, chunks, skipped }`; `skipped` lists every page left out and why, including a URL that redirects to a different page, and `onRefresh` receives the same report after the automatic first build), or save `answers.index.toJSON()` and pass it back as `snapshot` to skip re-embedding on restart. Pass `retrieve` to use your own vector store instead.
61
61
 
62
- Answers come only from the indexed passages. A draft that cites something it did not retrieve, or states a price or number that is not in a cited passage and does not follow from cited figures (a single-step percent-off or percent-of of a cited amount, rounded to the nearest cent, or a sequential stack total with each line rounded to the nearest cent), is rejected and the visitor gets the fallback. A stack total that depends on billing order is rejected unless the visitor set the order, the answer says order matters, or the answer itemizes lines that sum to that total. Custom Q&A is always considered, and a matching entry is answered first. When the sources are silent on a yes/no question, the answer says the site does not state it (with the support contact if a source has one). How-to questions cite the docs page; no new code is written.
62
+ Answers come only from the indexed passages. `refresh()` also fetches `/llms.txt` and `/llms-full.txt` for each origin in `urls` or the sitemap when the host publishes them as text or Markdown, and ignores a missing file. A draft that cites something it did not retrieve, or states a price or number that is not in a cited passage and does not follow from cited figures (a single-step percent-off or percent-of of a cited amount, rounded to the nearest cent, or a sequential stack total with each line rounded to the nearest cent), is rejected and the visitor gets the fallback. The same number with a comma, dot, space, or apostrophe as the thousands separator counts as the figure in the source, and a comma decimal such as `19,99` counts as `$19.99`. A stack total that depends on billing order is rejected unless the visitor set the order, the answer says order matters, or the answer itemizes lines that sum to that total. Custom Q&A is always considered, and a matching entry is answered first. When the sources are silent on a yes/no question, the answer says the site does not state it (with the support contact if a source has one). How-to questions cite the docs page; no new code is written.
63
63
 
64
64
  Custom Q&A can change at runtime without a refresh: call `await answers.upsertQa({ id, question, answer })`, `answers.removeQa(id)`, or `answers.setQa([...])` from your save handler. Only the changed entry is embedded and it answers on the next question. Relevant custom Q&A are always offered to the model, even when many page passages outrank them, and that includes setups that pass their own `retrieve`.
65
65
 
package/dist/server.d.ts CHANGED
@@ -330,6 +330,9 @@ type RefreshReport = {
330
330
  };
331
331
  /** Same page: host (ignoring www and scheme) and path, ignoring a trailing slash, query, and fragment. */
332
332
  declare function samePage(left: string, right: string): boolean;
333
+ /** `/llms.txt` and `/llms-full.txt` on an origin already being indexed. */
334
+ declare function agentIndexUrls(origin: string): string[];
335
+ declare function isAgentIndexUrl(raw: string): boolean;
333
336
  /**
334
337
  * BYO Answers backend. Runs on your server with your OpenAI key; questions and knowledge stay on
335
338
  * your infrastructure. Pair with createByoAdapter in the browser.
@@ -395,6 +398,33 @@ declare function tokenize(text: string): string[];
395
398
  declare function firstPathSegment(url?: string | null): string | null;
396
399
  /** Path segments the visitor named that also appear as product roots in the candidate URLs. */
397
400
  declare function namedPathQuery(query: string, urls: Array<string | null | undefined>): Set<string>;
401
+ /**
402
+ * Product or subject the visitor named ("Does Answers support…", capitalized "Answers").
403
+ * Used so the product name is not treated as the distinctive evidence term.
404
+ */
405
+ declare function subjectTokens(query: string): Set<string>;
406
+ /** Auxiliaries in "does X support/offer Y?" — they match contact-support Q&A, not the capability. */
407
+ declare function capabilityVerbs(query: string): Set<string>;
408
+ /** Query tokens that should pull the passage that actually states the fact (not the product name). */
409
+ declare function distinctiveTokens(query: string, named?: ReadonlySet<string>): string[];
410
+ declare function contentTokenOverlap(text: string, tokens: readonly string[]): number;
411
+ type EvidenceRow = {
412
+ id: string;
413
+ url?: string | null;
414
+ canonical_url?: string | null;
415
+ title?: string | null;
416
+ text?: string;
417
+ excerpt?: string;
418
+ kind?: string;
419
+ };
420
+ /** How many named-path passages that mention the distinctive terms are reserved for the model. */
421
+ declare const EVIDENCE_LANE = 3;
422
+ /**
423
+ * Pages under a product path the question named that also mention the distinctive terms
424
+ * (`stream` on an Answers grounding page). Generic product pages and another product's
425
+ * similar vocabulary do not qualify.
426
+ */
427
+ declare function selectNamedEvidence<T extends EvidenceRow>(query: string, rows: readonly T[], lane?: number): T[];
398
428
  /**
399
429
  * Sort bucket: 0 custom Q&A, 1 pages under a product path the question named, 2 the rest.
400
430
  * Empty `named` keeps page order (everyone 1 except Q&A).
@@ -403,7 +433,7 @@ declare function namedPathBucket(url: string | null | undefined, named: Readonly
403
433
  /** How many custom Q&A entries are always offered to the model when they look relevant. */
404
434
  declare const QA_LANE = 2;
405
435
  /** Embedding similarity at which a custom Q&A counts as relevant even with no shared words. */
406
- declare const QA_LANE_MIN_SIMILARITY = 0.3;
436
+ declare const QA_LANE_MIN_SIMILARITY = 0.55;
407
437
  type QaLaneCandidate = {
408
438
  id: string;
409
439
  title: string;
@@ -421,4 +451,4 @@ declare function withQaLane<T extends {
421
451
  id: string;
422
452
  }>(ranked: readonly T[], lane: readonly T[], limit: number): T[];
423
453
 
424
- export { type AnswerCitation, type AnswerDeps, type AnswerInput, type AnswerOutput, type AnswerReason, type AnswersBackend, type AnswersBackendOptions, CHUNK_MAX_CHARS, CHUNK_TARGET_CHARS, type ChatMessage, type Chunk, DEFAULT_ANSWER_MODEL, DEFAULT_EMBEDDING_MODEL, type DocumentInput, EMBEDDING_DIMENSIONS, type ExtractedPage, FALLBACK_ANSWER_MODEL, type IndexedEntry, type LlmClient, LlmError, MemoryIndex, type MemoryIndexSnapshot, type OpenAiOptions, type PageSection, type Passage, QA_LANE, QA_LANE_MIN_SIMILARITY, type QaInput, type QaLaneCandidate, type ReasoningEffort, type RefreshReport, type RetrieveQuery, type Retriever, type SkippedPage, type Turn, answerMessages, answerQuestion, authFailure, chunkPage, citationsFor, cosine, createAnswersBackend, createOpenAiClient, decodeCfEmail, decodeEntities, decodeProtectedEmails, extractPage, extractText, figuresIn, firstPathSegment, inferSources, isReasoningModel, matchingQa, namedPathBucket, namedPathQuery, prepareMessages, reciprocalRankFusion, samePage, selectQaLane, sourceBlock, stem, tokenize, verifyAnswer, withQaLane };
454
+ export { type AnswerCitation, type AnswerDeps, type AnswerInput, type AnswerOutput, type AnswerReason, type AnswersBackend, type AnswersBackendOptions, CHUNK_MAX_CHARS, CHUNK_TARGET_CHARS, type ChatMessage, type Chunk, DEFAULT_ANSWER_MODEL, DEFAULT_EMBEDDING_MODEL, type DocumentInput, EMBEDDING_DIMENSIONS, EVIDENCE_LANE, type EvidenceRow, type ExtractedPage, FALLBACK_ANSWER_MODEL, type IndexedEntry, type LlmClient, LlmError, MemoryIndex, type MemoryIndexSnapshot, type OpenAiOptions, type PageSection, type Passage, QA_LANE, QA_LANE_MIN_SIMILARITY, type QaInput, type QaLaneCandidate, type ReasoningEffort, type RefreshReport, type RetrieveQuery, type Retriever, type SkippedPage, type Turn, agentIndexUrls, answerMessages, answerQuestion, authFailure, capabilityVerbs, chunkPage, citationsFor, contentTokenOverlap, cosine, createAnswersBackend, createOpenAiClient, decodeCfEmail, decodeEntities, decodeProtectedEmails, distinctiveTokens, extractPage, extractText, figuresIn, firstPathSegment, inferSources, isAgentIndexUrl, isReasoningModel, matchingQa, namedPathBucket, namedPathQuery, prepareMessages, reciprocalRankFusion, samePage, selectNamedEvidence, selectQaLane, sourceBlock, stem, subjectTokens, tokenize, verifyAnswer, withQaLane };
package/dist/server.js CHANGED
@@ -504,11 +504,59 @@ function namedPathQuery(query, urls) {
504
504
  }
505
505
  const named = /* @__PURE__ */ new Set();
506
506
  if (!present.size) return named;
507
- for (const token of new Set(tokenize(query))) {
507
+ for (const token of /* @__PURE__ */ new Set([...tokenize(query), ...subjectTokens(query)])) {
508
508
  if (present.has(token)) named.add(token);
509
509
  }
510
510
  return named;
511
511
  }
512
+ function subjectTokens(query) {
513
+ const out = /* @__PURE__ */ new Set();
514
+ const subject = query.match(/\b(?:does|do|can|is|are)\s+([A-Za-z][\w-]*)\b/i);
515
+ if (subject) {
516
+ const [token] = tokenize(subject[1]);
517
+ if (token) out.add(token);
518
+ }
519
+ for (const word of query.match(/\b[A-Z][A-Za-z]{2,}\b/g) ?? []) {
520
+ const [token] = tokenize(word);
521
+ if (token) out.add(token);
522
+ }
523
+ return out;
524
+ }
525
+ function capabilityVerbs(query) {
526
+ const out = /* @__PURE__ */ new Set();
527
+ if (/\b(?:does|do|can)\s+[A-Za-z][\w-]*\s+(?:support|offer|have|include|allow)\b/i.test(query)) {
528
+ for (const verb of ["support", "offer", "have", "include", "allow"]) out.add(stem(verb));
529
+ }
530
+ return out;
531
+ }
532
+ function distinctiveTokens(query, named = /* @__PURE__ */ new Set()) {
533
+ const skip = /* @__PURE__ */ new Set([...named, ...subjectTokens(query), ...capabilityVerbs(query)]);
534
+ return [...new Set(tokenize(query))].filter((token) => token.length >= 4 && !skip.has(token));
535
+ }
536
+ function contentTokenOverlap(text, tokens) {
537
+ if (!tokens.length) return 0;
538
+ const own = new Set(tokenize(text));
539
+ let n = 0;
540
+ for (const token of tokens) if (own.has(token)) n += 1;
541
+ return n;
542
+ }
543
+ var EVIDENCE_LANE = 3;
544
+ function selectNamedEvidence(query, rows, lane = EVIDENCE_LANE) {
545
+ if (!rows.length || lane <= 0) return [];
546
+ const pages = rows.filter((row) => row.kind !== "qa" && row.kind !== "manual");
547
+ const named = namedPathQuery(
548
+ query,
549
+ pages.map((row) => row.url ?? row.canonical_url)
550
+ );
551
+ const terms = distinctiveTokens(query, named);
552
+ if (!terms.length) return [];
553
+ return pages.map((row) => {
554
+ const url = row.url ?? row.canonical_url;
555
+ const overlap = contentTokenOverlap(`${row.title ?? ""} ${row.excerpt ?? row.text ?? ""}`, terms);
556
+ const bucket = namedPathBucket(url, named);
557
+ return { row, overlap, bucket };
558
+ }).filter((item) => item.overlap >= 1 && (!named.size || item.bucket === 1)).sort((a, b) => b.overlap - a.overlap).slice(0, lane).map((item) => item.row);
559
+ }
512
560
  function namedPathBucket(url, named, qa = false) {
513
561
  if (qa) return 0;
514
562
  if (!named.size) return 1;
@@ -516,7 +564,7 @@ function namedPathBucket(url, named, qa = false) {
516
564
  return seg && named.has(seg) ? 1 : 2;
517
565
  }
518
566
  var QA_LANE = 2;
519
- var QA_LANE_MIN_SIMILARITY = 0.3;
567
+ var QA_LANE_MIN_SIMILARITY = 0.55;
520
568
  function selectQaLane(questions, candidates, lane = QA_LANE) {
521
569
  if (!candidates.length || lane <= 0) return [];
522
570
  const words = (text) => new Set(tokenize(text));
@@ -525,12 +573,18 @@ function selectQaLane(questions, candidates, lane = QA_LANE) {
525
573
  for (const t of titles) for (const w of t) df.set(w, (df.get(w) ?? 0) + 1);
526
574
  const common = Math.max(2, Math.ceil(candidates.length / 2));
527
575
  const asked = new Set(questions.flatMap((q) => [...words(q)]).filter((w) => (df.get(w) ?? 0) < common || candidates.length < 3));
576
+ const weak = new Set(questions.flatMap((q) => [...capabilityVerbs(q), ...subjectTokens(q)]));
528
577
  return candidates.map((c, i) => {
529
578
  let shared = 0;
530
- for (const w of asked) if (titles[i].has(w)) shared += 1;
579
+ let strong = 0;
580
+ for (const w of asked) {
581
+ if (!titles[i].has(w)) continue;
582
+ shared += 1;
583
+ if (!weak.has(w)) strong += 1;
584
+ }
531
585
  const sim = typeof c.similarity === "number" && Number.isFinite(c.similarity) ? c.similarity : 0;
532
- return { id: c.id, shared, sim, score: shared + sim * 2 };
533
- }).filter((x) => x.shared >= 1 || x.sim >= QA_LANE_MIN_SIMILARITY).sort((a, b) => b.score - a.score).slice(0, lane).map((x) => x.id);
586
+ return { id: c.id, shared, strong, sim, score: strong + shared * 0.25 + sim * 2 };
587
+ }).filter((x) => x.strong >= 1 || x.sim >= QA_LANE_MIN_SIMILARITY).sort((a, b) => b.score - a.score).slice(0, lane).map((x) => x.id);
534
588
  }
535
589
  function withQaLane(ranked, lane, limit) {
536
590
  const seen = /* @__PURE__ */ new Set();
@@ -679,14 +733,21 @@ var MemoryIndex = class _MemoryIndex {
679
733
  const lexical = this.lexical(`${query.text} ${query.original}`, depth).map(String);
680
734
  const vector = query.embedding ? this.vector(query.embedding, depth).map(String) : [];
681
735
  const fused = reciprocalRankFusion([lexical, vector], 60, [1, 1.3]);
682
- const named = namedPathQuery(`${query.text} ${query.original}`, this.entries.map((e) => e.url));
736
+ const asked = `${query.text} ${query.original}`;
737
+ const named = namedPathQuery(asked, this.entries.map((e) => e.url));
738
+ const terms = distinctiveTokens(asked, named);
683
739
  const boosted = fused.map(({ id, score }) => {
684
740
  const entry = this.entries[Number(id)];
685
741
  const weight = entry.kind === "qa" ? 1.25 : 1;
686
- return { id: Number(id), score: score * weight, bucket: namedPathBucket(entry.url, named, entry.kind === "qa") };
687
- }).sort((a, b) => a.bucket - b.bucket || b.score - a.score);
742
+ const overlap = entry.kind === "qa" ? 0 : contentTokenOverlap(`${entry.title} ${entry.content}`, terms);
743
+ return { id: Number(id), score: score * weight, overlap, bucket: namedPathBucket(entry.url, named, entry.kind === "qa") };
744
+ }).sort((a, b) => a.bucket - b.bucket || b.overlap - a.overlap || b.score - a.score);
688
745
  const ranked = boosted.slice(0, query.limit).map(({ id }) => this.passage(id));
689
- return withQaLane(ranked, this.qaLane(query), query.limit);
746
+ const evidence = selectNamedEvidence(
747
+ asked,
748
+ this.entries.map((e, i) => ({ id: String(i), url: e.url, title: e.title, text: e.content, kind: e.kind }))
749
+ ).map((row) => this.passage(Number(row.id)));
750
+ return withQaLane(withQaLane(ranked, evidence, query.limit), this.qaLane(query), query.limit);
690
751
  }
691
752
  };
692
753
  function qaEntry(item, fallbackId) {
@@ -916,10 +977,96 @@ ${sourceBlock(passages)}`,
916
977
  { role: "user", content: user }
917
978
  ];
918
979
  }
919
- var FIGURE = /(?:[$€£]\s?\d[\d,]*(?:\.\d+)?)|(?:\b\d[\d,]*(?:\.\d+)?\s?%)|(?:\b\d+\.\d+\b)|(?:\b\d{2,}[\d,]*\b)/g;
920
- var normalizeFigure = (f) => f.replace(/[\s,]/g, "").replace(/^[$€£]/, "").replace(/%$/, "").replace(/\.0+$/, "");
980
+ function isGroupSpace(ch) {
981
+ return ch === "\xA0" || ch === "\u202F" || ch === "\u2007" || ch === "\u2009";
982
+ }
983
+ function stripInt(digits) {
984
+ return digits.replace(/^0+(?=\d)/, "") || "0";
985
+ }
986
+ function decimalKey(intDigits, frac) {
987
+ return { key: `${stripInt(intDigits)}.${frac}`.replace(/\.0+$/, ""), twoPlace: frac.length === 2 };
988
+ }
989
+ function canonicalBody(body) {
990
+ const noSpace = [...body].map((ch) => isGroupSpace(ch) ? " " : ch).join("").replace(/[’']/g, "").replace(/ /g, "");
991
+ if (!/^\d+(?:[.,]\d+)*$/.test(noSpace)) return null;
992
+ if (!noSpace.includes(".") && !noSpace.includes(",")) return { key: stripInt(noSpace), twoPlace: false };
993
+ if (noSpace.includes(".") && noSpace.includes(",")) {
994
+ const dec = noSpace.lastIndexOf(".") > noSpace.lastIndexOf(",") ? "." : ",";
995
+ const at = noSpace.lastIndexOf(dec);
996
+ const intDigits2 = noSpace.slice(0, at).replace(/[.,]/g, "");
997
+ const frac2 = noSpace.slice(at + 1);
998
+ if (!/^\d+$/.test(intDigits2) || !/^\d+$/.test(frac2)) return null;
999
+ return decimalKey(intDigits2, frac2);
1000
+ }
1001
+ const parts = noSpace.split(noSpace.includes(".") ? "." : ",");
1002
+ if (parts.every((part, index) => /^\d+$/.test(part) && (index === 0 || part.length === 3))) {
1003
+ return { key: stripInt(parts.join("")), twoPlace: false };
1004
+ }
1005
+ const frac = parts[parts.length - 1] ?? "";
1006
+ const intDigits = parts.slice(0, -1).join("");
1007
+ if (!/^\d+$/.test(intDigits) || !/^\d+$/.test(frac)) return null;
1008
+ return decimalKey(intDigits, frac);
1009
+ }
1010
+ function parseFigure(raw) {
1011
+ let body = raw.trim();
1012
+ let kind = "other";
1013
+ if (/^[$€£]/.test(body)) {
1014
+ kind = "money";
1015
+ body = body.slice(1).trimStart();
1016
+ }
1017
+ if (body.endsWith("%")) {
1018
+ kind = "percent";
1019
+ body = body.slice(0, -1).trimEnd();
1020
+ }
1021
+ const parsed = canonicalBody(body);
1022
+ if (!parsed) return null;
1023
+ if (kind === "other" && parsed.twoPlace) kind = "money";
1024
+ if (kind === "other" && /^\d$/.test(parsed.key)) return null;
1025
+ return { key: parsed.key, kind };
1026
+ }
1027
+ function scanFigures(text) {
1028
+ const out = [];
1029
+ const groupSep = /[ \u00A0\u202F\u2007\u2009'’.,]/;
1030
+ for (let i = 0; i < text.length; i += 1) {
1031
+ const ch = text[i] ?? "";
1032
+ const prev = text[i - 1] ?? "";
1033
+ if (/\d/.test(ch) && /[\dA-Za-z]/.test(prev)) continue;
1034
+ if (!/\d/.test(ch) && !/[$€£]/.test(ch)) continue;
1035
+ let cursor = i;
1036
+ if (/[$€£]/.test(text[cursor] ?? "")) {
1037
+ cursor += 1;
1038
+ if (text[cursor] === " ") cursor += 1;
1039
+ }
1040
+ if (!/\d/.test(text[cursor] ?? "")) continue;
1041
+ while (cursor < text.length && /\d/.test(text[cursor] ?? "")) cursor += 1;
1042
+ while (cursor < text.length && groupSep.test(text[cursor] ?? "")) {
1043
+ const sep = text[cursor] ?? "";
1044
+ let next = cursor + 1;
1045
+ let digits = 0;
1046
+ while (next < text.length && /\d/.test(text[next] ?? "")) {
1047
+ digits += 1;
1048
+ next += 1;
1049
+ }
1050
+ if (!digits) break;
1051
+ const spaceLike = sep === " " || isGroupSpace(sep) || sep === "'" || sep === "\u2019";
1052
+ if (spaceLike && digits !== 3) break;
1053
+ cursor = next;
1054
+ }
1055
+ let end = cursor;
1056
+ if (text[end] === "%") end += 1;
1057
+ else if (text[end] === " " && text[end + 1] === "%") end += 2;
1058
+ const parsed = parseFigure(text.slice(i, end));
1059
+ if (parsed) out.push(parsed);
1060
+ if (end > i) i = end - 1;
1061
+ }
1062
+ return out;
1063
+ }
921
1064
  function figuresIn(text) {
922
- return [...new Set((text.match(FIGURE) ?? []).map(normalizeFigure))].filter(Boolean);
1065
+ const out = [];
1066
+ for (const figure of scanFigures(text)) {
1067
+ if (!out.includes(figure.key)) out.push(figure.key);
1068
+ }
1069
+ return out;
923
1070
  }
924
1071
  function figureSupported(figure, haystack) {
925
1072
  const figures = new Set(figuresIn(haystack));
@@ -930,19 +1077,15 @@ function roundCents(n) {
930
1077
  return Math.round(n * 100) / 100;
931
1078
  }
932
1079
  function figureKey(n) {
933
- return normalizeFigure(String(roundCents(n)));
1080
+ return String(roundCents(n)).replace(/\.0+$/, "");
934
1081
  }
935
1082
  function sourceFigures(text) {
936
1083
  const out = [];
937
- const re = /(?:[$€£]\s?(\d[\d,]*(?:\.\d+)?))|(?:\b(\d[\d,]*(?:\.\d+)?)\s?%)|(?:\b(\d+\.\d+)\b)|(?:\b(\d{2,}[\d,]*)\b)/g;
938
- let m;
939
- while (m = re.exec(text)) {
940
- if (m[1]) out.push({ num: Number(m[1].replace(/,/g, "")), kind: "money" });
941
- else if (m[2]) out.push({ num: Number(m[2].replace(/,/g, "")), kind: "percent" });
942
- else if (m[3]) out.push({ num: Number(m[3]), kind: /\.\d{2}$/.test(m[3]) ? "money" : "other" });
943
- else if (m[4]) out.push({ num: Number(m[4].replace(/,/g, "")), kind: "other" });
1084
+ for (const figure of scanFigures(text)) {
1085
+ const num = Number(figure.key);
1086
+ if (Number.isFinite(num)) out.push({ num, kind: figure.kind });
944
1087
  }
945
- return out.filter((f) => Number.isFinite(f.num));
1088
+ return out;
946
1089
  }
947
1090
  function uniqueNums(values) {
948
1091
  const seen = /* @__PURE__ */ new Set();
@@ -1224,8 +1367,9 @@ function citationsFor(cited, passages, answer, max) {
1224
1367
  }
1225
1368
  return out;
1226
1369
  }
1370
+ var CITATION_MARK = /\s*\[[\d\u0660-\u0669\u06F0-\u06F9]+(?:\s*[,،、,]\s*[\d\u0660-\u0669\u06F0-\u06F9]+)*\]/g;
1227
1371
  function tidy(answer) {
1228
- return answer.replace(/\s*\[\d+(?:,\s*\d+)*\]/g, "").replace(/^#+\s+/gm, "").replace(/[ \t]+\n/g, "\n").replace(/\n{3,}/g, "\n\n").trim();
1372
+ return answer.replace(CITATION_MARK, "").replace(/^#+\s+/gm, "").replace(/[ \t]+\n/g, "\n").replace(/\n{3,}/g, "\n\n").trim();
1229
1373
  }
1230
1374
  function authFailure(error) {
1231
1375
  return error instanceof LlmError && (error.status === 401 || error.status === 403);
@@ -1344,6 +1488,18 @@ function samePage(left, right) {
1344
1488
  }
1345
1489
  var HISTORY_TURNS = 3;
1346
1490
  var SESSIONS_MAX = 5e3;
1491
+ var AGENT_INDEX_PATHS = ["/llms.txt", "/llms-full.txt"];
1492
+ function agentIndexUrls(origin) {
1493
+ const base = origin.endsWith("/") ? origin : `${origin}/`;
1494
+ return AGENT_INDEX_PATHS.map((path) => new URL(path, base).href);
1495
+ }
1496
+ function isAgentIndexUrl(raw) {
1497
+ try {
1498
+ return /^\/llms(?:-full)?\.txt$/i.test(new URL(raw).pathname);
1499
+ } catch {
1500
+ return false;
1501
+ }
1502
+ }
1347
1503
  function randomHex(bytes) {
1348
1504
  const values = new Uint8Array(bytes);
1349
1505
  globalThis.crypto.getRandomValues(values);
@@ -1395,20 +1551,32 @@ function createAnswersBackend(options) {
1395
1551
  async function refresh() {
1396
1552
  const urls = [...options.urls ?? []];
1397
1553
  if (options.sitemap) urls.push(...await sitemapUrls(fetchImpl, options.sitemap, options.maxPages ?? 500));
1554
+ const listed = [...new Set(urls)].slice(0, options.maxPages ?? 500);
1555
+ const listedSet = new Set(listed);
1556
+ const optional = /* @__PURE__ */ new Set();
1557
+ for (const url of listed) {
1558
+ try {
1559
+ for (const extra of agentIndexUrls(new URL(url).origin)) {
1560
+ if (!listedSet.has(extra)) optional.add(extra);
1561
+ }
1562
+ } catch {
1563
+ }
1564
+ }
1398
1565
  const docs = [...options.documents ?? []];
1399
1566
  const skipped = [];
1400
- for (const url of [...new Set(urls)].slice(0, options.maxPages ?? 500)) {
1401
- const response = await fetchImpl(url, { headers: { accept: "text/html,text/plain" } }).catch(() => null);
1567
+ for (const url of [...listed, ...optional]) {
1568
+ const quiet = optional.has(url);
1569
+ const response = await fetchImpl(url, { headers: { accept: "text/html, text/markdown, text/plain" } }).catch(() => null);
1402
1570
  if (!response) {
1403
- skipped.push({ url, reason: "The page could not be fetched." });
1571
+ if (!quiet) skipped.push({ url, reason: "The page could not be fetched." });
1404
1572
  continue;
1405
1573
  }
1406
1574
  if (!response.ok) {
1407
- skipped.push({ url, reason: `HTTP ${response.status}.` });
1575
+ if (!quiet) skipped.push({ url, reason: `HTTP ${response.status}.` });
1408
1576
  continue;
1409
1577
  }
1410
1578
  if (response.redirected && response.url && !samePage(response.url, url)) {
1411
- skipped.push({ url, reason: `Redirects to ${response.url}. List that URL instead if it should be indexed.` });
1579
+ if (!quiet) skipped.push({ url, reason: `Redirects to ${response.url}. List that URL instead if it should be indexed.` });
1412
1580
  continue;
1413
1581
  }
1414
1582
  const type = response.headers.get("content-type") ?? "";
@@ -1499,27 +1667,33 @@ export {
1499
1667
  DEFAULT_ANSWER_MODEL,
1500
1668
  DEFAULT_EMBEDDING_MODEL,
1501
1669
  EMBEDDING_DIMENSIONS,
1670
+ EVIDENCE_LANE,
1502
1671
  FALLBACK_ANSWER_MODEL,
1503
1672
  LlmError,
1504
1673
  MemoryIndex,
1505
1674
  QA_LANE,
1506
1675
  QA_LANE_MIN_SIMILARITY,
1676
+ agentIndexUrls,
1507
1677
  answerMessages,
1508
1678
  answerQuestion,
1509
1679
  authFailure,
1680
+ capabilityVerbs,
1510
1681
  chunkPage,
1511
1682
  citationsFor,
1683
+ contentTokenOverlap,
1512
1684
  cosine,
1513
1685
  createAnswersBackend,
1514
1686
  createOpenAiClient,
1515
1687
  decodeCfEmail,
1516
1688
  decodeEntities,
1517
1689
  decodeProtectedEmails,
1690
+ distinctiveTokens,
1518
1691
  extractPage,
1519
1692
  extractText,
1520
1693
  figuresIn,
1521
1694
  firstPathSegment,
1522
1695
  inferSources,
1696
+ isAgentIndexUrl,
1523
1697
  isReasoningModel,
1524
1698
  matchingQa,
1525
1699
  namedPathBucket,
@@ -1527,9 +1701,11 @@ export {
1527
1701
  prepareMessages,
1528
1702
  reciprocalRankFusion,
1529
1703
  samePage,
1704
+ selectNamedEvidence,
1530
1705
  selectQaLane,
1531
1706
  sourceBlock,
1532
1707
  stem,
1708
+ subjectTokens,
1533
1709
  tokenize,
1534
1710
  verifyAnswer,
1535
1711
  withQaLane