@hraness/wordcell 0.22.5 → 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.
Files changed (78) hide show
  1. package/README.md +67 -18
  2. package/dist/authoring.js +2 -2
  3. package/dist/{authority-3ga47syb.js → authority-35cbvp1x.js} +2 -2
  4. package/dist/benchmark.js +3 -3
  5. package/dist/capture.js +6 -6
  6. package/dist/cli.js +26 -24
  7. package/dist/clip/acquire.js +3 -3
  8. package/dist/clip/args.js +1 -1
  9. package/dist/clip/cli.js +6 -6
  10. package/dist/clip/doctor.js +4 -4
  11. package/dist/evaluation-builder.js +10 -10
  12. package/dist/evaluation-kb.js +10 -10
  13. package/dist/graph-authority.js +4 -4
  14. package/dist/graph-percolation.js +6 -6
  15. package/dist/graph.js +3 -1
  16. package/dist/{index-bcknqxrq.js → index-0eacgvpv.js} +9 -1
  17. package/dist/{index-f75zmmdr.js → index-1er88ckw.js} +4 -4
  18. package/dist/{index-8v6k9h4r.js → index-1s8mc4hz.js} +1 -1
  19. package/dist/{index-n9kwenpt.js → index-1w0sp1q7.js} +1 -1
  20. package/dist/{index-de2w8crk.js → index-2fmnttca.js} +82 -15
  21. package/dist/{index-8bgnhjgq.js → index-44kw9tr8.js} +3867 -162
  22. package/dist/{index-42tb8qqp.js → index-5d1rd5v9.js} +444 -9
  23. package/dist/{index-aer0jdrq.js → index-ahyhryb8.js} +1 -1
  24. package/dist/{index-f7fnww7a.js → index-b0khj0h5.js} +164 -9
  25. package/dist/{index-qdb8f8va.js → index-bdwcjvr4.js} +1 -1
  26. package/dist/{index-dfag79p7.js → index-byz4kzww.js} +3 -0
  27. package/dist/{index-hgve9rh2.js → index-cd75vky9.js} +96 -1
  28. package/dist/{index-h2qn3y3f.js → index-f184qyh1.js} +4 -4
  29. package/dist/{index-n57tewfr.js → index-fp732bgg.js} +2 -2
  30. package/dist/{index-zy7an84p.js → index-jvb7w0gg.js} +5 -2
  31. package/dist/{index-f984hw45.js → index-k86wepd8.js} +1 -1
  32. package/dist/{index-1r5rnkfr.js → index-mvs6f7ne.js} +2 -2
  33. package/dist/{index-p96kb6t2.js → index-nn67kzwa.js} +1 -1
  34. package/dist/{index-fc4dr114.js → index-nwrehke7.js} +1 -1
  35. package/dist/{index-pz2b2x0y.js → index-pgtm2nhf.js} +1 -1
  36. package/dist/{index-054mb7d3.js → index-qfqjfxaa.js} +10 -9
  37. package/dist/{index-xzvcw9ga.js → index-r0m5taz2.js} +2 -2
  38. package/dist/{index-gtwqye5a.js → index-tcaq1c7f.js} +4 -4
  39. package/dist/{index-g5vsqmdy.js → index-xwxy71ew.js} +190 -4
  40. package/dist/index.js +15 -13
  41. package/dist/pdf.js +3 -3
  42. package/dist/percolate.js +2 -2
  43. package/dist/portfolio.js +10 -10
  44. package/dist/publish.js +3 -3
  45. package/dist/sdk.js +9 -9
  46. package/dist/search.js +2 -2
  47. package/dist/semantic.js +4 -4
  48. package/dist/{support-jf9v10j7.js → support-1yhv6vm6.js} +25 -23
  49. package/dist/url-intelligence.js +1 -1
  50. package/dist/workflows/decision-context.js +10 -10
  51. package/dist/workflows/index.js +10 -10
  52. package/package.json +12 -4
  53. package/skills/wordcell/AGENTS.md +1 -0
  54. package/skills/wordcell/SKILL.md +5 -3
  55. package/skills/wordcell/references/query.md +52 -4
  56. package/skills/wordcell/references/session-memory.md +207 -0
  57. package/src/authoring-import.ts +32 -0
  58. package/src/authoring-model.ts +185 -2
  59. package/src/authoring-program.ts +19 -7
  60. package/src/cli-help.ts +894 -0
  61. package/src/cli-intro.ts +1 -1
  62. package/src/cli-program.ts +612 -83
  63. package/src/cli-style.ts +132 -0
  64. package/src/clip/acquire.ts +20 -0
  65. package/src/clip/args.ts +3 -0
  66. package/src/clip/cli.ts +82 -5
  67. package/src/clip/cookie-permission.ts +241 -0
  68. package/src/clip/url-metadata-cli.ts +6 -5
  69. package/src/clip/url-metadata.ts +14 -0
  70. package/src/graph.ts +9 -4
  71. package/src/import-supermemory.ts +1629 -0
  72. package/src/mcp-server.ts +415 -0
  73. package/src/mcp-tools.ts +1138 -0
  74. package/src/pdf/cli.ts +5 -4
  75. package/src/sdk.ts +149 -1
  76. package/src/selected-passage.ts +409 -0
  77. package/src/support-profile.ts +1 -1
  78. package/src/support.ts +3 -0
package/src/pdf/cli.ts CHANGED
@@ -2,6 +2,7 @@ import { lstatSync, readFileSync } from "node:fs";
2
2
  import { resolve } from "node:path";
3
3
 
4
4
  import { redactSensitiveText } from "../clip/persist.js";
5
+ import { renderFailure, sentence, stderrStyle, terminalOutput } from "../cli-style.js";
5
6
  import { sanitizeTerminalLine, sanitizeTerminalText } from "../clip/terminal.js";
6
7
  import { parsePdfArguments, pdfUsage } from "./args.js";
7
8
  import { runPdfCapture } from "./capture.js";
@@ -19,10 +20,10 @@ export type PdfCliOutput = {
19
20
  readonly stderr: (value: string) => void;
20
21
  };
21
22
 
22
- const defaultOutput: PdfCliOutput = {
23
+ const defaultOutput: PdfCliOutput = terminalOutput({
23
24
  stdout: (value) => process.stdout.write(value),
24
25
  stderr: (value) => process.stderr.write(value),
25
- };
26
+ });
26
27
 
27
28
  export type PdfCliDependencies = {
28
29
  readonly runPdfCapture?: typeof runPdfCapture;
@@ -89,7 +90,7 @@ export async function main(
89
90
  ): Promise<number> {
90
91
  const parsed = parsePdfArguments(rawArguments, environment);
91
92
  if (!parsed.ok) {
92
- output.stderr(`error: ${safe(parsed.message)}\n\n${sanitizeTerminalText(pdfUsage)}`);
93
+ output.stderr(renderFailure(sentence(safe(parsed.message)), "wordcell pdf --help", stderrStyle(environment, output)));
93
94
  return 2;
94
95
  }
95
96
  const arguments_ = parsed.value;
@@ -149,7 +150,7 @@ export async function main(
149
150
  } catch (error) {
150
151
  const message = safe(error instanceof Error ? error.message : String(error));
151
152
  if (arguments_.json) output.stdout(terminalSafeJson({ ok: false, error: message }));
152
- else output.stderr(`error: ${message}\n`);
153
+ else output.stderr(renderFailure(message, "wordcell doctor", stderrStyle(environment, output)));
153
154
  return 1;
154
155
  } finally {
155
156
  preparedSource?.dispose();
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
 
@@ -0,0 +1,409 @@
1
+ import { createHash } from "node:crypto";
2
+
3
+ /** Maximum UTF-8 bytes in one selected passage; matches the reranker snippet budget. */
4
+ export const SELECTED_PASSAGE_MAX_BYTES = 512;
5
+ /** Maximum UTF-8 bytes of one heading line returned as section context. */
6
+ export const SELECTED_PASSAGE_MAX_HEADING_BYTES = 256;
7
+ /** Notes larger than this report `source-limit` instead of being scanned. */
8
+ export const SELECTED_PASSAGE_MAX_SOURCE_BYTES = 2 * 1024 * 1024;
9
+ /** Total note bytes one search may scan for selected passages. */
10
+ export const SELECTED_PASSAGE_MAX_SEARCH_BYTES = 8 * 1024 * 1024;
11
+ const MAX_QUERY_BYTES = 16 * 1024;
12
+ const MAX_QUERY_TERMS = 64;
13
+ const MAX_CANDIDATES = 10_000;
14
+ const WORD = /[\p{L}\p{N}][\p{L}\p{N}\p{M}]*/gu;
15
+ // Generic grammatical words, not corpus terms or benchmark-specific expansions.
16
+ const STOP_WORDS = new Set((
17
+ "a an and are as at be been being but by can could did do does doing each either "
18
+ + "for from had has have having he her here hers herself him himself his how i "
19
+ + "if in into is it its itself may me might mine more most must my myself neither "
20
+ + "no nor not of on once only or our ours ourselves out own same she should so "
21
+ + "some such than that the their theirs them themselves then there these they "
22
+ + "this those through to too under until up us very was we were what when where "
23
+ + "which while who whom why will with would you your yours yourself yourselves"
24
+ ).split(" "));
25
+
26
+ /** One enclosing ATX heading line, returned as its own exact source span. */
27
+ export type SelectedPassageHeading = Readonly<{
28
+ level: number;
29
+ text: string;
30
+ /** Half-open offsets into the UTF-8 encoding of the note snapshot. */
31
+ startByte: number;
32
+ endByte: number;
33
+ startLine: number;
34
+ endLine: number;
35
+ /** True when the heading line was shortened to the heading byte budget. */
36
+ truncated: boolean;
37
+ }>;
38
+
39
+ export type SelectedPassage = Readonly<{
40
+ status: "selected";
41
+ /** Exact contiguous source text; never rewritten, joined, or prefixed. */
42
+ text: string;
43
+ /** Half-open offsets into the UTF-8 encoding of the note snapshot. */
44
+ startByte: number;
45
+ endByte: number;
46
+ /** One-based inclusive lines occupied by the selected bytes. */
47
+ startLine: number;
48
+ endLine: number;
49
+ /** SHA-256 of the UTF-8 encoded snapshot text these offsets address. */
50
+ sourceSha256: string;
51
+ /** The snapshot is decoded text re-encoded as UTF-8, so a leading BOM is absent. */
52
+ sourceEncoding: "utf8-snapshot";
53
+ /** True when the passage starts or ends inside a longer paragraph. */
54
+ clippedStart: boolean;
55
+ clippedEnd: boolean;
56
+ /** Enclosing headings from outermost to innermost, excluding any heading inside `text`. */
57
+ headings: readonly SelectedPassageHeading[];
58
+ }>;
59
+
60
+ export type SelectedPassageNone = Readonly<{
61
+ status: "none";
62
+ reason: "no-query-terms" | "no-lexical-match";
63
+ }>;
64
+
65
+ export type SelectedPassageUnavailable = Readonly<{
66
+ status: "unavailable";
67
+ reason:
68
+ | "query-limit"
69
+ | "source-limit"
70
+ | "search-budget"
71
+ | "candidate-limit"
72
+ | "grapheme-limit"
73
+ | "invalid-source"
74
+ | "display-sanitized";
75
+ }>;
76
+
77
+ export type SelectedPassageResult = SelectedPassage | SelectedPassageNone | SelectedPassageUnavailable;
78
+
79
+ export const SELECTED_PASSAGE_NONE_REASONS: readonly SelectedPassageNone["reason"][] = Object.freeze([
80
+ "no-query-terms",
81
+ "no-lexical-match",
82
+ ]);
83
+
84
+ export const SELECTED_PASSAGE_UNAVAILABLE_REASONS: readonly SelectedPassageUnavailable["reason"][] = Object.freeze([
85
+ "query-limit",
86
+ "source-limit",
87
+ "search-budget",
88
+ "candidate-limit",
89
+ "grapheme-limit",
90
+ "invalid-source",
91
+ "display-sanitized",
92
+ ]);
93
+
94
+ /** Tracks the note bytes one search has scanned. */
95
+ export type SelectedPassageBudget = {
96
+ readonly take: (bytes: number) => boolean;
97
+ };
98
+
99
+ export function createSelectedPassageBudget(
100
+ maximumBytes = SELECTED_PASSAGE_MAX_SEARCH_BYTES,
101
+ ): SelectedPassageBudget {
102
+ if (!Number.isSafeInteger(maximumBytes) || maximumBytes < 0) {
103
+ throw new RangeError("Selected passage budget must be a non-negative safe integer.");
104
+ }
105
+ let remaining = maximumBytes;
106
+ return Object.freeze({
107
+ take: (bytes: number) => {
108
+ if (bytes > remaining) return false;
109
+ remaining -= bytes;
110
+ return true;
111
+ },
112
+ });
113
+ }
114
+
115
+ function unavailable(reason: SelectedPassageUnavailable["reason"]): SelectedPassageUnavailable {
116
+ return Object.freeze({ status: "unavailable", reason });
117
+ }
118
+
119
+ function none(reason: SelectedPassageNone["reason"]): SelectedPassageNone {
120
+ return Object.freeze({ status: "none", reason });
121
+ }
122
+
123
+ function normalize(value: string): string {
124
+ return value.normalize("NFC").toLocaleLowerCase("en-US");
125
+ }
126
+
127
+ function wellFormed(value: string): boolean {
128
+ return !/\p{Surrogate}/u.test(value);
129
+ }
130
+
131
+ function lowerBound(values: readonly number[], target: number): number {
132
+ let low = 0;
133
+ let high = values.length;
134
+ while (low < high) {
135
+ const middle = (low + high) >>> 1;
136
+ if (values[middle]! < target) low = middle + 1;
137
+ else high = middle;
138
+ }
139
+ return low;
140
+ }
141
+
142
+ function upperBound(values: readonly number[], target: number): number {
143
+ let low = 0;
144
+ let high = values.length;
145
+ while (low < high) {
146
+ const middle = (low + high) >>> 1;
147
+ if (values[middle]! <= target) low = middle + 1;
148
+ else high = middle;
149
+ }
150
+ return low;
151
+ }
152
+
153
+ type Paragraph = { start: number; end: number; headingOnly: boolean };
154
+ type HeadingLine = { start: number; end: number; level: number; line: number };
155
+
156
+ function sourceLayout(text: string): {
157
+ bodyStart: number;
158
+ lineStarts: number[];
159
+ paragraphs: Paragraph[];
160
+ headings: HeadingLine[];
161
+ } {
162
+ const lineStarts = [0];
163
+ for (let index = text.indexOf("\n"); index >= 0; index = text.indexOf("\n", index + 1)) {
164
+ lineStarts.push(index + 1);
165
+ }
166
+ const lineEnd = (index: number): number => {
167
+ const next = lineStarts[index + 1];
168
+ let end = next === undefined ? text.length : next - 1;
169
+ if (end > lineStarts[index]! && text[end - 1] === "\r") end--;
170
+ return end;
171
+ };
172
+ let firstBodyLine = 0;
173
+ if (text.slice(0, lineEnd(0)).trim() === "---") {
174
+ for (let index = 1; index < lineStarts.length; index++) {
175
+ if (text.slice(lineStarts[index], lineEnd(index)).trim() === "---") {
176
+ firstBodyLine = index + 1;
177
+ break;
178
+ }
179
+ }
180
+ }
181
+ const bodyStart = lineStarts[firstBodyLine] ?? text.length;
182
+ const paragraphs: Paragraph[] = [];
183
+ const headings: HeadingLine[] = [];
184
+ let fence: { character: string; length: number } | undefined;
185
+ let current: Paragraph | undefined;
186
+ for (let index = firstBodyLine; index < lineStarts.length; index++) {
187
+ const start = lineStarts[index]!;
188
+ const end = lineEnd(index);
189
+ const line = text.slice(start, end);
190
+ if (line.trim() === "") {
191
+ if (current !== undefined) paragraphs.push(current);
192
+ current = undefined;
193
+ continue;
194
+ }
195
+ const delimiter = /^\s{0,3}(`{3,}|~{3,})/u.exec(line)?.[1];
196
+ const marker = fence === undefined ? /^\s{0,3}(#{1,6})(?:\s|$)/u.exec(line)?.[1] : undefined;
197
+ const heading = marker !== undefined;
198
+ if (heading) headings.push({ start, end, level: marker.length, line: index + 1 });
199
+ if (delimiter !== undefined) {
200
+ if (fence === undefined) fence = { character: delimiter[0]!, length: delimiter.length };
201
+ else if (delimiter[0] === fence.character && delimiter.length >= fence.length
202
+ && line.trim() === delimiter) fence = undefined;
203
+ }
204
+ if (current === undefined) current = { start, end, headingOnly: heading };
205
+ else {
206
+ current.end = end;
207
+ current.headingOnly &&= heading;
208
+ }
209
+ }
210
+ if (current !== undefined) paragraphs.push(current);
211
+ return { bodyStart, lineStarts, paragraphs, headings };
212
+ }
213
+
214
+ /** Longest grapheme-aligned prefix of `value` within `maximumBytes` UTF-8 bytes. */
215
+ function graphemePrefix(value: string, maximumBytes: number): { text: string; truncated: boolean } {
216
+ if (Buffer.byteLength(value, "utf8") <= maximumBytes) return { text: value, truncated: false };
217
+ let end = 0;
218
+ let bytes = 0;
219
+ for (const part of new Intl.Segmenter("en-US", { granularity: "grapheme" }).segment(value)) {
220
+ const width = Buffer.byteLength(part.segment, "utf8");
221
+ if (bytes + width > maximumBytes) break;
222
+ bytes += width;
223
+ end = part.index + part.segment.length;
224
+ }
225
+ return { text: value.slice(0, end), truncated: true };
226
+ }
227
+
228
+ function enclosingHeadings(
229
+ text: string,
230
+ headings: readonly HeadingLine[],
231
+ passageStart: number,
232
+ passageEnd: number,
233
+ ): readonly SelectedPassageHeading[] {
234
+ const stack: HeadingLine[] = [];
235
+ const pop = (level: number): void => {
236
+ while (stack.length > 0 && stack[stack.length - 1]!.level >= level) stack.pop();
237
+ };
238
+ for (const heading of headings) {
239
+ if (heading.start >= passageEnd) break;
240
+ if (heading.start >= passageStart) {
241
+ // Leading headings inside the passage close earlier sections but are already in the text.
242
+ const before = text.slice(passageStart, heading.start).split("\n");
243
+ if (!before.every((line) => line.trim() === "" || /^\s{0,3}#{1,6}(?:\s|$)/u.test(line))) break;
244
+ pop(heading.level);
245
+ continue;
246
+ }
247
+ pop(heading.level);
248
+ stack.push(heading);
249
+ }
250
+ return Object.freeze(stack.map((heading) => {
251
+ // A window may begin inside a long heading line; the context span stops where the passage starts.
252
+ const end = Math.min(heading.end, passageStart);
253
+ const clipped = graphemePrefix(text.slice(heading.start, end), SELECTED_PASSAGE_MAX_HEADING_BYTES);
254
+ const startByte = Buffer.byteLength(text.slice(0, heading.start), "utf8");
255
+ return Object.freeze({
256
+ level: heading.level,
257
+ text: clipped.text,
258
+ startByte,
259
+ endByte: startByte + Buffer.byteLength(clipped.text, "utf8"),
260
+ startLine: heading.line,
261
+ endLine: heading.line,
262
+ truncated: clipped.truncated || end < heading.end,
263
+ });
264
+ }));
265
+ }
266
+
267
+ type Candidate = {
268
+ start: number;
269
+ end: number;
270
+ startByte: number;
271
+ endByte: number;
272
+ regionStart: number;
273
+ regionEnd: number;
274
+ score: number;
275
+ };
276
+
277
+ /**
278
+ * Select one exact passage from a note that search already returned. Ranking,
279
+ * retrieval evidence, and reranker input are unaffected.
280
+ *
281
+ * Passages score by distinct non-stop-word query terms with a small density
282
+ * tie-break, so term order and repetition do not matter. Closed opening
283
+ * frontmatter is skipped, headings travel with the following paragraph instead
284
+ * of scoring on their own when body text exists, fenced code stays eligible,
285
+ * and paragraphs longer than the budget use half-overlapping grapheme-aligned
286
+ * byte windows. Every bound reports a status rather than a partial scan.
287
+ */
288
+ export function selectSearchPassage(input: {
289
+ readonly query: string;
290
+ readonly text: string;
291
+ readonly budget: SelectedPassageBudget;
292
+ }): SelectedPassageResult {
293
+ const { query, text, budget } = input;
294
+ if (typeof query !== "string" || Buffer.byteLength(query, "utf8") > MAX_QUERY_BYTES) {
295
+ return unavailable("query-limit");
296
+ }
297
+ if (typeof text !== "string" || !wellFormed(text)) return unavailable("invalid-source");
298
+ const allTerms = new Set([...normalize(query).matchAll(WORD)].map((match) => match[0]));
299
+ if (allTerms.size > MAX_QUERY_TERMS) return unavailable("query-limit");
300
+ const terms = new Set([...allTerms].filter((term) => !STOP_WORDS.has(term)));
301
+ if (terms.size === 0) return none("no-query-terms");
302
+ const sourceBytes = Buffer.byteLength(text, "utf8");
303
+ if (sourceBytes > SELECTED_PASSAGE_MAX_SOURCE_BYTES) return unavailable("source-limit");
304
+ if (!budget.take(sourceBytes)) return unavailable("search-budget");
305
+ if (text.length === 0) return none("no-lexical-match");
306
+
307
+ const maxBytes = SELECTED_PASSAGE_MAX_BYTES;
308
+ const { bodyStart, lineStarts, paragraphs, headings } = sourceLayout(text);
309
+ const headingStarts = new Set(headings.map(({ start }) => start));
310
+ const headingOnlyFile = paragraphs.every((paragraph) => paragraph.headingOnly);
311
+ const positions = [bodyStart];
312
+ const bytePositions = [Buffer.byteLength(text.slice(0, bodyStart), "utf8")];
313
+ let bytes = bytePositions[0]!;
314
+ for (const part of new Intl.Segmenter("en-US", { granularity: "grapheme" }).segment(text.slice(bodyStart))) {
315
+ const width = Buffer.byteLength(part.segment, "utf8");
316
+ if (width > maxBytes) return unavailable("grapheme-limit");
317
+ bytes += width;
318
+ positions.push(bodyStart + part.index + part.segment.length);
319
+ bytePositions.push(bytes);
320
+ }
321
+
322
+ const tokens: { start: number; end: number; term: string | undefined }[] = [];
323
+ const tokenStarts: number[] = [];
324
+ for (const match of text.slice(bodyStart).matchAll(WORD)) {
325
+ const start = bodyStart + match.index;
326
+ const term = normalize(match[0]);
327
+ const inHeading = headingStarts.has(lineStarts[upperBound(lineStarts, start) - 1]!);
328
+ tokenStarts.push(start);
329
+ tokens.push({
330
+ start,
331
+ end: start + match[0].length,
332
+ term: terms.has(term) && (headingOnlyFile || !inHeading) ? term : undefined,
333
+ });
334
+ }
335
+
336
+ const regions: { start: number; end: number }[] = [];
337
+ let pendingHeading: Paragraph | undefined;
338
+ for (const paragraph of paragraphs) {
339
+ if (paragraph.headingOnly) {
340
+ pendingHeading = pendingHeading === undefined
341
+ ? paragraph
342
+ : { ...pendingHeading, end: paragraph.end };
343
+ } else {
344
+ regions.push({ start: pendingHeading?.start ?? paragraph.start, end: paragraph.end });
345
+ pendingHeading = undefined;
346
+ }
347
+ }
348
+ if (regions.length === 0 && pendingHeading !== undefined) regions.push(pendingHeading);
349
+
350
+ let best: Candidate | undefined;
351
+ let considered = 0;
352
+ for (const region of regions) {
353
+ // Line and paragraph boundaries must also be grapheme boundaries; CRLF stays whole.
354
+ const first = lowerBound(positions, region.start);
355
+ const last = upperBound(positions, region.end) - 1;
356
+ if (first >= last) continue;
357
+ let startIndex = first;
358
+ for (;;) {
359
+ const endIndex = Math.min(last, upperBound(bytePositions, bytePositions[startIndex]! + maxBytes) - 1);
360
+ if (endIndex <= startIndex) return unavailable("grapheme-limit");
361
+ if (++considered > MAX_CANDIDATES) return unavailable("candidate-limit");
362
+ const start = positions[startIndex]!;
363
+ const end = positions[endIndex]!;
364
+ const matched = new Set<string>();
365
+ let tokenCount = 0;
366
+ for (let index = lowerBound(tokenStarts, start); index < tokens.length; index++) {
367
+ const token = tokens[index]!;
368
+ if (token.start >= end) break;
369
+ if (token.end > end) continue;
370
+ tokenCount++;
371
+ if (token.term !== undefined) matched.add(token.term);
372
+ }
373
+ if (matched.size > 0) {
374
+ const score = matched.size + matched.size / (tokenCount + 1) / 10;
375
+ // Candidates arrive in source order, so a strict comparison keeps the earliest tie.
376
+ if (best === undefined || score > best.score) {
377
+ best = {
378
+ start,
379
+ end,
380
+ startByte: bytePositions[startIndex]!,
381
+ endByte: bytePositions[endIndex]!,
382
+ regionStart: positions[first]!,
383
+ regionEnd: positions[last]!,
384
+ score,
385
+ };
386
+ }
387
+ }
388
+ if (endIndex === last) break;
389
+ const stepped = lowerBound(bytePositions, bytePositions[startIndex]! + Math.max(1, Math.floor(maxBytes / 2)));
390
+ const finalStart = lowerBound(bytePositions, bytePositions[last]! - maxBytes);
391
+ startIndex = Math.max(startIndex + 1, Math.min(stepped, finalStart));
392
+ }
393
+ }
394
+ if (best === undefined) return none("no-lexical-match");
395
+
396
+ return Object.freeze({
397
+ status: "selected",
398
+ text: text.slice(best.start, best.end),
399
+ startByte: best.startByte,
400
+ endByte: best.endByte,
401
+ startLine: upperBound(lineStarts, best.start),
402
+ endLine: upperBound(lineStarts, best.end - 1),
403
+ sourceSha256: createHash("sha256").update(text, "utf8").digest("hex"),
404
+ sourceEncoding: "utf8-snapshot",
405
+ clippedStart: best.start > best.regionStart,
406
+ clippedEnd: best.end < best.regionEnd,
407
+ headings: enclosingHeadings(text, headings, best.start, best.end),
408
+ });
409
+ }
@@ -2,6 +2,6 @@
2
2
  export const supportProfile = {
3
3
  id: "kb",
4
4
  name: "Wordcell",
5
- valueProposition: "Support ongoing development of inspectable Markdown memory.",
5
+ valueProposition: "Support ongoing development of Wordcell.",
6
6
  updates: false,
7
7
  } as const;
package/src/support.ts CHANGED
@@ -52,6 +52,9 @@ export function isUsefulSupportResult(args: readonly string[], environment: Read
52
52
  const metadata = parseUrlMetadataArguments(command.arguments, environment);
53
53
  return metadata.ok && metadata.value.kind === "backfill";
54
54
  }
55
+ case "mcp":
56
+ // stdout belongs to JSON-RPC frames, so a long-running server never invites.
57
+ return false;
55
58
  default:
56
59
  return false;
57
60
  }