@hraness/wordcell 0.22.3 → 0.22.5

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 (53) hide show
  1. package/README.md +20 -17
  2. package/dist/authoring.js +2 -2
  3. package/dist/{authority-06eqhqam.js → authority-3ga47syb.js} +2 -2
  4. package/dist/benchmark.js +3 -3
  5. package/dist/cli.js +16 -16
  6. package/dist/evaluation-builder.js +10 -10
  7. package/dist/evaluation-kb.js +10 -10
  8. package/dist/graph-authority.js +4 -4
  9. package/dist/graph-percolation.js +6 -6
  10. package/dist/graph.js +3 -1
  11. package/dist/{index-1k595z6v.js → index-1r5rnkfr.js} +2 -2
  12. package/dist/{index-3agn8scn.js → index-23m6bbjt.js} +47 -11
  13. package/dist/{index-q7tbg07z.js → index-42tb8qqp.js} +7 -7
  14. package/dist/{index-yx6p1vzc.js → index-8bgnhjgq.js} +17 -16
  15. package/dist/{index-233z9wmn.js → index-8v6k9h4r.js} +13 -5
  16. package/dist/{index-vnybgywh.js → index-aer0jdrq.js} +1 -1
  17. package/dist/{index-xv80vzfp.js → index-f75zmmdr.js} +4 -4
  18. package/dist/{index-ecqpgr5y.js → index-f7fnww7a.js} +1 -1
  19. package/dist/{index-djrcc1yf.js → index-gtwqye5a.js} +4 -4
  20. package/dist/{index-bqtqeak3.js → index-h2qn3y3f.js} +4 -4
  21. package/dist/{index-rr9kzq4n.js → index-n57tewfr.js} +2 -2
  22. package/dist/{index-76dbn6pe.js → index-n9kwenpt.js} +1 -1
  23. package/dist/{index-n386evxj.js → index-p96kb6t2.js} +1 -1
  24. package/dist/{index-tf4wzjew.js → index-pz2b2x0y.js} +1 -1
  25. package/dist/{index-jk1sgx36.js → index-qdb8f8va.js} +1 -1
  26. package/dist/{index-a3j61wz9.js → index-xzvcw9ga.js} +201 -131
  27. package/dist/{index-qbssx940.js → index-zy7an84p.js} +223 -8
  28. package/dist/index.js +21 -15
  29. package/dist/percolate.js +2 -2
  30. package/dist/portfolio.js +10 -10
  31. package/dist/publish-reader/reader.js +48 -12
  32. package/dist/publish-search.js +1 -1
  33. package/dist/publish.js +4 -4
  34. package/dist/sdk.js +9 -9
  35. package/dist/search.js +2 -2
  36. package/dist/semantic.js +4 -4
  37. package/dist/{support-vm8fw4yc.js → support-jf9v10j7.js} +15 -15
  38. package/dist/workflows/decision-context.js +10 -10
  39. package/dist/workflows/index.js +10 -10
  40. package/package.json +5 -4
  41. package/skills/wordcell/SKILL.md +1 -1
  42. package/skills/wordcell/references/plan-structure.md +1 -1
  43. package/skills/wordcell/references/url-platforms.md +1 -1
  44. package/src/cli-program.ts +4 -2
  45. package/src/graph.ts +79 -9
  46. package/src/mention-index.ts +194 -0
  47. package/src/publish-index.ts +3 -2
  48. package/src/publish-markdown.ts +55 -0
  49. package/src/publish-model.ts +2 -2
  50. package/src/publish-reader/reader.ts +3 -1
  51. package/src/publish-search.ts +44 -13
  52. package/src/publish.ts +4 -1
  53. package/src/vault.ts +26 -2
@@ -2,12 +2,12 @@
2
2
  import {
3
3
  parseArguments as parseArguments2,
4
4
  parseUrlMetadataArguments
5
- } from "./index-yx6p1vzc.js";
5
+ } from "./index-8bgnhjgq.js";
6
6
  import"./index-bcknqxrq.js";
7
7
  import {
8
8
  parsePdfArguments
9
9
  } from "./index-054mb7d3.js";
10
- import"./index-bqtqeak3.js";
10
+ import"./index-h2qn3y3f.js";
11
11
  import"./index-j4zgmzjr.js";
12
12
  import"./index-de2w8crk.js";
13
13
  import"./index-6jcz0m1c.js";
@@ -28,34 +28,34 @@ import"./index-mxxxytys.js";
28
28
  import"./index-23z4zxgg.js";
29
29
  import"./index-pj501bh1.js";
30
30
  import"./index-9rf81m0p.js";
31
- import"./index-1k595z6v.js";
32
- import"./index-q7tbg07z.js";
31
+ import"./index-1r5rnkfr.js";
32
+ import"./index-42tb8qqp.js";
33
33
  import"./index-adx6khj5.js";
34
- import"./index-rr9kzq4n.js";
34
+ import"./index-n57tewfr.js";
35
35
  import"./index-4j3tt0c3.js";
36
36
  import"./index-j70m75wd.js";
37
37
  import"./index-b88v3vtm.js";
38
- import"./index-djrcc1yf.js";
39
- import"./index-jk1sgx36.js";
40
- import"./index-xv80vzfp.js";
41
- import"./index-tf4wzjew.js";
38
+ import"./index-gtwqye5a.js";
39
+ import"./index-qdb8f8va.js";
40
+ import"./index-f75zmmdr.js";
41
+ import"./index-pz2b2x0y.js";
42
42
  import"./index-1gwbassd.js";
43
- import"./index-vnybgywh.js";
43
+ import"./index-aer0jdrq.js";
44
44
  import"./index-1xxnjn0d.js";
45
- import"./index-a3j61wz9.js";
46
- import"./index-233z9wmn.js";
45
+ import"./index-xzvcw9ga.js";
46
+ import"./index-8v6k9h4r.js";
47
47
  import"./index-d13v9ckt.js";
48
48
  import"./index-48pz4jpc.js";
49
49
  import"./index-06c9ctr6.js";
50
50
  import"./index-4knsp9qj.js";
51
51
  import"./index-66pshdtx.js";
52
- import"./index-3agn8scn.js";
52
+ import"./index-23m6bbjt.js";
53
53
  import"./index-hya40gb2.js";
54
54
  import"./index-5vwpzb5a.js";
55
55
  import"./index-x3fthpsc.js";
56
- import"./index-ecqpgr5y.js";
56
+ import"./index-f7fnww7a.js";
57
57
  import"./index-3rm7cz6h.js";
58
- import"./index-qbssx940.js";
58
+ import"./index-zy7an84p.js";
59
59
  import"./index-z1w83f81.js";
60
60
 
61
61
  // src/support.ts
@@ -1,27 +1,27 @@
1
1
  // @bun
2
2
  import {
3
3
  decisionContextWorkflow
4
- } from "../index-76dbn6pe.js";
4
+ } from "../index-n9kwenpt.js";
5
5
  import"../index-h170byqw.js";
6
- import"../index-q7tbg07z.js";
6
+ import"../index-42tb8qqp.js";
7
7
  import"../index-adx6khj5.js";
8
- import"../index-rr9kzq4n.js";
8
+ import"../index-n57tewfr.js";
9
9
  import"../index-4j3tt0c3.js";
10
10
  import"../index-j70m75wd.js";
11
- import"../index-djrcc1yf.js";
12
- import"../index-jk1sgx36.js";
13
- import"../index-xv80vzfp.js";
14
- import"../index-tf4wzjew.js";
11
+ import"../index-gtwqye5a.js";
12
+ import"../index-qdb8f8va.js";
13
+ import"../index-f75zmmdr.js";
14
+ import"../index-pz2b2x0y.js";
15
15
  import"../index-1gwbassd.js";
16
- import"../index-vnybgywh.js";
16
+ import"../index-aer0jdrq.js";
17
17
  import"../index-1xxnjn0d.js";
18
- import"../index-233z9wmn.js";
18
+ import"../index-8v6k9h4r.js";
19
19
  import"../index-d13v9ckt.js";
20
20
  import"../index-48pz4jpc.js";
21
21
  import"../index-06c9ctr6.js";
22
22
  import"../index-5vwpzb5a.js";
23
23
  import"../index-3rm7cz6h.js";
24
- import"../index-qbssx940.js";
24
+ import"../index-zy7an84p.js";
25
25
  import"../index-z1w83f81.js";
26
26
  export {
27
27
  decisionContextWorkflow
@@ -1,7 +1,7 @@
1
1
  // @bun
2
2
  import {
3
3
  decisionContextWorkflow
4
- } from "../index-76dbn6pe.js";
4
+ } from "../index-n9kwenpt.js";
5
5
  import {
6
6
  explainChangeWorkflow
7
7
  } from "../index-e4fagfjz.js";
@@ -9,25 +9,25 @@ import {
9
9
  planRadarWorkflow
10
10
  } from "../index-rdd5f7jj.js";
11
11
  import"../index-h170byqw.js";
12
- import"../index-q7tbg07z.js";
12
+ import"../index-42tb8qqp.js";
13
13
  import"../index-adx6khj5.js";
14
- import"../index-rr9kzq4n.js";
14
+ import"../index-n57tewfr.js";
15
15
  import"../index-4j3tt0c3.js";
16
16
  import"../index-j70m75wd.js";
17
- import"../index-djrcc1yf.js";
18
- import"../index-jk1sgx36.js";
19
- import"../index-xv80vzfp.js";
20
- import"../index-tf4wzjew.js";
17
+ import"../index-gtwqye5a.js";
18
+ import"../index-qdb8f8va.js";
19
+ import"../index-f75zmmdr.js";
20
+ import"../index-pz2b2x0y.js";
21
21
  import"../index-1gwbassd.js";
22
- import"../index-vnybgywh.js";
22
+ import"../index-aer0jdrq.js";
23
23
  import"../index-1xxnjn0d.js";
24
- import"../index-233z9wmn.js";
24
+ import"../index-8v6k9h4r.js";
25
25
  import"../index-d13v9ckt.js";
26
26
  import"../index-48pz4jpc.js";
27
27
  import"../index-06c9ctr6.js";
28
28
  import"../index-5vwpzb5a.js";
29
29
  import"../index-3rm7cz6h.js";
30
- import"../index-qbssx940.js";
30
+ import"../index-zy7an84p.js";
31
31
  import"../index-z1w83f81.js";
32
32
  export {
33
33
  planRadarWorkflow,
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@hraness/wordcell",
3
- "version": "0.22.3",
4
- "description": "A local Markdown knowledge base with superpowers. Save decisions, sources, and plans as files you own, then recover them through exact and semantic search, typed relationships, backlinks, and Git history.",
3
+ "version": "0.22.5",
4
+ "description": "A local Markdown knowledge base with superpowers. Save decisions, sources, and plans as files you own, then find them by exact words or, with an optional local model, by meaning.",
5
5
  "license": "MIT",
6
6
  "type": "module",
7
7
  "sideEffects": [
@@ -381,6 +381,7 @@
381
381
  "src/evaluation-v2.ts",
382
382
  "src/evaluation.ts",
383
383
  "src/graph.ts",
384
+ "src/mention-index.ts",
384
385
  "src/index.ts",
385
386
  "src/init.ts",
386
387
  "src/navigation.ts",
@@ -479,8 +480,8 @@
479
480
  "check": "bun run check:worker && bun run check:effect && bun run check:workflows && bun run check:portfolio-inventory && bun run check:installed-command-docs && bun run typecheck && bun run build && bun run test:package && bun run test && bun run url-metadata:tool:check",
480
481
  "prepack": "bun run check",
481
482
  "check:effect": "bun run scripts/check-effect-policy.ts",
482
- "kb:search": "bunx --bun --package https://github.com/hraness/wordcell/releases/download/v0.22.2/hraness-wordcell-0.22.2.tgz wordcell search --root kb --mode exact --rerank typesafe --rerank-limit 25",
483
- "kb:search:local": "bunx --bun --package https://github.com/hraness/wordcell/releases/download/v0.22.2/hraness-wordcell-0.22.2.tgz wordcell search --root kb --mode exact",
483
+ "kb:search": "bunx --bun --package https://github.com/hraness/wordcell/releases/download/v0.22.5/hraness-wordcell-0.22.5.tgz wordcell search --root kb --mode exact --rerank typesafe --rerank-limit 25",
484
+ "kb:search:local": "bunx --bun --package https://github.com/hraness/wordcell/releases/download/v0.22.5/hraness-wordcell-0.22.5.tgz wordcell search --root kb --mode exact",
484
485
  "check:worker": "tsc -p worker/tsconfig.json --noEmit"
485
486
  },
486
487
  "dependencies": {
@@ -56,7 +56,7 @@ missing:
56
56
  ```sh
57
57
  command -v wordcell >/dev/null 2>&1 || {
58
58
  command -v bun >/dev/null 2>&1 || exit 1
59
- bun add --global --ignore-scripts https://github.com/hraness/wordcell/releases/download/v0.22.3/hraness-wordcell-0.22.3.tgz
59
+ bun add --global --ignore-scripts https://github.com/hraness/wordcell/releases/download/v0.22.5/hraness-wordcell-0.22.5.tgz
60
60
  }
61
61
  wordcell --help
62
62
  ```
@@ -80,7 +80,7 @@ Add these when execution starts:
80
80
  ```md
81
81
  ## Execution evidence
82
82
 
83
- - YYYY-MM-DD — Result, command or artifact, and what it proved.
83
+ - YYYY-MM-DD: result, command or artifact, and what it proved.
84
84
 
85
85
  ## Review findings
86
86
 
@@ -2,7 +2,7 @@
2
2
 
3
3
  Use the strongest available read route, then describe exactly what it retained. Every route is bounded by item count, tree depth, time, HTML bytes, per-asset bytes, and aggregate asset bytes.
4
4
 
5
- | Surface | Preferred route | Conversation behavior | Honest limit |
5
+ | Surface | Preferred route | Conversation behavior | Limit |
6
6
  | --- | --- | --- | --- |
7
7
  | Generic articles and papers | Structured data or HTTP + Defuddle; rendered browser fallback | Captures the page body and any comments present in that representation | JavaScript-only or unusual layouts can remain partial |
8
8
  | X | Defuddle X extraction plus rendered text; public profile metadata as a separate reviewed surface; current tab or profile for signed-in views | Preserves the complete visible root post or long-form article, quote context, visible metrics, localized post media, and loaded replies while keeping author, continuation, and reply boundaries distinct | Virtualized or unloaded replies and missing trustworthy item trees remain partial; profile bios and links are never inferred from post prose |
@@ -162,7 +162,9 @@ import { MAX_RERANK_CANDIDATES, type SearchReranker } from "./rerank.js";
162
162
  import { createCliTypeSafeReranker } from "./rerank-credentials.js";
163
163
  import {
164
164
  refreshVault,
165
+ refreshVaultComplete,
165
166
  scanVault,
167
+ scanVaultComplete,
166
168
  type ScanVaultOptions,
167
169
  type VaultSnapshot,
168
170
  } from "./vault.js";
@@ -3871,8 +3873,8 @@ async function runVault(
3871
3873
  dependencies: CliDependencies,
3872
3874
  ): Promise<number> {
3873
3875
  const snapshot = command.kind === "refresh"
3874
- ? await (dependencies.refreshVault ?? refreshVault)(command.root, command.options)
3875
- : await (dependencies.scanVault ?? scanVault)(command.root, command.options);
3876
+ ? await (dependencies.refreshVault ?? refreshVaultComplete)(command.root, command.options)
3877
+ : await (dependencies.scanVault ?? (command.kind === "check" ? scanVaultComplete : scanVault))(command.root, command.options);
3876
3878
 
3877
3879
  if (command.kind === "refresh" || command.kind === "check") {
3878
3880
  const noCatalog = command.kind === "check" && command.noCatalog === true;
package/src/graph.ts CHANGED
@@ -9,6 +9,8 @@ import {
9
9
  type Node,
10
10
  } from "yaml";
11
11
  import { parseQualifiedDocumentUri, type QualifiedDocumentUri } from "./portfolio-identity.js";
12
+ import { cleanMentionBoundary, MentionIndexBudgetError, MentionMatcher, preflightMentionInput, type MentionIndexBudgetKind, type MentionIndexLimits } from "./mention-index.js";
13
+ export type { MentionIndexLimits } from "./mention-index.js";
12
14
 
13
15
  export const catalogStart = "<!-- kb:catalog:start -->";
14
16
  export const catalogEnd = "<!-- kb:catalog:end -->";
@@ -22,7 +24,8 @@ export type VaultAnalysisBudgetKind =
22
24
  | "notes"
23
25
  | "connection-observations"
24
26
  | "mention-pairs"
25
- | "mentions";
27
+ | "mentions"
28
+ | MentionIndexBudgetKind;
26
29
 
27
30
  /** A stable failure for callers that need to distinguish bounded graph work. */
28
31
  export class VaultAnalysisBudgetError extends RangeError {
@@ -214,6 +217,11 @@ export type AnalyzeVaultOptions = {
214
217
  catalogNoteId?: string;
215
218
  };
216
219
 
220
+ /** Complete discovery indexes phrases instead of enumerating every note pair. */
221
+ export type CompleteAnalyzeVaultOptions = AnalyzeVaultOptions & {
222
+ readonly mentionIndexLimits?: MentionIndexLimits;
223
+ };
224
+
217
225
  export type VaultAnalysis = {
218
226
  noteCount: number;
219
227
  contextualLinks: readonly ResolvedLink[];
@@ -1015,17 +1023,10 @@ function compareRelationIssues(left: RelationIssue, right: RelationIssue): numbe
1015
1023
  || targetComparison;
1016
1024
  }
1017
1025
 
1018
- const wordCharacter = (value: string): boolean => /[A-Za-z0-9]/.test(value);
1019
-
1020
1026
  function phraseOffset(lowerHaystack: string, lowerPhrase: string): number {
1021
1027
  let offset = lowerHaystack.indexOf(lowerPhrase);
1022
1028
  while (offset !== -1) {
1023
- const before = offset === 0 ? "" : lowerHaystack[offset - 1] ?? "";
1024
- const afterIndex = offset + lowerPhrase.length;
1025
- const after = afterIndex >= lowerHaystack.length ? "" : lowerHaystack[afterIndex] ?? "";
1026
- const startsClean = !wordCharacter(lowerPhrase[0] ?? "") || !wordCharacter(before);
1027
- const endsClean = !wordCharacter(lowerPhrase.at(-1) ?? "") || !wordCharacter(after);
1028
- if (startsClean && endsClean) return offset;
1029
+ if (cleanMentionBoundary(lowerHaystack, lowerPhrase, offset)) return offset;
1029
1030
  offset = lowerHaystack.indexOf(lowerPhrase, offset + 1);
1030
1031
  }
1031
1032
  return -1;
@@ -1093,6 +1094,33 @@ function checkedAnalysisLimit(
1093
1094
  export function analyzeVault(
1094
1095
  notes: readonly Note[],
1095
1096
  options: AnalyzeVaultOptions = {},
1097
+ ): VaultAnalysis {
1098
+ return analyzeVaultInternal(notes, options);
1099
+ }
1100
+
1101
+ /**
1102
+ * Return the same complete graph using a finite phrase index. maxMentionPairs
1103
+ * counts matching source/target candidates; index work and repeated matches
1104
+ * have separate aggregate limits. Exhaustion always throws, never truncates.
1105
+ */
1106
+ export function analyzeVaultComplete(
1107
+ notes: readonly Note[],
1108
+ options: CompleteAnalyzeVaultOptions = {},
1109
+ ): VaultAnalysis {
1110
+ try {
1111
+ return analyzeVaultInternal(notes, options, options.mentionIndexLimits ?? {});
1112
+ } catch (error: unknown) {
1113
+ if (error instanceof MentionIndexBudgetError) {
1114
+ throw new VaultAnalysisBudgetError(error.kind, error.limit, error.message);
1115
+ }
1116
+ throw error;
1117
+ }
1118
+ }
1119
+
1120
+ function analyzeVaultInternal(
1121
+ notes: readonly Note[],
1122
+ options: AnalyzeVaultOptions,
1123
+ mentionIndexLimits?: MentionIndexLimits,
1096
1124
  ): VaultAnalysis {
1097
1125
  const maxNotes = checkedAnalysisLimit(
1098
1126
  options.maxNotes,
@@ -1121,6 +1149,13 @@ export function analyzeVault(
1121
1149
  `Vault analysis exceeds the ${maxNotes} note limit.`,
1122
1150
  );
1123
1151
  }
1152
+ const mentionInput = mentionIndexLimits === undefined ? undefined : preflightMentionInput((function* () {
1153
+ for (const note of notes) {
1154
+ yield note.searchableText;
1155
+ yield note.title;
1156
+ yield* note.aliases;
1157
+ }
1158
+ })(), mentionIndexLimits);
1124
1159
  const catalogNoteId = withoutMarkdownExtension(
1125
1160
  normalizeVaultPath(options.catalogNoteId ?? "index"),
1126
1161
  );
@@ -1332,7 +1367,42 @@ export function analyzeVault(
1332
1367
  : uniquePhrasesByTarget(suggestionNotes);
1333
1368
  const mentions: MentionCandidate[] = [];
1334
1369
  let mentionPairs = 0;
1370
+ const matcher = mentionIndexLimits === undefined ? undefined : new MentionMatcher(
1371
+ [...phrasesByTarget].flatMap(([targetId, phrases]) => phrases.map((phrase, rank) => ({ ...phrase, targetId, rank }))),
1372
+ mentionIndexLimits,
1373
+ mentionInput!.rawInputCodeUnits,
1374
+ mentionInput!.work,
1375
+ );
1376
+ const targetsById = new Map<string, { readonly note: Note; readonly order: number }[]>();
1377
+ if (matcher !== undefined) suggestionNotes.forEach((note, order) => {
1378
+ const targets = targetsById.get(note.id) ?? [];
1379
+ targets.push({ note, order });
1380
+ targetsById.set(note.id, targets);
1381
+ });
1335
1382
  for (const source of suggestionNotes) {
1383
+ if (matcher !== undefined) {
1384
+ if (scopedMentionNotes.length === 0) continue;
1385
+ const found = matcher.scan(source.searchableText.toLocaleLowerCase("en-US"));
1386
+ const targets = [...found].flatMap(([id, match]) => (targetsById.get(id) ?? []).map((target) => ({ ...target, match })))
1387
+ .filter(({ note }) => source.id !== note.id && (options.mentionScope === undefined
1388
+ || scopedMentionIds.has(source.id) || scopedMentionIds.has(note.id)))
1389
+ .sort((left, right) => left.order - right.order);
1390
+ let newlineOffsets: readonly number[] | undefined;
1391
+ for (const { note: target, match } of targets) {
1392
+ if (mentionPairs >= maxMentionPairs) throw new VaultAnalysisBudgetError(
1393
+ "mention-pairs", maxMentionPairs, `Vault analysis exceeds the ${maxMentionPairs} mention-pair limit.`,
1394
+ );
1395
+ mentionPairs += 1;
1396
+ if (linkedPairs.has(pairKey(source.id, target.id))) continue;
1397
+ if (mentions.length >= maxMentions) throw new VaultAnalysisBudgetError(
1398
+ "mentions", maxMentions, `Vault analysis exceeds the ${maxMentions} mention limit.`,
1399
+ );
1400
+ newlineOffsets ??= matcher.newlineOffsets(source.searchableText);
1401
+ mentions.push({ source: source.path, target: target.path, phrase: match.pattern.phrase,
1402
+ line: matcher.lineAt(newlineOffsets, match.offset) });
1403
+ }
1404
+ continue;
1405
+ }
1336
1406
  const targets = options.mentionScope === undefined
1337
1407
  || scopedMentionIds.has(source.id)
1338
1408
  ? suggestionNotes
@@ -0,0 +1,194 @@
1
+ /** Finite UTF-16 multi-pattern matching for complete vault analysis. */
2
+ export type MentionIndexLimits = {
3
+ readonly maxNodes?: number;
4
+ readonly maxWork?: number;
5
+ readonly maxMatches?: number;
6
+ readonly maxInputCodeUnits?: number;
7
+ };
8
+
9
+ export const MENTION_INDEX_LIMITS = {
10
+ maxNodes: 262_144,
11
+ maxWork: 64 * 1_024 * 1_024,
12
+ maxMatches: 1_000_000,
13
+ maxInputCodeUnits: 256 * 1_024 * 1_024,
14
+ } as const;
15
+
16
+ export type MentionIndexBudgetKind =
17
+ | "mention-index-nodes"
18
+ | "mention-index-work"
19
+ | "mention-matches"
20
+ | "mention-input-code-units";
21
+
22
+ export class MentionIndexBudgetError extends RangeError {
23
+ readonly kind: MentionIndexBudgetKind;
24
+ readonly limit: number;
25
+
26
+ constructor(kind: MentionIndexBudgetKind, limit: number) {
27
+ super(`Vault analysis exceeds the ${limit} ${kind} limit.`);
28
+ this.kind = kind;
29
+ this.limit = limit;
30
+ }
31
+ }
32
+
33
+ function checkedLimits(options: MentionIndexLimits): Record<keyof MentionIndexLimits, number> {
34
+ const limits: Record<keyof MentionIndexLimits, number> = { ...MENTION_INDEX_LIMITS };
35
+ for (const name of Object.keys(MENTION_INDEX_LIMITS) as (keyof MentionIndexLimits)[]) {
36
+ const value = options[name] ?? MENTION_INDEX_LIMITS[name];
37
+ if (!Number.isSafeInteger(value) || value < 0 || value > MENTION_INDEX_LIMITS[name]) {
38
+ throw new RangeError(`${name} must be a safe integer from 0 through ${MENTION_INDEX_LIMITS[name]}.`);
39
+ }
40
+ limits[name] = value;
41
+ }
42
+ return limits;
43
+ }
44
+
45
+ /** Admit original strings before trimming, normalizing, or indexing any phrase. */
46
+ export function preflightMentionInput(inputs: Iterable<string>, options: MentionIndexLimits): { rawInputCodeUnits: number; work: number } {
47
+ const limits = checkedLimits(options);
48
+ let rawInputCodeUnits = 0, work = 0;
49
+ for (const input of inputs) {
50
+ rawInputCodeUnits += input.length;
51
+ if (rawInputCodeUnits > limits.maxInputCodeUnits) throw new MentionIndexBudgetError("mention-input-code-units", limits.maxInputCodeUnits);
52
+ work += input.length + 1;
53
+ if (work > limits.maxWork) throw new MentionIndexBudgetError("mention-index-work", limits.maxWork);
54
+ }
55
+ return { rawInputCodeUnits, work };
56
+ }
57
+
58
+ export type MentionPattern = {
59
+ readonly targetId: string;
60
+ readonly phrase: string;
61
+ readonly lowerPhrase: string;
62
+ readonly rank: number;
63
+ };
64
+ export type IndexedMention = { readonly pattern: MentionPattern; readonly offset: number };
65
+ type TrieNode = {
66
+ readonly next: Map<number, number>;
67
+ failure: number;
68
+ output: number;
69
+ pattern: MentionPattern | undefined;
70
+ };
71
+
72
+ /** The legacy matcher uses ASCII word boundaries, even for Unicode phrases. */
73
+ export function cleanMentionBoundary(text: string, phrase: string, offset: number): boolean {
74
+ const word = (value: string): boolean => /[A-Za-z0-9]/.test(value);
75
+ return (!word(phrase[0] ?? "") || !word(text[offset - 1] ?? ""))
76
+ && (!word(phrase.at(-1) ?? "") || !word(text[offset + phrase.length] ?? ""));
77
+ }
78
+
79
+ export class MentionMatcher {
80
+ private readonly limits: Record<keyof MentionIndexLimits, number>;
81
+ private readonly nodes: TrieNode[] = [];
82
+ private work = 0;
83
+ private matches = 0;
84
+ private input = 0;
85
+
86
+ constructor(patterns: readonly MentionPattern[], options: MentionIndexLimits = {}, rawInputCodeUnits = 0, preflightWork = 0) {
87
+ this.limits = checkedLimits(options);
88
+ if (!Number.isSafeInteger(preflightWork) || preflightWork < 0) throw new RangeError("Invalid mention preflight work.");
89
+ if (preflightWork > this.limits.maxWork) this.fail("mention-index-work", this.limits.maxWork);
90
+ this.work = preflightWork;
91
+ if (!Number.isSafeInteger(rawInputCodeUnits) || rawInputCodeUnits < 0) throw new RangeError("Invalid mention input length.");
92
+ if (rawInputCodeUnits > this.limits.maxInputCodeUnits) this.fail("mention-input-code-units", this.limits.maxInputCodeUnits);
93
+ this.node();
94
+ for (const pattern of patterns) {
95
+ this.inputUnits(pattern.lowerPhrase.length);
96
+ let state = 0;
97
+ for (let index = 0; index < pattern.lowerPhrase.length; index += 1) {
98
+ this.step();
99
+ const character = pattern.lowerPhrase.charCodeAt(index);
100
+ let next = this.nodes[state]!.next.get(character);
101
+ if (next === undefined) {
102
+ next = this.node();
103
+ this.nodes[state]!.next.set(character, next);
104
+ }
105
+ state = next;
106
+ }
107
+ if (pattern.lowerPhrase.length === 0 || this.nodes[state]!.pattern !== undefined) {
108
+ throw new TypeError("Mention index requires nonempty, uniquely owned normalized phrases.");
109
+ }
110
+ this.nodes[state]!.pattern = pattern;
111
+ }
112
+ const queue = [...this.nodes[0]!.next.values()];
113
+ for (let cursor = 0; cursor < queue.length; cursor += 1) {
114
+ const state = queue[cursor]!;
115
+ for (const [character, child] of this.nodes[state]!.next) {
116
+ this.step();
117
+ let fallback = this.nodes[state]!.failure;
118
+ while (fallback !== 0 && !this.nodes[fallback]!.next.has(character)) {
119
+ this.step();
120
+ fallback = this.nodes[fallback]!.failure;
121
+ }
122
+ const failure = this.nodes[fallback]!.next.get(character) ?? 0;
123
+ this.nodes[child]!.failure = failure;
124
+ this.nodes[child]!.output = this.nodes[failure]!.pattern === undefined
125
+ ? this.nodes[failure]!.output : failure;
126
+ queue.push(child);
127
+ }
128
+ }
129
+ }
130
+
131
+ private fail(kind: MentionIndexBudgetKind, limit: number): never { throw new MentionIndexBudgetError(kind, limit); }
132
+ private step(): void {
133
+ if (this.work >= this.limits.maxWork) this.fail("mention-index-work", this.limits.maxWork);
134
+ this.work += 1;
135
+ }
136
+ private node(): number {
137
+ if (this.nodes.length >= this.limits.maxNodes) this.fail("mention-index-nodes", this.limits.maxNodes);
138
+ this.nodes.push({ next: new Map(), failure: 0, output: -1, pattern: undefined });
139
+ return this.nodes.length - 1;
140
+ }
141
+ private inputUnits(count: number): void {
142
+ this.input += count;
143
+ if (this.input > this.limits.maxInputCodeUnits) this.fail("mention-input-code-units", this.limits.maxInputCodeUnits);
144
+ }
145
+
146
+ newlineOffsets(originalText: string): readonly number[] {
147
+ const offsets: number[] = [];
148
+ for (let index = 0; index < originalText.length; index += 1) {
149
+ this.step();
150
+ if (originalText.charCodeAt(index) === 10) offsets.push(index);
151
+ }
152
+ return offsets;
153
+ }
154
+
155
+ lineAt(offsets: readonly number[], offset: number): number {
156
+ let low = 0, high = offsets.length;
157
+ while (low < high) {
158
+ this.step();
159
+ const middle = low + Math.floor((high - low) / 2);
160
+ if (offsets[middle]! < offset) low = middle + 1;
161
+ else high = middle;
162
+ }
163
+ return low + 1;
164
+ }
165
+
166
+ scan(lowerText: string): ReadonlyMap<string, IndexedMention> {
167
+ this.inputUnits(lowerText.length);
168
+ const result = new Map<string, IndexedMention>();
169
+ let state = 0;
170
+ for (let index = 0; index < lowerText.length; index += 1) {
171
+ this.step();
172
+ const character = lowerText.charCodeAt(index);
173
+ while (state !== 0 && !this.nodes[state]!.next.has(character)) {
174
+ this.step();
175
+ state = this.nodes[state]!.failure;
176
+ }
177
+ state = this.nodes[state]!.next.get(character) ?? 0;
178
+ let output = this.nodes[state]!.pattern === undefined ? this.nodes[state]!.output : state;
179
+ while (output !== -1) {
180
+ if (this.matches >= this.limits.maxMatches) this.fail("mention-matches", this.limits.maxMatches);
181
+ this.matches += 1; // Count overlapping/repeated outputs before deduplication or boundary filtering.
182
+ const pattern = this.nodes[output]!.pattern!;
183
+ const offset = index + 1 - pattern.lowerPhrase.length;
184
+ const existing = result.get(pattern.targetId);
185
+ if (cleanMentionBoundary(lowerText, pattern.lowerPhrase, offset)
186
+ && (existing === undefined || pattern.rank < existing.pattern.rank)) {
187
+ result.set(pattern.targetId, { pattern, offset });
188
+ }
189
+ output = this.nodes[output]!.output;
190
+ }
191
+ }
192
+ return result;
193
+ }
194
+ }
@@ -1,6 +1,7 @@
1
1
  import { canonicalJson } from "@hraness/oh";
2
2
 
3
3
  import type { Note } from "./graph.js";
4
+ import { projectMarkdownText } from "./publish-markdown.js";
4
5
  import {
5
6
  WORDCELL_SITE_DOCS_FORMAT_V1,
6
7
  WORDCELL_SITE_LIMITS_V1,
@@ -38,8 +39,8 @@ function bounded(value: string, maximumBytes: number): { text: string; truncated
38
39
  }
39
40
 
40
41
  function previewText(note: Note): string {
41
- const basis = note.summary !== "" ? note.summary : note.searchableText;
42
- const collapsed = basis.replace(/\s+/gu, " ").trim();
42
+ const display = projectMarkdownText(note.content, note.properties["description"]);
43
+ const collapsed = display.preview.replace(/\s+/gu, " ").trim();
43
44
  return bounded(collapsed, WORDCELL_SITE_LIMITS_V1.docPreviewBytes).text;
44
45
  }
45
46
 
@@ -108,6 +108,8 @@ type Footnotes = {
108
108
  definitions: number;
109
109
  references: number;
110
110
  disabled: boolean;
111
+ /** Plain display projections omit admitted citation navigation only. */
112
+ readonly plain?: true;
111
113
  };
112
114
 
113
115
  function footnoteLabel(raw: string): string | undefined {
@@ -136,6 +138,7 @@ function renderFootnoteReference(raw: string, label: string, footnotes: Footnote
136
138
  const id = `wordcell:footnote-ref:${number}:${note.references.length + 1}`;
137
139
  note.references.push(id);
138
140
  footnotes.references += 1;
141
+ if (footnotes.plain) return "";
139
142
  return `<sup><a id="${id}" href="#wordcell:footnote:${number}" role="doc-noteref" aria-label="Footnote ${number}">${number}</a></sup>`;
140
143
  }
141
144
 
@@ -820,6 +823,58 @@ export function renderMarkdownToHtml(content: string, ctx: PublishRenderContext)
820
823
  return `${body}${body === "" ? "" : "\n"}<section class="footnotes" aria-label="Footnotes"><ol>\n${notes}\n</ol></section>`;
821
824
  }
822
825
 
826
+ export type PublishMarkdownText = {
827
+ readonly text: string;
828
+ readonly preview: string;
829
+ };
830
+
831
+ /**
832
+ * Readable display text, derived before truncation or case normalization.
833
+ * Shares the article grammar and footnote admission rules; it is not an index
834
+ * or a replacement for authored Markdown. Valid citation navigation disappears,
835
+ * while unresolved definitions/references, escaped text, and code stay literal.
836
+ */
837
+ export function projectMarkdownText(content: string, description?: string): PublishMarkdownText {
838
+ const footnotes: Footnotes = { byLabel: new Map(), numbered: [], definitions: 0, references: 0, disabled: false, plain: true };
839
+ const blocks = parseMarkdownBlocks(content, footnotes, true);
840
+ // Display labels need no filesystem, URL fetch, or selected-note resolution.
841
+ // Unresolved local assets retain their authored alt text through renderInline.
842
+ const context: PublishRenderContext = {
843
+ source: "", resolveNote: () => undefined, resolveAsset: () => undefined, noteHref: () => "",
844
+ };
845
+ const inline = (text: string, state?: Footnotes): string => stripMarkup(renderInline(text, context, state));
846
+ const parts = (items: readonly MarkdownBlock[], state: Footnotes): string[] => items.map((block): string => {
847
+ switch (block.type) {
848
+ case "paragraph": case "heading": return inline(block.text, state);
849
+ case "literal": case "code": return block.text;
850
+ case "break": return "";
851
+ case "quote": return parts(block.blocks, state).join("\n\n");
852
+ case "table": return [block.header, ...block.rows].map((row) => row.map((cell) => inline(cell, state)).join(" ")).join("\n");
853
+ case "list": return block.parts.map((part) => part.type === "item" || part.type === "continuation" ? inline(part.text, state) : "").filter(Boolean).join("\n");
854
+ case "footnote": return state.disabled || block.note.invalid ? block.note.authored : "";
855
+ }
856
+ });
857
+ const rendered = parts(blocks, footnotes);
858
+ // The article orders valid definitions by first reference, then unreferenced
859
+ // definitions. Nested references in their bodies deliberately remain literal.
860
+ if (!footnotes.disabled) for (const note of footnotes.byLabel.values()) {
861
+ if (note !== null && !note.invalid) footnoteNumber(note, footnotes);
862
+ }
863
+ const text = [...rendered, ...footnotes.numbered.map((note) => inline(note.body.replaceAll("\n", " ")))].filter(Boolean).join("\n\n");
864
+ let preview = rendered.find((value, index) => blocks[index]?.type === "paragraph" && value.trim() !== "") ?? text;
865
+ if (description !== undefined && description.trim() !== "") {
866
+ // Resolve description citations against the same authored definitions, but
867
+ // never let preview rendering consume the article's citation allowance.
868
+ const state: Footnotes = {
869
+ byLabel: new Map([...footnotes.byLabel].map(([label, note]) => [label, note === null ? null : { body: note.body, authored: note.authored, invalid: note.invalid, references: [] }])),
870
+ numbered: [], definitions: footnotes.definitions, references: 0, disabled: footnotes.disabled, plain: true,
871
+ };
872
+ const authored = parts(parseMarkdownBlocks(description, state, false), state).filter(Boolean).join("\n\n");
873
+ if (authored.trim() !== "") preview = authored;
874
+ }
875
+ return { text, preview };
876
+ }
877
+
823
878
  export function stripMarkup(html: string): string {
824
879
  // Scan literal tags first — escaped angle brackets (&lt;) decode to text,
825
880
  // never tag boundaries. The character scanner (not a regex) keeps CodeQL's