@hraness/wordcell 0.22.4 → 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 (44) 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 +15 -15
  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-q7tbg07z.js → index-42tb8qqp.js} +7 -7
  13. package/dist/{index-9h7zh0wt.js → index-8bgnhjgq.js} +17 -16
  14. package/dist/{index-233z9wmn.js → index-8v6k9h4r.js} +13 -5
  15. package/dist/{index-vnybgywh.js → index-aer0jdrq.js} +1 -1
  16. package/dist/{index-xv80vzfp.js → index-f75zmmdr.js} +4 -4
  17. package/dist/{index-ecqpgr5y.js → index-f7fnww7a.js} +1 -1
  18. package/dist/{index-djrcc1yf.js → index-gtwqye5a.js} +4 -4
  19. package/dist/{index-bqtqeak3.js → index-h2qn3y3f.js} +4 -4
  20. package/dist/{index-rr9kzq4n.js → index-n57tewfr.js} +2 -2
  21. package/dist/{index-76dbn6pe.js → index-n9kwenpt.js} +1 -1
  22. package/dist/{index-n386evxj.js → index-p96kb6t2.js} +1 -1
  23. package/dist/{index-tf4wzjew.js → index-pz2b2x0y.js} +1 -1
  24. package/dist/{index-jk1sgx36.js → index-qdb8f8va.js} +1 -1
  25. package/dist/{index-pnt9wr59.js → index-xzvcw9ga.js} +2 -2
  26. package/dist/{index-qbssx940.js → index-zy7an84p.js} +223 -8
  27. package/dist/index.js +20 -14
  28. package/dist/percolate.js +2 -2
  29. package/dist/portfolio.js +10 -10
  30. package/dist/publish.js +3 -3
  31. package/dist/sdk.js +9 -9
  32. package/dist/search.js +2 -2
  33. package/dist/semantic.js +4 -4
  34. package/dist/{support-m3t917ev.js → support-jf9v10j7.js} +14 -14
  35. package/dist/workflows/decision-context.js +10 -10
  36. package/dist/workflows/index.js +10 -10
  37. package/package.json +5 -4
  38. package/skills/wordcell/SKILL.md +1 -1
  39. package/skills/wordcell/references/plan-structure.md +1 -1
  40. package/skills/wordcell/references/url-platforms.md +1 -1
  41. package/src/cli-program.ts +4 -2
  42. package/src/graph.ts +79 -9
  43. package/src/mention-index.ts +194 -0
  44. package/src/vault.ts +26 -2
@@ -2,12 +2,12 @@
2
2
  import {
3
3
  parseArguments as parseArguments2,
4
4
  parseUrlMetadataArguments
5
- } from "./index-9h7zh0wt.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,22 +28,22 @@ 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-pnt9wr59.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";
@@ -53,9 +53,9 @@ 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.4",
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.3/hraness-wordcell-0.22.3.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.3/hraness-wordcell-0.22.3.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.4/hraness-wordcell-0.22.4.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
+ }
package/src/vault.ts CHANGED
@@ -13,6 +13,7 @@ import { basename, dirname, join, relative, resolve, sep } from "node:path";
13
13
 
14
14
  import {
15
15
  analyzeVault,
16
+ analyzeVaultComplete,
16
17
  isCanonicalNoteId,
17
18
  lookupNote,
18
19
  MAX_ANALYZED_NOTES,
@@ -21,6 +22,7 @@ import {
21
22
  renderCatalog,
22
23
  replaceCatalog,
23
24
  type AnalyzeVaultOptions,
25
+ type CompleteAnalyzeVaultOptions,
24
26
  type Note,
25
27
  type VaultAnalysis,
26
28
  } from "./graph.js";
@@ -90,6 +92,8 @@ export type ScanVaultOptions = Omit<AnalyzeVaultOptions, "mentionScope"> & {
90
92
  readonly mentionScope?: string | false;
91
93
  };
92
94
 
95
+ export type CompleteScanVaultOptions = ScanVaultOptions & Pick<CompleteAnalyzeVaultOptions, "mentionIndexLimits">;
96
+
93
97
  export async function markdownFiles(
94
98
  directory: string,
95
99
  ignoredDirectories: ReadonlySet<string> = defaultIgnoredDirectories,
@@ -520,8 +524,9 @@ async function atomicReplace(
520
524
 
521
525
  async function snapshot(
522
526
  rootInput: string,
523
- options: ScanVaultOptions,
527
+ options: CompleteScanVaultOptions,
524
528
  writeIndex: boolean,
529
+ completeMentions = false,
525
530
  ): Promise<VaultSnapshot> {
526
531
  const requestedRoot = resolve(rootInput);
527
532
  const root = await realpath(requestedRoot);
@@ -613,7 +618,7 @@ async function snapshot(
613
618
  catalogMode,
614
619
  index,
615
620
  notes,
616
- analysis: analyzeVault(notes, {
621
+ analysis: (completeMentions ? analyzeVaultComplete : analyzeVault)(notes, {
617
622
  catalogNoteId,
618
623
  ...(options.includeInSuggestions === undefined
619
624
  ? {}
@@ -631,6 +636,9 @@ async function snapshot(
631
636
  ...(options.maxMentions === undefined
632
637
  ? {}
633
638
  : { maxMentions: options.maxMentions }),
639
+ ...(!completeMentions || options.mentionIndexLimits === undefined
640
+ ? {}
641
+ : { mentionIndexLimits: options.mentionIndexLimits }),
634
642
  }),
635
643
  };
636
644
  }
@@ -648,3 +656,19 @@ export async function refreshVault(
648
656
  ): Promise<VaultSnapshot> {
649
657
  return snapshot(root, options, true);
650
658
  }
659
+
660
+ /** Read every admitted note and compute the full graph through the phrase index. */
661
+ export async function scanVaultComplete(
662
+ root = ".",
663
+ options: CompleteScanVaultOptions = {},
664
+ ): Promise<VaultSnapshot> {
665
+ return snapshot(root, options, false, true);
666
+ }
667
+
668
+ /** Refresh the configured catalog while retaining complete indexed analysis. */
669
+ export async function refreshVaultComplete(
670
+ root = ".",
671
+ options: CompleteScanVaultOptions = {},
672
+ ): Promise<VaultSnapshot> {
673
+ return snapshot(root, options, true, true);
674
+ }