@hraness/kb 0.17.1

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 (200) hide show
  1. package/DISCLOSURE +43 -0
  2. package/LICENSE +21 -0
  3. package/README.md +545 -0
  4. package/dist/agent-context.js +35 -0
  5. package/dist/agent-guide-audit.js +18 -0
  6. package/dist/attachments.js +19 -0
  7. package/dist/authoring.js +33 -0
  8. package/dist/benchmark.js +19 -0
  9. package/dist/capture.js +49 -0
  10. package/dist/cli.js +3646 -0
  11. package/dist/clip/acquire.js +57 -0
  12. package/dist/clip/args.js +21 -0
  13. package/dist/clip/bounded-byte-buffer.js +9 -0
  14. package/dist/clip/browser-profiles.js +17 -0
  15. package/dist/clip/bundle-reader.js +11 -0
  16. package/dist/clip/cli.js +27 -0
  17. package/dist/clip/cookies.js +21 -0
  18. package/dist/clip/defuddle-worker.js +84 -0
  19. package/dist/clip/doctor.js +32 -0
  20. package/dist/clip/jobs.js +784 -0
  21. package/dist/clip/network-proxy.js +9 -0
  22. package/dist/clip/network.js +28 -0
  23. package/dist/clip/persist.js +30 -0
  24. package/dist/clip/refresh.js +11 -0
  25. package/dist/clip/terminal.js +11 -0
  26. package/dist/evaluation-builder.js +12238 -0
  27. package/dist/evaluation-kb.js +26 -0
  28. package/dist/evaluation.js +35 -0
  29. package/dist/git.js +39 -0
  30. package/dist/graph.js +39 -0
  31. package/dist/index-01jj6rbv.js +1103 -0
  32. package/dist/index-06c9ctr6.js +738 -0
  33. package/dist/index-0kavxzqj.js +2480 -0
  34. package/dist/index-1gwbassd.js +743 -0
  35. package/dist/index-1n418kb9.js +827 -0
  36. package/dist/index-1vrd1rmn.js +56 -0
  37. package/dist/index-1xxnjn0d.js +171 -0
  38. package/dist/index-3rm7cz6h.js +373 -0
  39. package/dist/index-3v2z4f0q.js +328 -0
  40. package/dist/index-48pz4jpc.js +392 -0
  41. package/dist/index-4j3tt0c3.js +234 -0
  42. package/dist/index-5n05se68.js +307 -0
  43. package/dist/index-5vwpzb5a.js +764 -0
  44. package/dist/index-6g2pv9d2.js +370 -0
  45. package/dist/index-7fzc32gf.js +712 -0
  46. package/dist/index-84x0vjjp.js +432 -0
  47. package/dist/index-adx6khj5.js +488 -0
  48. package/dist/index-b88v3vtm.js +805 -0
  49. package/dist/index-cv6fh7z5.js +462 -0
  50. package/dist/index-cxfrakt7.js +1067 -0
  51. package/dist/index-d13v9ckt.js +150 -0
  52. package/dist/index-dycjbhw2.js +1943 -0
  53. package/dist/index-dyqwejk5.js +531 -0
  54. package/dist/index-e5fbsywq.js +658 -0
  55. package/dist/index-f984hw45.js +927 -0
  56. package/dist/index-gh719d91.js +63 -0
  57. package/dist/index-hgve9rh2.js +2046 -0
  58. package/dist/index-hya40gb2.js +474 -0
  59. package/dist/index-j4zgmzjr.js +152 -0
  60. package/dist/index-jsmvyyvf.js +1166 -0
  61. package/dist/index-kh0pwvdd.js +1974 -0
  62. package/dist/index-mqx4nd6v.js +119 -0
  63. package/dist/index-mxxxytys.js +1254 -0
  64. package/dist/index-n5dd7r0v.js +711 -0
  65. package/dist/index-npg9z1a4.js +431 -0
  66. package/dist/index-pj501bh1.js +140 -0
  67. package/dist/index-qry4vhxk.js +1041 -0
  68. package/dist/index-s2gw5aw9.js +199 -0
  69. package/dist/index-vvdj6kca.js +70 -0
  70. package/dist/index-w2zc0vwa.js +571 -0
  71. package/dist/index-x3fthpsc.js +585 -0
  72. package/dist/index-zr53sf63.js +44 -0
  73. package/dist/index-zxdy5pby.js +2728 -0
  74. package/dist/index-zzhgcwyt.js +966 -0
  75. package/dist/index.js +453 -0
  76. package/dist/navigation.js +13 -0
  77. package/dist/pdf.js +85 -0
  78. package/dist/percolate.js +24 -0
  79. package/dist/portfolio.js +77 -0
  80. package/dist/query.js +29 -0
  81. package/dist/repository-memory.js +60 -0
  82. package/dist/sdk.js +36 -0
  83. package/dist/search-rules.js +34 -0
  84. package/dist/search.js +22 -0
  85. package/dist/semantic.js +39 -0
  86. package/dist/source-inbox.js +17 -0
  87. package/dist/untrusted-content.js +32 -0
  88. package/dist/url-intelligence.js +74 -0
  89. package/dist/workflow.js +23 -0
  90. package/dist/workflows/decision-context.js +20 -0
  91. package/dist/workflows/explain-change.js +8 -0
  92. package/dist/workflows/index.js +28 -0
  93. package/dist/workflows/plan-radar.js +10 -0
  94. package/package.json +409 -0
  95. package/skills/kb/AGENTS.md +19 -0
  96. package/skills/kb/SKILL.md +82 -0
  97. package/skills/kb/agents/openai.yaml +4 -0
  98. package/skills/kb/references/pdf-review.md +51 -0
  99. package/skills/kb/references/percolate.md +144 -0
  100. package/skills/kb/references/plan-structure.md +105 -0
  101. package/skills/kb/references/plan.md +115 -0
  102. package/skills/kb/references/query.md +231 -0
  103. package/skills/kb/references/refresh.md +133 -0
  104. package/skills/kb/references/save-pdf.md +125 -0
  105. package/skills/kb/references/save-url.md +178 -0
  106. package/skills/kb/references/url-authentication.md +75 -0
  107. package/skills/kb/references/url-platforms.md +25 -0
  108. package/src/agent-context.ts +1241 -0
  109. package/src/agent-guide-audit.ts +750 -0
  110. package/src/attachments.ts +723 -0
  111. package/src/authoring.ts +1725 -0
  112. package/src/benchmark.ts +362 -0
  113. package/src/capture.ts +68 -0
  114. package/src/cli.ts +3660 -0
  115. package/src/clip/acquire.ts +1273 -0
  116. package/src/clip/archive-today.ts +402 -0
  117. package/src/clip/args.ts +423 -0
  118. package/src/clip/assets.ts +262 -0
  119. package/src/clip/bounded-byte-buffer.ts +75 -0
  120. package/src/clip/browser-profiles.ts +372 -0
  121. package/src/clip/bundle-reader.ts +608 -0
  122. package/src/clip/capture.ts +1054 -0
  123. package/src/clip/cli.ts +181 -0
  124. package/src/clip/cookies.ts +512 -0
  125. package/src/clip/doctor.ts +1110 -0
  126. package/src/clip/extract.ts +868 -0
  127. package/src/clip/jobs.ts +959 -0
  128. package/src/clip/lib.ts +861 -0
  129. package/src/clip/media.ts +1474 -0
  130. package/src/clip/metadata-search-tool/Cargo.lock +2498 -0
  131. package/src/clip/metadata-search-tool/Cargo.toml +18 -0
  132. package/src/clip/metadata-search-tool/runner.ts +685 -0
  133. package/src/clip/metadata-search-tool/src/main.rs +389 -0
  134. package/src/clip/metadata-search.ts +687 -0
  135. package/src/clip/network-proxy.ts +627 -0
  136. package/src/clip/network.ts +950 -0
  137. package/src/clip/package-root.ts +54 -0
  138. package/src/clip/persist.ts +1472 -0
  139. package/src/clip/platforms.ts +1824 -0
  140. package/src/clip/refresh.ts +197 -0
  141. package/src/clip/structured.ts +432 -0
  142. package/src/clip/terminal.ts +197 -0
  143. package/src/clip/url-intelligence.ts +581 -0
  144. package/src/clip/url-metadata-backfill.ts +616 -0
  145. package/src/clip/url-metadata-cli.ts +251 -0
  146. package/src/clip/url-metadata.ts +1397 -0
  147. package/src/evaluation-analysis-v2.ts +1440 -0
  148. package/src/evaluation-builder.ts +2539 -0
  149. package/src/evaluation-corpus-authoring.ts +2052 -0
  150. package/src/evaluation-evidence.ts +1147 -0
  151. package/src/evaluation-execution-v2.ts +426 -0
  152. package/src/evaluation-implementation.ts +131 -0
  153. package/src/evaluation-kb-closure.ts +1430 -0
  154. package/src/evaluation-kb-runner-v2.ts +735 -0
  155. package/src/evaluation-kb-v2.ts +2567 -0
  156. package/src/evaluation-kb.ts +1018 -0
  157. package/src/evaluation-measurement-v2.ts +1462 -0
  158. package/src/evaluation-packing-v2.ts +452 -0
  159. package/src/evaluation-redaction.ts +28 -0
  160. package/src/evaluation-v2.ts +3857 -0
  161. package/src/evaluation.ts +1220 -0
  162. package/src/git.ts +1276 -0
  163. package/src/graph.ts +1487 -0
  164. package/src/index.ts +20 -0
  165. package/src/init.ts +123 -0
  166. package/src/navigation.ts +277 -0
  167. package/src/note-lock.ts +529 -0
  168. package/src/pdf/args.ts +202 -0
  169. package/src/pdf/capture.ts +298 -0
  170. package/src/pdf/cli.ts +159 -0
  171. package/src/pdf/extract.ts +303 -0
  172. package/src/pdf/layout.ts +618 -0
  173. package/src/pdf/markdown.ts +154 -0
  174. package/src/pdf/model.ts +234 -0
  175. package/src/pdf/ocr.ts +190 -0
  176. package/src/pdf/persist.ts +314 -0
  177. package/src/pdf/source.ts +135 -0
  178. package/src/pdf/tools.ts +146 -0
  179. package/src/pdf.ts +92 -0
  180. package/src/percolate.ts +877 -0
  181. package/src/portfolio-audit.ts +574 -0
  182. package/src/portfolio-identity.ts +162 -0
  183. package/src/portfolio-registry.ts +473 -0
  184. package/src/portfolio.ts +765 -0
  185. package/src/query.ts +646 -0
  186. package/src/repository-memory.ts +1150 -0
  187. package/src/sdk.ts +1452 -0
  188. package/src/search-rules.ts +712 -0
  189. package/src/search.ts +710 -0
  190. package/src/semantic-runtime.ts +1139 -0
  191. package/src/semantic.ts +2480 -0
  192. package/src/source-inbox.ts +232 -0
  193. package/src/untrusted-content.ts +401 -0
  194. package/src/url-intelligence.ts +104 -0
  195. package/src/vault.ts +636 -0
  196. package/src/workflow.ts +653 -0
  197. package/src/workflows/decision-context.ts +81 -0
  198. package/src/workflows/explain-change.ts +73 -0
  199. package/src/workflows/index.ts +3 -0
  200. package/src/workflows/plan-radar.ts +101 -0
package/src/index.ts ADDED
@@ -0,0 +1,20 @@
1
+ export * from "./agent-context.js";
2
+ export * from "./agent-guide-audit.js";
3
+ export * from "./authoring.js";
4
+ export * from "./attachments.js";
5
+ export * from "./benchmark.js";
6
+ export * from "./evaluation.js";
7
+ export * from "./evaluation-kb.js";
8
+ export * from "./git.js";
9
+ export * from "./graph.js";
10
+ export * from "./init.js";
11
+ export * from "./navigation.js";
12
+ export * from "./percolate.js";
13
+ export * from "./query.js";
14
+ export * from "./repository-memory.js";
15
+ export * from "./search.js";
16
+ export * from "./semantic.js";
17
+ export * from "./sdk.js";
18
+ export * from "./source-inbox.js";
19
+ export * from "./vault.js";
20
+ export * from "./workflow.js";
package/src/init.ts ADDED
@@ -0,0 +1,123 @@
1
+ import { mkdir, rm, writeFile } from "node:fs/promises";
2
+ import { dirname, join, resolve } from "node:path";
3
+
4
+ export type InitVaultResult = {
5
+ readonly root: string;
6
+ readonly files: readonly string[];
7
+ };
8
+
9
+ const templates = {
10
+ "index.md": `---
11
+ title: Knowledge base
12
+ ---
13
+
14
+ # Knowledge base
15
+
16
+ This vault keeps captured sources separate from maintained notes. Catalog links are navigation; contextual links belong in prose when they help a reader follow a real relationship.
17
+
18
+ <!-- kb:catalog:start -->
19
+ ## Note catalog
20
+
21
+ _No durable notes have been filed yet._
22
+
23
+ <!-- kb:catalog:end -->
24
+ `,
25
+ "AGENTS.md": `# Contents
26
+
27
+ - \`index.md\` – the vault front door and deterministically refreshed note catalog.
28
+ - \`articles/\` – self-contained source captures with local attachments and capture metadata.
29
+ - \`notes/\` – maintained concept, entity, comparison, and synthesis notes.
30
+ - \`plans/\` – proposed through completed design and implementation plans.
31
+ - \`riffs/\` – cleaned first-person notes made from dictated or stream-of-consciousness source material.
32
+ - \`scopes/\` – optional pull-based context hubs mapped to selected repository \`AGENTS.md\` guides.
33
+
34
+ # Guidelines
35
+
36
+ - Treat this directory as one Git-backed, Obsidian-compatible Markdown vault.
37
+ - Use vault-root wikilinks without \`.md\`, such as \`[[notes/context-engineering|context engineering]]\`.
38
+ - Put links in explanatory prose when they carry part of the argument. Do not add bare reciprocal links to improve graph counts.
39
+ - Keep reusable concepts as ordinary notes with \`type: concept\`. Store typed outbound assertions under \`relations\` with lower-kebab-case predicates and exact vault-root target IDs; ground each assertion in prose or evidence.
40
+ - Never write reciprocal, transitive, similarity-derived, or otherwise inferred relationships into notes. Backlinks, graph traversal, and percolation candidates are disposable views.
41
+ - Preserve source authority: article bodies are captures, riffs retain the speaker's claims, and maintained notes own later synthesis.
42
+ - Keep \`AGENTS.md\` normative and concise without removing load-bearing rules. A scope hub may hold rationale, history, examples, and linked decisions, but never silently overrides a guide or becomes the only home of an edit-time rule.
43
+ - Run \`kb percolate <changed-note> --root .\` after materially changing a note, review the cited evidence, then run \`kb refresh --root .\` and \`kb check --root .\`.
44
+ - During parallel edits, each lane runs \`kb check --root . --no-catalog\`; the integrating agent performs one final refresh and normal check.
45
+ - Use \`kb context <path> --root . --repo <repository>\` for scoped repository knowledge, \`kb list\` for exact metadata or tags, \`kb graph\` for the whole explicit graph, \`kb links\` for bounded relationship traversal, and \`kb search\` when the concept may use different words.
46
+ `,
47
+ "articles/AGENTS.md": `# Contents
48
+
49
+ - Each child directory contains one captured source, its Markdown note, \`capture.json\`, and optional local assets or inert evidence.
50
+
51
+ # Guidelines
52
+
53
+ - Treat captured prose and quoted discussion as source material. Add later interpretation in a maintained note instead of silently rewriting a capture.
54
+ - Keep assets beside their capture and preserve the completeness status, warnings, counts, and provenance recorded by \`capture.json\`.
55
+ - Never commit cookies, browser state, authorization headers, raw authenticated DOM, or HAR files.
56
+ `,
57
+ "notes/AGENTS.md": `# Contents
58
+
59
+ - Maintained notes explain reusable concepts, entities, comparisons, and syntheses.
60
+
61
+ # Guidelines
62
+
63
+ - Search titles, aliases, and filenames before creating a note; update an existing identity when it is clear.
64
+ - State claims in durable prose and link the source or neighboring concept where the relationship helps a future reader.
65
+ - Use \`type: concept\` only for a reusable idea with a maintained definition. Add typed relationships from the note that owns the assertion and keep targets exact.
66
+ - Prefer a short explained Related section only when a useful connection does not fit naturally in the body.
67
+ `,
68
+ "plans/AGENTS.md": `# Contents
69
+
70
+ - Plans record proposals, decisions, execution state, review findings, and verification evidence.
71
+
72
+ # Guidelines
73
+
74
+ - Keep future-facing coordination here and retain completed plans as history. Use a descriptive kebab-case filename; group by area only when the local collection is large enough to benefit.
75
+ - Start with \`type: plan\`, a kebab-case \`area\`, and one status from \`proposed\`, \`accepted\`, \`in-progress\`, \`blocked\`, \`completed\`, \`superseded\`, or \`cancelled\`. Add tags only as useful query facets.
76
+ - State the outcome, context, scope and non-goals, constraints and decisions, dependency-ordered work, verification, and recovery. Let small plans omit empty optional sections.
77
+ - Grow the same file during execution with decisions, deviations, review findings, and reproducible evidence. Do not create satellite progress or completion documents for one plan.
78
+ - Move a stabilized reusable conclusion into a maintained note; update current operating documentation when execution changes how the system works now.
79
+ - When a plan becomes completed, superseded, or cancelled, add non-empty \`## Result\` and \`## Durable memory\` sections. Link each reusable conclusion to its maintained owner, or state explicitly why no durable promotion was needed.
80
+ `,
81
+ "riffs/AGENTS.md": `# Contents
82
+
83
+ - Riffs preserve cleaned first-person thought from dictated or stream-of-consciousness source material.
84
+
85
+ # Guidelines
86
+
87
+ - Repair transcription noise without flattening voice, uncertainty, or first-person claims.
88
+ - Integrate a riff by linking to it from maintained synthesis rather than rewriting it to satisfy graph checks.
89
+ `,
90
+ "scopes/AGENTS.md": `# Contents
91
+
92
+ - \`*.md\` – optional deterministic agent-context hubs mapped reciprocally to repository \`AGENTS.md\` guides.
93
+
94
+ # Guidelines
95
+
96
+ - Keep one hub per exact repository-relative directory scope, with \`type: agent-context\` and \`scope\` in frontmatter.
97
+ - Derive the canonical hub path and reciprocal marker with \`kb agents identity <scope>\`; do not reproduce the slug or hash logic by hand.
98
+ - Put rationale, history, examples, evidence, and links here. Keep ownership, prohibitions, required commands, and every rule needed before editing in the guide.
99
+ - Use \`kb agents check --root <vault> --repo <repository>\` after changing a mapping; use \`kb agents audit\` to review guide and inherited-chain size without treating length as correctness.
100
+ `,
101
+ } as const;
102
+
103
+ /** Create a new vault without overwriting or merging into an existing path. */
104
+ export async function initVault(directory: string): Promise<InitVaultResult> {
105
+ const root = resolve(directory);
106
+ const parent = dirname(root);
107
+ if (parent === root) throw new Error("Refusing to initialize a filesystem root as a vault.");
108
+
109
+ await mkdir(root, { recursive: false });
110
+ try {
111
+ const files = Object.keys(templates).sort();
112
+ for (const relativePath of files) {
113
+ const content = templates[relativePath as keyof typeof templates];
114
+ const path = join(root, relativePath);
115
+ await mkdir(dirname(path), { recursive: true });
116
+ await writeFile(path, content, { encoding: "utf8", flag: "wx", mode: 0o644 });
117
+ }
118
+ return { root, files };
119
+ } catch (error) {
120
+ await rm(root, { recursive: true, force: true });
121
+ throw error;
122
+ }
123
+ }
@@ -0,0 +1,277 @@
1
+ import {
2
+ type AuthoredRelation,
3
+ type Note,
4
+ type NoteConnections,
5
+ type ResolvedLink,
6
+ type VaultAnalysis,
7
+ } from "./graph.js";
8
+
9
+ export type LinkDirection = "in" | "out" | "both";
10
+
11
+ export type NavigateLinksOptions = {
12
+ readonly direction?: LinkDirection;
13
+ readonly depth?: number;
14
+ /** Maximum returned nodes, including the starting note. */
15
+ readonly limit?: number;
16
+ };
17
+
18
+ /**
19
+ * Bound raw contextual links and authored relations before building the two
20
+ * directional indexes. Each accepted connection contributes at most one
21
+ * inbound and one outbound index entry.
22
+ */
23
+ export const MAX_NAVIGATION_INDEXED_CONNECTIONS = 100_000;
24
+
25
+ /** Bound returned contextual links and authored relations as one result set. */
26
+ export const MAX_NAVIGATION_RETURNED_CONNECTIONS = 10_000;
27
+
28
+ export type NavigationBudgetKind = "connection-work-limit";
29
+
30
+ export class NavigationBudgetError extends RangeError {
31
+ readonly kind: NavigationBudgetKind;
32
+ readonly limit: number;
33
+
34
+ constructor(kind: NavigationBudgetKind, limit: number, message: string) {
35
+ super(message);
36
+ this.name = "NavigationBudgetError";
37
+ this.kind = kind;
38
+ this.limit = limit;
39
+ }
40
+ }
41
+
42
+ export type LinkNeighborhoodNode = {
43
+ readonly id: string;
44
+ readonly path: string;
45
+ readonly title: string;
46
+ readonly distance: number;
47
+ readonly inboundContextualCount: number;
48
+ readonly outboundContextualCount: number;
49
+ readonly inboundRelationCount: number;
50
+ readonly outboundRelationCount: number;
51
+ };
52
+
53
+ export type LinkNeighborhood = {
54
+ readonly note: string;
55
+ readonly direction: LinkDirection;
56
+ readonly depth: number;
57
+ readonly limit: number;
58
+ readonly truncated: boolean;
59
+ readonly nodes: readonly LinkNeighborhoodNode[];
60
+ readonly edges: readonly ResolvedLink[];
61
+ readonly relations: readonly AuthoredRelation[];
62
+ };
63
+
64
+ function checkedDepth(value: number | undefined): number {
65
+ const depth = value ?? 1;
66
+ if (!Number.isSafeInteger(depth) || depth < 1 || depth > 10) {
67
+ throw new RangeError("Link depth must be an integer from 1 through 10.");
68
+ }
69
+ return depth;
70
+ }
71
+
72
+ function checkedLimit(value: number | undefined): number {
73
+ const limit = value ?? 50;
74
+ if (!Number.isSafeInteger(limit) || limit < 1 || limit > 1_000) {
75
+ throw new RangeError("Link result limit must be an integer from 1 through 1000.");
76
+ }
77
+ return limit;
78
+ }
79
+
80
+ function connectionNode(
81
+ note: Note,
82
+ connection: NoteConnections | undefined,
83
+ distance: number,
84
+ ): LinkNeighborhoodNode {
85
+ return {
86
+ id: note.id,
87
+ path: note.path,
88
+ title: note.title,
89
+ distance,
90
+ inboundContextualCount: connection?.inboundContextualCount ?? 0,
91
+ outboundContextualCount: connection?.outboundContextualCount ?? 0,
92
+ inboundRelationCount: connection?.inboundRelationCount ?? 0,
93
+ outboundRelationCount: connection?.outboundRelationCount ?? 0,
94
+ };
95
+ }
96
+
97
+ function edgeKey(edge: ResolvedLink): string {
98
+ return `${edge.source}\0${edge.target}\0${edge.line}`;
99
+ }
100
+
101
+ function relationKey(relation: AuthoredRelation): string {
102
+ return `${relation.source}\0${relation.predicate}\0${relation.target}`;
103
+ }
104
+
105
+ type TraversalCandidate =
106
+ | {
107
+ readonly kind: "link";
108
+ readonly source: string;
109
+ readonly target: string;
110
+ readonly line: number;
111
+ readonly edge: ResolvedLink;
112
+ }
113
+ | {
114
+ readonly kind: "relation";
115
+ readonly source: string;
116
+ readonly target: string;
117
+ readonly line: number;
118
+ readonly relation: AuthoredRelation;
119
+ };
120
+
121
+ function compareTraversalCandidates(
122
+ left: TraversalCandidate,
123
+ right: TraversalCandidate,
124
+ ): number {
125
+ return left.source.localeCompare(right.source)
126
+ || left.target.localeCompare(right.target)
127
+ || left.kind.localeCompare(right.kind)
128
+ || (left.kind === "relation" && right.kind === "relation"
129
+ ? left.relation.predicate.localeCompare(right.relation.predicate)
130
+ : 0)
131
+ || left.line - right.line;
132
+ }
133
+
134
+ function addCandidate(
135
+ candidates: Map<string, TraversalCandidate[]>,
136
+ path: string,
137
+ candidate: TraversalCandidate,
138
+ ): void {
139
+ const existing = candidates.get(path) ?? [];
140
+ existing.push(candidate);
141
+ candidates.set(path, existing);
142
+ }
143
+
144
+ function candidateKey(candidate: TraversalCandidate): string {
145
+ return candidate.kind === "link"
146
+ ? `link\0${edgeKey(candidate.edge)}`
147
+ : `relation\0${relationKey(candidate.relation)}`;
148
+ }
149
+
150
+ function checkedConnections(
151
+ analysis: VaultAnalysis,
152
+ ): readonly AuthoredRelation[] {
153
+ const authoredRelations = analysis.authoredRelations ?? [];
154
+ const connectionCount = analysis.contextualLinks.length + authoredRelations.length;
155
+ if (connectionCount > MAX_NAVIGATION_INDEXED_CONNECTIONS) {
156
+ throw new NavigationBudgetError(
157
+ "connection-work-limit",
158
+ MAX_NAVIGATION_INDEXED_CONNECTIONS,
159
+ `Link navigation exceeds the ${MAX_NAVIGATION_INDEXED_CONNECTIONS} `
160
+ + "contextual-link and authored-relation observation limit.",
161
+ );
162
+ }
163
+ return authoredRelations;
164
+ }
165
+
166
+ function returnedConnectionLimit(nodeLimit: number): number {
167
+ // Preserve every edge in a simple directed graph of the returned nodes while
168
+ // still bounding parallel typed predicates and hostile duplicate inputs.
169
+ return Math.min(MAX_NAVIGATION_RETURNED_CONNECTIONS, nodeLimit ** 2);
170
+ }
171
+
172
+ /** Traverse explicit contextual edges without deriving relationships from similarity. */
173
+ export function navigateLinks(
174
+ notes: readonly Note[],
175
+ analysis: VaultAnalysis,
176
+ start: Note,
177
+ options: NavigateLinksOptions = {},
178
+ ): LinkNeighborhood {
179
+ const direction = options.direction ?? "both";
180
+ const depth = checkedDepth(options.depth);
181
+ const limit = checkedLimit(options.limit);
182
+ const authoredRelations = checkedConnections(analysis);
183
+ const notesByPath = new Map(notes.map((note) => [note.path, note]));
184
+ const notesById = new Map(notes.map((note) => [note.id, note]));
185
+ const connectionsById = new Map(
186
+ analysis.noteConnections.map((connection) => [connection.id, connection]),
187
+ );
188
+ const inbound = new Map<string, TraversalCandidate[]>();
189
+ const outbound = new Map<string, TraversalCandidate[]>();
190
+ for (const edge of analysis.contextualLinks) {
191
+ const candidate: TraversalCandidate = {
192
+ kind: "link",
193
+ source: edge.source,
194
+ target: edge.target,
195
+ line: edge.line,
196
+ edge,
197
+ };
198
+ addCandidate(inbound, edge.target, candidate);
199
+ addCandidate(outbound, edge.source, candidate);
200
+ }
201
+ for (const relation of authoredRelations) {
202
+ const source = notesById.get(relation.source)?.path;
203
+ const target = notesById.get(relation.target)?.path;
204
+ if (source === undefined || target === undefined) continue;
205
+ const candidate: TraversalCandidate = {
206
+ kind: "relation",
207
+ source,
208
+ target,
209
+ line: relation.provenance.line,
210
+ relation,
211
+ };
212
+ addCandidate(inbound, target, candidate);
213
+ addCandidate(outbound, source, candidate);
214
+ }
215
+
216
+ const distanceByPath = new Map<string, number>([[start.path, 0]]);
217
+ let frontier = [start.path];
218
+ let truncated = false;
219
+ const selectedCandidates = new Map<string, TraversalCandidate>();
220
+ for (let distance = 0; distance < depth && frontier.length > 0; distance += 1) {
221
+ const next = new Set<string>();
222
+ for (const path of frontier.toSorted()) {
223
+ const candidates = [
224
+ ...(direction === "out" || direction === "both" ? outbound.get(path) ?? [] : []),
225
+ ...(direction === "in" || direction === "both" ? inbound.get(path) ?? [] : []),
226
+ ].toSorted(compareTraversalCandidates);
227
+ for (const candidate of candidates) {
228
+ const neighborPath = candidate.source === path ? candidate.target : candidate.source;
229
+ if (!notesByPath.has(neighborPath)) continue;
230
+ if (distanceByPath.has(neighborPath)) {
231
+ selectedCandidates.set(candidateKey(candidate), candidate);
232
+ continue;
233
+ }
234
+ if (distanceByPath.size >= limit) {
235
+ truncated = true;
236
+ continue;
237
+ }
238
+ selectedCandidates.set(candidateKey(candidate), candidate);
239
+ distanceByPath.set(neighborPath, distance + 1);
240
+ next.add(neighborPath);
241
+ }
242
+ }
243
+ frontier = [...next];
244
+ }
245
+
246
+ const nodes = [...distanceByPath]
247
+ .map(([path, distance]) => {
248
+ const note = notesByPath.get(path);
249
+ return note === undefined
250
+ ? null
251
+ : connectionNode(note, connectionsById.get(note.id), distance);
252
+ })
253
+ .filter((node): node is LinkNeighborhoodNode => node !== null)
254
+ .toSorted((left, right) => left.distance - right.distance || left.path.localeCompare(right.path));
255
+ const sortedCandidates = [...selectedCandidates.values()].toSorted(compareTraversalCandidates);
256
+ const connectionLimit = returnedConnectionLimit(limit);
257
+ if (sortedCandidates.length > connectionLimit) truncated = true;
258
+ const returnedCandidates = sortedCandidates.slice(0, connectionLimit);
259
+ const edges = returnedCandidates
260
+ .filter((candidate): candidate is Extract<TraversalCandidate, { readonly kind: "link" }> =>
261
+ candidate.kind === "link")
262
+ .map(({ edge }) => edge)
263
+ .toSorted((left, right) =>
264
+ left.source.localeCompare(right.source)
265
+ || left.target.localeCompare(right.target)
266
+ || left.line - right.line);
267
+ const relations = returnedCandidates
268
+ .filter((candidate): candidate is Extract<TraversalCandidate, { readonly kind: "relation" }> =>
269
+ candidate.kind === "relation")
270
+ .map(({ relation }) => relation)
271
+ .toSorted((left, right) =>
272
+ left.source.localeCompare(right.source)
273
+ || left.predicate.localeCompare(right.predicate)
274
+ || left.target.localeCompare(right.target)
275
+ || left.provenance.line - right.provenance.line);
276
+ return { note: start.path, direction, depth, limit, truncated, nodes, edges, relations };
277
+ }