@hraness/wordcell 0.23.0 → 0.24.0

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.
@@ -54,7 +54,7 @@ returned:
54
54
  ```sh
55
55
  wordcell context packages/parser/src/index.ts --root "$KB_ROOT" --repo "$KB_REPO"
56
56
  wordcell search "why parser retries stop" --root "$KB_ROOT" --mode exact \
57
- --history --repo "$KB_REPO" --json
57
+ --selected-passage --history --repo "$KB_REPO" --json
58
58
  wordcell backlinks notes/parser-contract --root "$KB_ROOT" --json
59
59
  wordcell history notes/parser-contract --root "$KB_ROOT" \
60
60
  --repo "$KB_REPO" --json
@@ -80,7 +80,7 @@ read them with the commands under
80
80
  resolve note identities before returning authored relationships.
81
81
  - A whole-vault structural question or relationship audit: use `wordcell graph --json`,
82
82
  then inspect the smallest relevant portion of its canonical output.
83
- - A phrase, identity, or concept expressed with different vocabulary: use `wordcell search`, whose default hybrid result preserves exact and QMD evidence separately.
83
+ - A phrase, identity, or concept expressed with different vocabulary: use `wordcell search`, whose default hybrid result preserves exact and QMD evidence separately. Add `--selected-passage` when you will read, quote, or act on results: each hit then carries the note's best-matching excerpt with its line range and section headings.
84
84
  - Direct provenance for one note or repository path: use `wordcell history` or
85
85
  `wordcell history search` without changing authored metadata or links.
86
86
  - Recent captures awaiting maintained disposition: use the advisory `wordcell inbox` view.
@@ -96,7 +96,7 @@ wordcell backlinks "Plan title or path" --root "$KB_ROOT" --json
96
96
  wordcell links "Plan title or path" --root "$KB_ROOT" --direction both --depth 1 --limit 25 --json
97
97
  wordcell relation list "Plan title or path" --root "$KB_ROOT" --json
98
98
  wordcell graph --root "$KB_ROOT" --json
99
- wordcell search "why browser capture uses the current tab" --root "$KB_ROOT" --json
99
+ wordcell search "why browser capture uses the current tab" --root "$KB_ROOT" --selected-passage --json
100
100
  wordcell search "accepted ingestion plans" --root "$KB_ROOT" --where type=plan --where status=accepted --tag ingestion --json
101
101
  wordcell search "notes/write-path" --root "$KB_ROOT" --mode exact --no-history --json
102
102
  wordcell history "notes/write-path" --root "$KB_ROOT" --repo "$KB_REPO" --json
@@ -211,6 +211,20 @@ For a repository's ordinary KB searches, prefer its approved, pinned
211
211
  choice. Read returned notes before acting; model probabilities are ranking
212
212
  signals, not proof of truth. Explicit priority rules still run last.
213
213
 
214
+ Add `--selected-passage` to attach one source excerpt to each returned note.
215
+ Wordcell chooses it locally, without model calls: the paragraph, or window of
216
+ at most 512 UTF-8 bytes, that contains the most distinct query words, outside
217
+ frontmatter. The excerpt is unedited note text with its byte offsets, line
218
+ range, the SHA-256 of the note text, and the enclosing Markdown headings as
219
+ separate spans. Offsets count from the start of the note text, after any
220
+ byte-order mark. Ranking, scores, the `snippet` field, and reranker input do not
221
+ change. A note without matching body text reports `status: "none"`; a note
222
+ past a size limit reports `status: "unavailable"` with a reason. Both keep
223
+ their snippet. Terminal output shows CRLF as LF and tabs as four spaces, and
224
+ reports `display-sanitized` instead of an excerpt that credential redaction
225
+ would alter. An excerpt shows where query words appear; it is not a verified
226
+ answer. SDK and MCP `search` callers pass `selectedPassage: true`.
227
+
214
228
  `wordcell history <note>` returns the bounded commit history already associated with
215
229
  one resolved note. `wordcell history search <query-or-path>` searches the bounded Git
216
230
  projection directly and retains hashes, subjects, matched paths, co-change
package/src/cli-help.ts CHANGED
@@ -114,6 +114,7 @@ export const commandHelp: readonly CommandHelp[] = [
114
114
  ["--min-score <score>", "Drop results scoring below this (0 to 1)"],
115
115
  ["--rerank <typesafe>", "Rerank results with the hosted TypeSafe service"],
116
116
  ["--rerank-limit <2..25>", "How many results the reranker sees"],
117
+ ["--selected-passage", "Add a local source excerpt to each result"],
117
118
  JSON_OPTION,
118
119
  ],
119
120
  examples: ['wordcell search "retries" --root kb --mode exact', 'wordcell search "retry policy" --root kb --limit 5'],
@@ -385,6 +385,7 @@ type ParsedCommand =
385
385
  readonly minScore?: number;
386
386
  readonly rerank?: "typesafe";
387
387
  readonly rerankLimit?: number;
388
+ readonly selectedPassage?: boolean;
388
389
  readonly query: string;
389
390
  readonly json: boolean;
390
391
  }
@@ -1274,6 +1275,7 @@ function parseSemanticCommand(command: "index" | "search", arguments_: readonly
1274
1275
  let minScore: number | undefined;
1275
1276
  let rerank: "typesafe" | undefined;
1276
1277
  let rerankLimit: number | undefined;
1278
+ let selectedPassage = false;
1277
1279
  let graphDepth: number | undefined;
1278
1280
  let noGraph = false;
1279
1281
  let history = false;
@@ -1300,6 +1302,10 @@ function parseSemanticCommand(command: "index" | "search", arguments_: readonly
1300
1302
  noGraph = true;
1301
1303
  continue;
1302
1304
  }
1305
+ if (argument === "--selected-passage" && command === "search") {
1306
+ selectedPassage = true;
1307
+ continue;
1308
+ }
1303
1309
  if (argument === "--no-history" && command === "search") {
1304
1310
  noHistory = true;
1305
1311
  continue;
@@ -1535,6 +1541,7 @@ function parseSemanticCommand(command: "index" | "search", arguments_: readonly
1535
1541
  ...(minScore === undefined ? {} : { minScore }),
1536
1542
  ...(rerank === undefined ? {} : { rerank }),
1537
1543
  ...(rerankLimit === undefined ? {} : { rerankLimit }),
1544
+ ...(selectedPassage ? { selectedPassage: true } : {}),
1538
1545
  query,
1539
1546
  json,
1540
1547
  },
@@ -2418,6 +2425,22 @@ function renderSemanticIndex(result: SemanticIndexResult): string {
2418
2425
  ].join("\n");
2419
2426
  }
2420
2427
 
2428
+ function searchForDisplay(result: KnowledgeBaseSearchResult): KnowledgeBaseSearchResult {
2429
+ return {
2430
+ ...result,
2431
+ results: result.results.map((hit) => {
2432
+ const passage = hit.selectedPassage;
2433
+ if (passage?.status !== "selected") return hit;
2434
+ const texts = [passage.text, ...passage.headings.map(({ text }) => text)];
2435
+ // Terminal output always renders CRLF as LF and tabs as four spaces; only other changes lose content.
2436
+ if (texts.every((text) => sanitizeTerminalText(redactSensitiveText(text))
2437
+ === text.replaceAll("\r\n", "\n").replaceAll("\t", " "))) return hit;
2438
+ // Redacted display text cannot retain a claim of exact source bytes.
2439
+ return { ...hit, selectedPassage: { status: "unavailable", reason: "display-sanitized" } as const };
2440
+ }),
2441
+ };
2442
+ }
2443
+
2421
2444
  function renderKnowledgeBaseSearch(result: KnowledgeBaseSearchResult): string {
2422
2445
  const lines = [
2423
2446
  `${result.mode[0]?.toLocaleUpperCase("en-US") ?? ""}${result.mode.slice(1)} results for “${safe(result.query)}” (${result.results.length})${result.partial ? " [partial]" : ""}`,
@@ -2445,7 +2468,20 @@ function renderKnowledgeBaseSearch(result: KnowledgeBaseSearchResult): string {
2445
2468
  .map((item) => `${item.kind}#${searchEvidenceRank(item)}`)
2446
2469
  .join(", ");
2447
2470
  lines.push(` ${hit.rank}. ${hit.score.toFixed(3)} ${location} — ${safe(hit.title)} [${safe(evidence)}]`);
2448
- if (hit.snippet !== "") lines.push(` ${safe(hit.snippet)}`);
2471
+ const passage = hit.selectedPassage;
2472
+ if (passage?.status === "selected") {
2473
+ lines.push(` Selected passage: ${safe(hit.path)}:${passage.startLine}-${passage.endLine}`
2474
+ + (passage.clippedStart || passage.clippedEnd ? " (clipped excerpt)" : ""));
2475
+ for (const heading of passage.headings) {
2476
+ lines.push(` Section: ${safe(heading.text)}${heading.truncated ? " (clipped)" : ""}`);
2477
+ }
2478
+ for (const line of passage.text.split(/\r?\n/u)) lines.push(` ${safe(line)}`);
2479
+ } else {
2480
+ if (hit.snippet !== "") lines.push(` ${safe(hit.snippet)}`);
2481
+ if (passage !== undefined) {
2482
+ lines.push(` Selected passage: ${passage.status} (${safe(passage.reason)}); showing the search snippet.`);
2483
+ }
2484
+ }
2449
2485
  }
2450
2486
  if ((result.graph?.related.length ?? 0) > 0) {
2451
2487
  lines.push(` Related graph context: ${result.graph?.related.length ?? 0}`);
@@ -2523,6 +2559,7 @@ async function runSemantic(
2523
2559
  ? {}
2524
2560
  : { candidateLimit: command.candidateLimit }),
2525
2561
  ...(command.minScore === undefined ? {} : { minScore: command.minScore }),
2562
+ ...(command.selectedPassage === undefined ? {} : { selectedPassage: command.selectedPassage }),
2526
2563
  ...(command.rerank === undefined ? {} : {
2527
2564
  rerank: {
2528
2565
  engine: command.rerank,
@@ -2530,9 +2567,10 @@ async function runSemantic(
2530
2567
  },
2531
2568
  }),
2532
2569
  });
2570
+ const display = searchForDisplay(result);
2533
2571
  output.stdout(command.json
2534
- ? terminalSafeJson(result)
2535
- : sanitizeTerminalText(renderKnowledgeBaseSearch(result)));
2572
+ ? terminalSafeJson(display)
2573
+ : sanitizeTerminalText(renderKnowledgeBaseSearch(display)));
2536
2574
  if (withoutIndex && person) {
2537
2575
  output.stderr("Searched without an index, so only exact words matched. "
2538
2576
  + `For meaning-based search, run wordcell index --root ${safe(shellWord(command.root))} `
package/src/mcp-tools.ts CHANGED
@@ -229,6 +229,15 @@ export function optionalText(arguments_: Arguments, key: string, maxBytes: numbe
229
229
  return has(arguments_, key) ? checkedText(arguments_[key], key, maxBytes) : undefined;
230
230
  }
231
231
 
232
+ function optionalBoolean(arguments_: Arguments, key: string): boolean | undefined {
233
+ if (!has(arguments_, key)) return undefined;
234
+ const value = arguments_[key];
235
+ if (typeof value !== "boolean") {
236
+ throw new ToolArgumentError(`Invalid argument "${key}": expected a boolean.`);
237
+ }
238
+ return value;
239
+ }
240
+
232
241
  export function optionalInteger(
233
242
  arguments_: Arguments,
234
243
  key: string,
@@ -631,6 +640,11 @@ export function readTools(root: string, sessions: VaultSessions): readonly ToolE
631
640
  properties: {
632
641
  query: { type: "string", minLength: 1, maxLength: MAX_QUERY_TEXT_UTF8_BYTES },
633
642
  mode: { type: "string", enum: SEARCH_MODES, default: "hybrid" },
643
+ selectedPassage: {
644
+ type: "boolean",
645
+ default: false,
646
+ description: "Include a local source excerpt with line references and section headings. Ranking is unchanged.",
647
+ },
634
648
  limit: { type: "integer", minimum: 1, maximum: MAX_SEARCH_RESULTS, default: DEFAULT_SEARCH_RESULTS },
635
649
  ...filterProperties,
636
650
  },
@@ -638,10 +652,11 @@ export function readTools(root: string, sessions: VaultSessions): readonly ToolE
638
652
  annotations: { title: "Search notes", ...READ_ONLY },
639
653
  },
640
654
  handler: async (arguments_) => {
641
- allowOnly(arguments_, ["query", "mode", "limit", "tags", "where", "has", "scope"]);
655
+ allowOnly(arguments_, ["query", "mode", "limit", "tags", "where", "has", "scope", "selectedPassage"]);
642
656
  const query = requiredText(arguments_, "query", MAX_QUERY_TEXT_UTF8_BYTES);
643
657
  const mode = optionalChoice(arguments_, "mode", SEARCH_MODES) ?? "hybrid";
644
658
  const limit = optionalInteger(arguments_, "limit", 1, MAX_SEARCH_RESULTS) ?? DEFAULT_SEARCH_RESULTS;
659
+ const selectedPassage = optionalBoolean(arguments_, "selectedPassage");
645
660
  const filters = metadataFilters(arguments_);
646
661
  const tags = optionalTextList(arguments_, "tags", MAX_QUERY_TAGS, MAX_QUERY_TEXT_UTF8_BYTES);
647
662
  const scopes = optionalTextList(arguments_, "scope", MAX_REPOSITORY_SCOPES, MAX_QUERY_TEXT_UTF8_BYTES);
@@ -650,6 +665,7 @@ export function readTools(root: string, sessions: VaultSessions): readonly ToolE
650
665
  query,
651
666
  mode,
652
667
  limit,
668
+ ...(selectedPassage === undefined ? {} : { selectedPassage }),
653
669
  ...(filters.length === 0 ? {} : { filters }),
654
670
  ...(tags === undefined ? {} : { tags }),
655
671
  ...(scopes === undefined ? {} : { repositoryScopes: scopes }),
package/src/sdk.ts CHANGED
@@ -52,6 +52,15 @@ import {
52
52
  type SearchRuleRequest,
53
53
  type SearchRulesV1,
54
54
  } from "./search-rules.js";
55
+ import {
56
+ createSelectedPassageBudget,
57
+ SELECTED_PASSAGE_MAX_BYTES,
58
+ SELECTED_PASSAGE_MAX_HEADING_BYTES,
59
+ SELECTED_PASSAGE_NONE_REASONS,
60
+ SELECTED_PASSAGE_UNAVAILABLE_REASONS,
61
+ selectSearchPassage,
62
+ type SelectedPassageResult,
63
+ } from "./selected-passage.js";
55
64
  import {
56
65
  applyRerank,
57
66
  MAX_RERANK_CANDIDATES,
@@ -82,6 +91,14 @@ import {
82
91
  type UntrustedStructuredContent,
83
92
  } from "./untrusted-content.js";
84
93
 
94
+ export type {
95
+ SelectedPassage,
96
+ SelectedPassageHeading,
97
+ SelectedPassageNone,
98
+ SelectedPassageResult,
99
+ SelectedPassageUnavailable,
100
+ } from "./selected-passage.js";
101
+
85
102
  export const MAX_SEARCH_RESULTS = 100;
86
103
  export const MAX_SEARCH_CANDIDATES = 500;
87
104
  export const DEFAULT_SEARCH_RESULTS = 10;
@@ -131,6 +148,8 @@ export type KnowledgeBaseSearchOptions = {
131
148
  readonly history?: false | "auto" | "required" | KnowledgeBaseHistoryOptions;
132
149
  /** Opt-in rerank engine over a bounded result window. */
133
150
  readonly rerank?: KnowledgeBaseSearchRerankOptions;
151
+ /** Add one local query-selected source passage after ranking; never changes reranker input. */
152
+ readonly selectedPassage?: boolean;
134
153
  };
135
154
 
136
155
  export type KnowledgeBaseExactEvidence = {
@@ -179,6 +198,8 @@ export type KnowledgeBaseSearchHit = {
179
198
  readonly identity: boolean;
180
199
  readonly line?: number;
181
200
  readonly snippet: string;
201
+ /** Present only when requested; legacy snippet, line, and retrieval evidence retain their meanings. */
202
+ readonly selectedPassage?: SelectedPassageResult;
182
203
  readonly tags: readonly string[];
183
204
  readonly metadata: MetadataObject;
184
205
  readonly evidence: readonly KnowledgeBaseSearchEvidence[];
@@ -729,6 +750,10 @@ export async function openKnowledgeBase(
729
750
  searchRules,
730
751
  );
731
752
  const effectiveOptions = expansion.request as KnowledgeBaseSearchOptions;
753
+ if (effectiveOptions.selectedPassage !== undefined
754
+ && typeof effectiveOptions.selectedPassage !== "boolean") {
755
+ throw new TypeError("Search selectedPassage must be a boolean.");
756
+ }
732
757
  const { query: effectiveQuery } = validateSearchQuery(effectiveOptions.query);
733
758
  const mode = checkedSearchMode(effectiveOptions.mode);
734
759
  const ordering = checkedSearchOrdering(effectiveOptions.ordering);
@@ -1029,6 +1054,17 @@ export async function openKnowledgeBase(
1029
1054
  hit.rank === index + 1 ? hit : { ...hit, rank: index + 1 }));
1030
1055
  }
1031
1056
  }
1057
+ if (effectiveOptions.selectedPassage === true) {
1058
+ const budget = createSelectedPassageBudget();
1059
+ results = Object.freeze(results.map((hit) => {
1060
+ const note = notesById.get(hit.id);
1061
+ if (note === undefined) throw new Error("Selected search hit has no snapshot note.");
1062
+ return Object.freeze({
1063
+ ...hit,
1064
+ selectedPassage: selectSearchPassage({ query: effectiveQuery, text: note.content, budget }),
1065
+ });
1066
+ }));
1067
+ }
1032
1068
  let graph: GraphContext | null = null;
1033
1069
  if (graphOptions !== null) {
1034
1070
  try {
@@ -1273,7 +1309,16 @@ export function packSearchContext(
1273
1309
  if (!append(String(searchEvidenceRank(item)))) return writer.result();
1274
1310
  }
1275
1311
  if (!append("\n\n")) return writer.result();
1276
- if (!append(hit.snippet)) return writer.result();
1312
+ const selected = selectedPassageForContext(hit, `Knowledge-base search result ${hit.rank}`);
1313
+ if (selected?.status === "selected") {
1314
+ for (const heading of selected.headings) {
1315
+ if (!append(`Section (level ${heading.level}, lines ${heading.startLine}-${heading.endLine}${heading.truncated ? ", shortened" : ""}):\n`)) return writer.result();
1316
+ if (!append(heading.text)) return writer.result();
1317
+ if (!append("\n\n")) return writer.result();
1318
+ }
1319
+ if (!append(`Selected source (UTF-8 snapshot ${selected.sourceSha256}; bytes ${selected.startByte}-${selected.endByte}; lines ${selected.startLine}-${selected.endLine}${selected.clippedStart || selected.clippedEnd ? "; excerpt clipped" : ""}):\n`)) return writer.result();
1320
+ if (!append(selected.text)) return writer.result();
1321
+ } else if (!append(hit.snippet)) return writer.result();
1277
1322
  }
1278
1323
 
1279
1324
  const graph = result.graph;
@@ -1420,6 +1465,107 @@ function optionalDataNumber(
1420
1465
  return dataNumber(record[key], `${label}.${key}`);
1421
1466
  }
1422
1467
 
1468
+ function dataCount(value: unknown, label: string, minimum = 0): number {
1469
+ if (typeof value !== "number" || !Number.isSafeInteger(value) || value < minimum) {
1470
+ throw new TypeError(`${label} must be an integer of at least ${minimum}.`);
1471
+ }
1472
+ return value;
1473
+ }
1474
+
1475
+ function dataSourceText(value: unknown, label: string, maximumBytes: number): string {
1476
+ const text = dataString(value, label);
1477
+ if (/\p{Surrogate}/u.test(text)) throw new TypeError(`${label} must be well-formed Unicode.`);
1478
+ if (Buffer.byteLength(text, "utf8") > maximumBytes) {
1479
+ throw new RangeError(`${label} exceeds ${maximumBytes} UTF-8 bytes.`);
1480
+ }
1481
+ return text;
1482
+ }
1483
+
1484
+ function sourceSpan(
1485
+ record: InspectedDataRecord,
1486
+ label: string,
1487
+ text: string,
1488
+ ): { startByte: number; endByte: number; startLine: number; endLine: number } {
1489
+ const startByte = dataCount(requiredData(record, "startByte", label), `${label}.startByte`);
1490
+ const endByte = dataCount(requiredData(record, "endByte", label), `${label}.endByte`);
1491
+ const startLine = dataCount(requiredData(record, "startLine", label), `${label}.startLine`, 1);
1492
+ const endLine = dataCount(requiredData(record, "endLine", label), `${label}.endLine`, startLine);
1493
+ if (endByte - startByte !== Buffer.byteLength(text, "utf8")) {
1494
+ throw new TypeError(`${label} byte span must match its text.`);
1495
+ }
1496
+ return { startByte, endByte, startLine, endLine };
1497
+ }
1498
+
1499
+ function selectedPassageData(value: unknown, label: string): SelectedPassageResult {
1500
+ const record = inspectedDataRecord(value, label);
1501
+ const status = requiredData(record, "status", label);
1502
+ if (status === "none" || status === "unavailable") {
1503
+ const reason = requiredData(record, "reason", label);
1504
+ const reasons: readonly string[] = status === "none"
1505
+ ? SELECTED_PASSAGE_NONE_REASONS
1506
+ : SELECTED_PASSAGE_UNAVAILABLE_REASONS;
1507
+ if (typeof reason !== "string" || !reasons.includes(reason)) {
1508
+ throw new TypeError(`${label}.reason is not a known ${status} reason.`);
1509
+ }
1510
+ return Object.freeze({ status, reason }) as SelectedPassageResult;
1511
+ }
1512
+ if (status !== "selected") throw new TypeError(`${label}.status is not a known selected-passage status.`);
1513
+ const text = dataSourceText(requiredData(record, "text", label), `${label}.text`, SELECTED_PASSAGE_MAX_BYTES);
1514
+ if (text === "") throw new TypeError(`${label}.text must not be empty.`);
1515
+ const span = sourceSpan(record, label, text);
1516
+ const sourceSha256 = dataString(requiredData(record, "sourceSha256", label), `${label}.sourceSha256`);
1517
+ if (!/^[0-9a-f]{64}$/u.test(sourceSha256)) throw new TypeError(`${label}.sourceSha256 must be a SHA-256 digest.`);
1518
+ if (requiredData(record, "sourceEncoding", label) !== "utf8-snapshot") {
1519
+ throw new TypeError(`${label}.sourceEncoding must be utf8-snapshot.`);
1520
+ }
1521
+ let previousLevel = 0;
1522
+ let previousEnd = 0;
1523
+ const headings = inspectedDataArray(requiredData(record, "headings", label), `${label}.headings`, 6)
1524
+ .map((entry, index) => {
1525
+ const headingLabel = `${label}.headings[${index}]`;
1526
+ const heading = inspectedDataRecord(entry, headingLabel);
1527
+ const level = dataCount(requiredData(heading, "level", headingLabel), `${headingLabel}.level`, previousLevel + 1);
1528
+ if (level > 6) throw new TypeError(`${headingLabel}.level must be at most 6.`);
1529
+ const headingText = dataSourceText(
1530
+ requiredData(heading, "text", headingLabel),
1531
+ `${headingLabel}.text`,
1532
+ SELECTED_PASSAGE_MAX_HEADING_BYTES,
1533
+ );
1534
+ const headingSpan = sourceSpan(heading, headingLabel, headingText);
1535
+ if (headingSpan.startByte < previousEnd || headingSpan.endByte > span.startByte) {
1536
+ throw new TypeError(`${headingLabel} must precede the selected passage in source order.`);
1537
+ }
1538
+ previousLevel = level;
1539
+ previousEnd = headingSpan.endByte;
1540
+ return Object.freeze({
1541
+ level,
1542
+ text: headingText,
1543
+ ...headingSpan,
1544
+ truncated: dataBoolean(requiredData(heading, "truncated", headingLabel), `${headingLabel}.truncated`),
1545
+ });
1546
+ });
1547
+ return Object.freeze({
1548
+ status,
1549
+ text,
1550
+ ...span,
1551
+ sourceSha256,
1552
+ sourceEncoding: "utf8-snapshot",
1553
+ clippedStart: dataBoolean(requiredData(record, "clippedStart", label), `${label}.clippedStart`),
1554
+ clippedEnd: dataBoolean(requiredData(record, "clippedEnd", label), `${label}.clippedEnd`),
1555
+ headings: Object.freeze(headings),
1556
+ });
1557
+ }
1558
+
1559
+ /** Read an optional hit passage as untrusted data without invoking accessors. */
1560
+ function selectedPassageForContext(hit: object, label: string): SelectedPassageResult | undefined {
1561
+ const descriptor = Object.getOwnPropertyDescriptor(hit, "selectedPassage");
1562
+ if (descriptor === undefined) return undefined;
1563
+ if (!("value" in descriptor)) throw new TypeError(`${label} contains an accessor property.`);
1564
+ return descriptor.value === undefined
1565
+ ? undefined
1566
+ : selectedPassageData(descriptor.value, `${label}.selectedPassage`);
1567
+ }
1568
+
1423
1569
  function searchEvidenceSummary(value: unknown, label: string): Readonly<Record<string, unknown>> {
1424
1570
  const evidence = inspectedDataRecord(value, label);
1425
1571
  return Object.freeze({
@@ -1438,6 +1584,7 @@ function searchHitContextRecord(value: unknown, index: number): Readonly<Record<
1438
1584
  ).map((entry, evidenceIndex) =>
1439
1585
  searchEvidenceSummary(entry, `${label}.evidence[${evidenceIndex}]`));
1440
1586
  const line = optionalDataNumber(hit, "line", label);
1587
+ const selectedPassage = selectedPassageForContext(hit, label);
1441
1588
  return Object.freeze({
1442
1589
  kind: "search-result",
1443
1590
  id: dataString(requiredData(hit, "id", label), `${label}.id`),
@@ -1449,6 +1596,7 @@ function searchHitContextRecord(value: unknown, index: number): Readonly<Record<
1449
1596
  ...(line === undefined ? {} : { line }),
1450
1597
  evidence: Object.freeze(evidence),
1451
1598
  snippet: dataString(requiredData(hit, "snippet", label), `${label}.snippet`),
1599
+ ...(selectedPassage === undefined ? {} : { selectedPassage }),
1452
1600
  });
1453
1601
  }
1454
1602