@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.
- package/README.md +19 -8
- package/dist/cli.js +5 -5
- package/dist/evaluation-builder.js +2 -2
- package/dist/evaluation-kb.js +2 -2
- package/dist/{index-p70t8j13.js → index-1w0sp1q7.js} +1 -1
- package/dist/{index-vtwbapjr.js → index-44kw9tr8.js} +61 -7
- package/dist/{index-tvd2sqrx.js → index-5d1rd5v9.js} +437 -2
- package/dist/{index-r4qs6vq4.js → index-f184qyh1.js} +1 -1
- package/dist/{index-mnq2wy34.js → index-mvs6f7ne.js} +1 -1
- package/dist/index.js +2 -2
- package/dist/portfolio.js +2 -2
- package/dist/sdk.js +1 -1
- package/dist/{support-c8hy7qfa.js → support-1yhv6vm6.js} +4 -4
- package/dist/workflows/decision-context.js +2 -2
- package/dist/workflows/index.js +2 -2
- package/package.json +4 -3
- package/skills/wordcell/SKILL.md +1 -1
- package/skills/wordcell/references/query.md +17 -3
- package/src/cli-help.ts +1 -0
- package/src/cli-program.ts +41 -3
- package/src/mcp-tools.ts +17 -1
- package/src/sdk.ts +149 -1
- package/src/selected-passage.ts +409 -0
|
@@ -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'],
|
package/src/cli-program.ts
CHANGED
|
@@ -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
|
-
|
|
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(
|
|
2535
|
-
: sanitizeTerminalText(renderKnowledgeBaseSearch(
|
|
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
|
-
|
|
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
|
|