@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/graph.ts ADDED
@@ -0,0 +1,1487 @@
1
+ import { posix } from "node:path";
2
+ import {
3
+ isMap,
4
+ isNode,
5
+ isScalar,
6
+ isSeq,
7
+ LineCounter,
8
+ parseDocument,
9
+ type Node,
10
+ } from "yaml";
11
+ import { parseQualifiedDocumentUri, type QualifiedDocumentUri } from "./portfolio-identity.js";
12
+
13
+ export const catalogStart = "<!-- kb:catalog:start -->";
14
+ export const catalogEnd = "<!-- kb:catalog:end -->";
15
+
16
+ export const MAX_ANALYZED_NOTES = 10_000;
17
+ export const MAX_CONNECTION_OBSERVATIONS = 250_000;
18
+ export const MAX_MENTION_PAIRS = 1_000_000;
19
+ export const MAX_MENTIONS = 50_000;
20
+
21
+ export type VaultAnalysisBudgetKind =
22
+ | "notes"
23
+ | "connection-observations"
24
+ | "mention-pairs"
25
+ | "mentions";
26
+
27
+ /** A stable failure for callers that need to distinguish bounded graph work. */
28
+ export class VaultAnalysisBudgetError extends RangeError {
29
+ readonly kind: VaultAnalysisBudgetKind;
30
+ readonly limit: number;
31
+
32
+ constructor(
33
+ kind: VaultAnalysisBudgetKind,
34
+ limit: number,
35
+ message: string,
36
+ ) {
37
+ super(message);
38
+ this.name = "VaultAnalysisBudgetError";
39
+ this.kind = kind;
40
+ this.limit = limit;
41
+ }
42
+ }
43
+
44
+ export type WikiLink = {
45
+ target: string;
46
+ line: number;
47
+ embedded: boolean;
48
+ };
49
+
50
+ export type RelationDeclaration = {
51
+ /** Strict lower-kebab predicate as authored after Unicode normalization. */
52
+ readonly predicate: string;
53
+ /** Authored target, normalized and trimmed but not yet resolved. */
54
+ readonly target: string;
55
+ readonly line: number;
56
+ };
57
+
58
+ export type RelationProvenance = {
59
+ readonly kind: "frontmatter";
60
+ /** Vault-relative Markdown path that owns the outbound assertion. */
61
+ readonly source: string;
62
+ readonly line: number;
63
+ /** Target spelling retained for diagnostics and auditable projection. */
64
+ readonly authoredTarget: string;
65
+ };
66
+
67
+ export type AuthoredRelation = {
68
+ /** Canonical extensionless note ID. */
69
+ readonly source: string;
70
+ /** Canonical extensionless note ID. */
71
+ readonly target: string;
72
+ readonly predicate: string;
73
+ readonly provenance: RelationProvenance;
74
+ };
75
+
76
+ export type ExternalAuthoredRelation = {
77
+ /** Canonical extensionless note ID in the current vault. */
78
+ readonly source: string;
79
+ /** Stable cross-vault document identity; portfolio audit resolves availability. */
80
+ readonly target: QualifiedDocumentUri;
81
+ readonly predicate: string;
82
+ readonly provenance: RelationProvenance;
83
+ };
84
+
85
+ export type MetadataScalar = string | number | boolean | null;
86
+
87
+ export type MetadataValue =
88
+ | MetadataScalar
89
+ | readonly MetadataValue[]
90
+ | MetadataObject;
91
+
92
+ export interface MetadataObject {
93
+ readonly [key: string]: MetadataValue;
94
+ }
95
+
96
+ function isMetadataObject(value: MetadataValue): value is MetadataObject {
97
+ return value !== null && typeof value === "object" && !Array.isArray(value);
98
+ }
99
+
100
+ export type Note = {
101
+ path: string;
102
+ id: string;
103
+ title: string;
104
+ aliases: readonly string[];
105
+ tags: readonly string[];
106
+ properties: Readonly<Record<string, string>>;
107
+ metadata: MetadataObject;
108
+ content: string;
109
+ summary: string;
110
+ searchableText: string;
111
+ links: readonly WikiLink[];
112
+ /**
113
+ * Always present on parser-produced notes. Optionality preserves compatibility
114
+ * for callers that construct the older structural Note shape themselves.
115
+ */
116
+ relationDeclarations?: readonly RelationDeclaration[];
117
+ /** Parser diagnostics promoted into VaultAnalysis.relationIssues. */
118
+ relationIssues?: readonly MalformedRelationIssue[];
119
+ };
120
+
121
+ export type ResolvedLink = {
122
+ source: string;
123
+ target: string;
124
+ line: number;
125
+ };
126
+
127
+ export type LinkIssue =
128
+ | {
129
+ kind: "broken";
130
+ source: string;
131
+ line: number;
132
+ target: string;
133
+ }
134
+ | {
135
+ kind: "ambiguous";
136
+ source: string;
137
+ line: number;
138
+ target: string;
139
+ candidates: readonly string[];
140
+ };
141
+
142
+ export type MalformedRelationIssue = {
143
+ readonly kind: "malformed";
144
+ readonly source: string;
145
+ readonly line: number;
146
+ readonly predicate?: string;
147
+ readonly target?: string;
148
+ readonly message: string;
149
+ };
150
+
151
+ export type RelationIssue =
152
+ | MalformedRelationIssue
153
+ | {
154
+ readonly kind: "broken";
155
+ readonly source: string;
156
+ readonly line: number;
157
+ readonly predicate: string;
158
+ readonly target: string;
159
+ }
160
+ | {
161
+ readonly kind: "ambiguous";
162
+ readonly source: string;
163
+ readonly line: number;
164
+ readonly predicate: string;
165
+ readonly target: string;
166
+ readonly candidates: readonly string[];
167
+ };
168
+
169
+ export type MentionCandidate = {
170
+ source: string;
171
+ line: number;
172
+ target: string;
173
+ phrase: string;
174
+ };
175
+
176
+ export type Backlink = {
177
+ source: string;
178
+ target: string;
179
+ line: number;
180
+ };
181
+
182
+ export type NoteConnections = {
183
+ id: string;
184
+ path: string;
185
+ inboundContextualCount: number;
186
+ outboundContextualCount: number;
187
+ backlinks: readonly Backlink[];
188
+ inboundRelationCount: number;
189
+ outboundRelationCount: number;
190
+ relationBacklinks: readonly AuthoredRelation[];
191
+ };
192
+
193
+ export type NoteLookupResult =
194
+ | { kind: "found"; note: Note }
195
+ | { kind: "ambiguous"; query: string; candidates: readonly Note[] }
196
+ | { kind: "missing"; query: string };
197
+
198
+ export type AnalyzeVaultOptions = {
199
+ includeInSuggestions?: (note: Note) => boolean;
200
+ /**
201
+ * Restrict mention discovery to pairs touching an included note. An empty
202
+ * scope skips mention pairing while retaining links and authored relations.
203
+ */
204
+ mentionScope?: (note: Note) => boolean;
205
+ /** Maximum notes accepted by this analysis, up to the hard package limit. */
206
+ maxNotes?: number;
207
+ /** Maximum parsed wikilinks and relation records inspected in aggregate. */
208
+ maxConnectionObservations?: number;
209
+ /** Maximum source/target phrase pairs inspected for prose mentions. */
210
+ maxMentionPairs?: number;
211
+ /** Maximum mention candidates materialized in the result. */
212
+ maxMentions?: number;
213
+ /** Vault-relative path of the generated navigation catalog. */
214
+ catalogNoteId?: string;
215
+ };
216
+
217
+ export type VaultAnalysis = {
218
+ noteCount: number;
219
+ contextualLinks: readonly ResolvedLink[];
220
+ backlinks: readonly Backlink[];
221
+ authoredRelations: readonly AuthoredRelation[];
222
+ readonly externalAuthoredRelations: readonly ExternalAuthoredRelation[];
223
+ noteConnections: readonly NoteConnections[];
224
+ issues: readonly LinkIssue[];
225
+ relationIssues: readonly RelationIssue[];
226
+ orphans: readonly string[];
227
+ mentions: readonly MentionCandidate[];
228
+ };
229
+
230
+ type Frontmatter = {
231
+ values: ReadonlyMap<string, string>;
232
+ aliases: readonly string[];
233
+ tags: readonly string[];
234
+ metadata: MetadataObject;
235
+ relationDeclarations: readonly RelationDeclaration[];
236
+ relationIssues: readonly MalformedRelationIssue[];
237
+ };
238
+
239
+ type MetadataParseResult =
240
+ | { readonly ok: true; readonly value: MetadataValue }
241
+ | { readonly ok: false };
242
+
243
+ function parsedMetadataValue(
244
+ value: unknown,
245
+ ancestors: WeakSet<object>,
246
+ ): MetadataParseResult {
247
+ if (value === null || typeof value === "string" || typeof value === "boolean") {
248
+ return { ok: true, value };
249
+ }
250
+ if (typeof value === "number") {
251
+ return Number.isFinite(value)
252
+ && (!Number.isInteger(value) || Number.isSafeInteger(value))
253
+ ? { ok: true, value }
254
+ : { ok: false };
255
+ }
256
+ if (typeof value !== "object" || ancestors.has(value)) return { ok: false };
257
+
258
+ ancestors.add(value);
259
+ try {
260
+ if (Array.isArray(value)) {
261
+ const parsed: MetadataValue[] = [];
262
+ const ownKeys = Reflect.ownKeys(value);
263
+ if (ownKeys.some((key) =>
264
+ typeof key !== "string" || (key !== "length" && !/^(?:0|[1-9]\d*)$/u.test(key)))) {
265
+ return { ok: false };
266
+ }
267
+ for (let index = 0; index < value.length; index += 1) {
268
+ const descriptor = Object.getOwnPropertyDescriptor(value, String(index));
269
+ if (descriptor === undefined || !("value" in descriptor)) return { ok: false };
270
+ const result = parsedMetadataValue(descriptor.value, ancestors);
271
+ if (!result.ok) return result;
272
+ parsed.push(result.value);
273
+ }
274
+ return { ok: true, value: parsed };
275
+ }
276
+
277
+ const prototype: unknown = Object.getPrototypeOf(value);
278
+ if (prototype !== Object.prototype && prototype !== null) return { ok: false };
279
+ const parsed = Object.create(null) as Record<string, MetadataValue>;
280
+ for (const key of Reflect.ownKeys(value)) {
281
+ if (typeof key !== "string") return { ok: false };
282
+ const descriptor = Object.getOwnPropertyDescriptor(value, key);
283
+ if (descriptor === undefined || !("value" in descriptor) || !descriptor.enumerable) {
284
+ return { ok: false };
285
+ }
286
+ const result = parsedMetadataValue(descriptor.value, ancestors);
287
+ if (!result.ok) return result;
288
+ Object.defineProperty(parsed, key, {
289
+ configurable: false,
290
+ enumerable: true,
291
+ value: result.value,
292
+ writable: false,
293
+ });
294
+ }
295
+ return { ok: true, value: parsed };
296
+ } finally {
297
+ ancestors.delete(value);
298
+ }
299
+ }
300
+
301
+ /** Narrow foreign parser output to the JSON-like metadata values owned by the graph. */
302
+ export function metadataValueFromUnknown(value: unknown): MetadataValue | undefined {
303
+ try {
304
+ const parsed = parsedMetadataValue(value, new WeakSet());
305
+ return parsed.ok ? parsed.value : undefined;
306
+ } catch {
307
+ return undefined;
308
+ }
309
+ }
310
+
311
+ function metadataObjectFromUnknown(value: unknown): MetadataObject | null {
312
+ if (value === null || Array.isArray(value) || typeof value !== "object") return null;
313
+ const parsed = metadataValueFromUnknown(value);
314
+ return parsed !== undefined && isMetadataObject(parsed) ? parsed : null;
315
+ }
316
+
317
+ function emptyMetadata(): MetadataObject {
318
+ return Object.create(null) as MetadataObject;
319
+ }
320
+
321
+ type ParsedMetadata = {
322
+ readonly metadata: MetadataObject;
323
+ readonly relationDeclarations: readonly RelationDeclaration[];
324
+ readonly relationIssues: readonly MalformedRelationIssue[];
325
+ };
326
+
327
+ const relationPredicatePattern = /^[a-z][a-z0-9]*(?:-[a-z0-9]+)*$/u;
328
+ const maxNoteIdLength = 2_048;
329
+
330
+ /**
331
+ * Whether a value is the exact, extensionless vault-root ID used on disk.
332
+ *
333
+ * Relationship metadata and the authoring API share this predicate so a
334
+ * value accepted by the reader can always be passed back to the writer
335
+ * without a second normalization language.
336
+ */
337
+ export function isCanonicalNoteId(value: string): boolean {
338
+ if (
339
+ value === ""
340
+ || value.length > maxNoteIdLength
341
+ || value !== value.trim()
342
+ || value !== value.normalize("NFC")
343
+ || value.includes("\\")
344
+ || value.includes("\0")
345
+ || value.includes("\n")
346
+ || value.includes("\r")
347
+ || value.startsWith("/")
348
+ || value.endsWith("/")
349
+ || value.toLocaleLowerCase("en-US").endsWith(".md")
350
+ ) {
351
+ return false;
352
+ }
353
+ const segments = value.split("/");
354
+ return !segments.some((segment) =>
355
+ segment === ""
356
+ || segment === "."
357
+ || segment === ".."
358
+ || segment.startsWith("."))
359
+ && posix.normalize(value) === value
360
+ && posix.basename(`${value}.md`) !== "AGENTS.md";
361
+ }
362
+
363
+ function frontmatterSourceLine(
364
+ lineCounter: LineCounter,
365
+ node: Node | null | undefined,
366
+ fallback = 2,
367
+ ): number {
368
+ const offset = node?.range?.[0];
369
+ if (offset === undefined) return fallback;
370
+ // The parsed source starts immediately after the opening frontmatter marker.
371
+ return lineCounter.linePos(offset).line + 1;
372
+ }
373
+
374
+ function malformedRelation(
375
+ source: string,
376
+ line: number,
377
+ message: string,
378
+ details: {
379
+ readonly predicate?: string;
380
+ readonly target?: string;
381
+ } = {},
382
+ ): MalformedRelationIssue {
383
+ return {
384
+ kind: "malformed",
385
+ source,
386
+ line,
387
+ ...details,
388
+ message,
389
+ };
390
+ }
391
+
392
+ function relationTarget(
393
+ node: Node | null,
394
+ predicate: string,
395
+ source: string,
396
+ lineCounter: LineCounter,
397
+ fallbackLine: number,
398
+ ): RelationDeclaration | MalformedRelationIssue {
399
+ const line = frontmatterSourceLine(lineCounter, node, fallbackLine);
400
+ if (!isScalar(node) || typeof node.value !== "string") {
401
+ return malformedRelation(
402
+ source,
403
+ line,
404
+ `Relation "${predicate}" targets must be non-empty strings.`,
405
+ { predicate },
406
+ );
407
+ }
408
+ const target = node.value;
409
+ let qualified = false;
410
+ if (target.startsWith("kb://")) {
411
+ try {
412
+ parseQualifiedDocumentUri(target);
413
+ qualified = true;
414
+ } catch {
415
+ qualified = false;
416
+ }
417
+ }
418
+ if (!isCanonicalNoteId(target) && !qualified) {
419
+ return malformedRelation(
420
+ source,
421
+ line,
422
+ `Relation "${predicate}" target ${JSON.stringify(target)} must be an exact `
423
+ + "extensionless vault-root note ID or canonical kb:// document URI.",
424
+ { predicate, target: node.value },
425
+ );
426
+ }
427
+ return { predicate, target, line };
428
+ }
429
+
430
+ function parsedRelations(
431
+ contents: Node | null,
432
+ source: string,
433
+ lineCounter: LineCounter,
434
+ ): Pick<ParsedMetadata, "relationDeclarations" | "relationIssues"> {
435
+ if (!isMap(contents)) return { relationDeclarations: [], relationIssues: [] };
436
+ const relationsPair = contents.items.find((pair) =>
437
+ isScalar(pair.key)
438
+ && typeof pair.key.value === "string"
439
+ && pair.key.value.toLocaleLowerCase("en-US") === "relations");
440
+ if (relationsPair === undefined) {
441
+ return { relationDeclarations: [], relationIssues: [] };
442
+ }
443
+
444
+ const relationsLine = frontmatterSourceLine(
445
+ lineCounter,
446
+ isScalar(relationsPair.key) ? relationsPair.key : null,
447
+ );
448
+ if (!isMap(relationsPair.value)) {
449
+ return {
450
+ relationDeclarations: [],
451
+ relationIssues: [
452
+ malformedRelation(
453
+ source,
454
+ relationsLine,
455
+ "Relations must map strict lower-kebab predicates to a string or string list.",
456
+ ),
457
+ ],
458
+ };
459
+ }
460
+
461
+ const relationDeclarations: RelationDeclaration[] = [];
462
+ const relationIssues: MalformedRelationIssue[] = [];
463
+ for (const pair of relationsPair.value.items) {
464
+ const predicateLine = frontmatterSourceLine(
465
+ lineCounter,
466
+ isNode(pair.key) ? pair.key : null,
467
+ relationsLine,
468
+ );
469
+ if (!isScalar(pair.key) || typeof pair.key.value !== "string") {
470
+ relationIssues.push(malformedRelation(
471
+ source,
472
+ predicateLine,
473
+ "Relation predicates must be strict lower kebab-case strings.",
474
+ ));
475
+ continue;
476
+ }
477
+
478
+ const predicate = pair.key.value.normalize("NFC");
479
+ if (!relationPredicatePattern.test(predicate)) {
480
+ relationIssues.push(malformedRelation(
481
+ source,
482
+ predicateLine,
483
+ `Invalid relation predicate "${predicate}"; use strict lower kebab-case.`,
484
+ { predicate },
485
+ ));
486
+ continue;
487
+ }
488
+
489
+ const nodes = isSeq(pair.value) ? pair.value.items : [pair.value];
490
+ for (const node of nodes) {
491
+ const parsed = relationTarget(
492
+ isNode(node) ? node : null,
493
+ predicate,
494
+ source,
495
+ lineCounter,
496
+ predicateLine,
497
+ );
498
+ if ("kind" in parsed) relationIssues.push(parsed);
499
+ else relationDeclarations.push(parsed);
500
+ }
501
+ }
502
+
503
+ return {
504
+ relationDeclarations: relationDeclarations.toSorted((left, right) =>
505
+ left.predicate.localeCompare(right.predicate)
506
+ || left.target.localeCompare(right.target)
507
+ || left.line - right.line),
508
+ relationIssues: relationIssues.toSorted((left, right) =>
509
+ left.line - right.line
510
+ || (left.predicate ?? "").localeCompare(right.predicate ?? "")
511
+ || (left.target ?? "").localeCompare(right.target ?? "")),
512
+ };
513
+ }
514
+
515
+ function parseMetadata(source: string, path: string): ParsedMetadata {
516
+ if (source.trim() === "") {
517
+ return {
518
+ metadata: emptyMetadata(),
519
+ relationDeclarations: [],
520
+ relationIssues: [],
521
+ };
522
+ }
523
+ try {
524
+ const lineCounter = new LineCounter();
525
+ const document = parseDocument(source, {
526
+ lineCounter,
527
+ schema: "core",
528
+ uniqueKeys: true,
529
+ });
530
+ if (document.errors.length > 0) {
531
+ throw new Error("the YAML parser reported an error");
532
+ }
533
+ if (document.contents === null) {
534
+ return {
535
+ metadata: emptyMetadata(),
536
+ relationDeclarations: [],
537
+ relationIssues: [],
538
+ };
539
+ }
540
+ const parsed: unknown = document.toJS({ mapAsMap: false, maxAliasCount: 50 });
541
+ const metadata = metadataObjectFromUnknown(parsed);
542
+ if (metadata === null) throw new Error("the YAML document is not a JSON-like object");
543
+ return {
544
+ metadata,
545
+ ...parsedRelations(document.contents, path, lineCounter),
546
+ };
547
+ } catch (error) {
548
+ throw new Error(`Invalid YAML frontmatter in ${path}.`, { cause: error });
549
+ }
550
+ }
551
+
552
+ function metadataProperty(
553
+ metadata: MetadataObject,
554
+ name: string,
555
+ ): MetadataValue | undefined {
556
+ if (Object.hasOwn(metadata, name)) return metadata[name];
557
+ const lowerName = name.toLocaleLowerCase("en-US");
558
+ const matches = Object.keys(metadata).filter(
559
+ (key) => key.toLocaleLowerCase("en-US") === lowerName,
560
+ );
561
+ return matches.length === 1 ? metadata[matches[0] ?? ""] : undefined;
562
+ }
563
+
564
+ function normalizedTags(metadata: MetadataObject): readonly string[] {
565
+ const value = metadataProperty(metadata, "tags");
566
+ const candidates = typeof value === "string"
567
+ ? [value]
568
+ : Array.isArray(value)
569
+ ? value.filter((candidate): candidate is string => typeof candidate === "string")
570
+ : [];
571
+ const tags: string[] = [];
572
+ const seen = new Set<string>();
573
+ for (const candidate of candidates) {
574
+ const tag = candidate.trim().replace(/^#+/u, "").normalize("NFC").toLocaleLowerCase("en-US");
575
+ if (tag === "" || seen.has(tag)) continue;
576
+ seen.add(tag);
577
+ tags.push(tag);
578
+ }
579
+ return tags;
580
+ }
581
+
582
+ export const normalizeVaultPath = (path: string): string =>
583
+ posix.normalize(path.replaceAll("\\", "/")).replace(/^\.\//, "");
584
+
585
+ const withoutMarkdownExtension = (path: string): string =>
586
+ path.toLowerCase().endsWith(".md") ? path.slice(0, -3) : path;
587
+
588
+ function legacyPropertyValue(value: MetadataValue): string | undefined {
589
+ if (typeof value === "string") return value;
590
+ if (typeof value === "number" || typeof value === "boolean") return String(value);
591
+ if (value === null) return "null";
592
+ return undefined;
593
+ }
594
+
595
+ function aliasesFromMetadata(metadata: MetadataObject): readonly string[] {
596
+ const value = metadataProperty(metadata, "aliases");
597
+ if (typeof value === "string") return value.trim() === "" ? [] : [value];
598
+ if (!Array.isArray(value)) return [];
599
+ return value.filter((candidate): candidate is string =>
600
+ typeof candidate === "string" && candidate.trim() !== "");
601
+ }
602
+
603
+ function frontmatterOf(content: string, path: string): Frontmatter {
604
+ const lines = content.split("\n");
605
+ if (lines[0]?.trim() !== "---") {
606
+ return {
607
+ values: new Map(),
608
+ aliases: [],
609
+ tags: [],
610
+ metadata: emptyMetadata(),
611
+ relationDeclarations: [],
612
+ relationIssues: [],
613
+ };
614
+ }
615
+ const end = lines.findIndex((line, index) => index > 0 && line.trim() === "---");
616
+ if (end === -1) {
617
+ throw new Error(`Invalid YAML frontmatter in ${path}: missing closing delimiter.`);
618
+ }
619
+
620
+ const parsed = parseMetadata(lines.slice(1, end).join("\n"), path);
621
+ const metadata = parsed.metadata;
622
+
623
+ const values = new Map<string, string>();
624
+ const seenKeys = new Set<string>();
625
+ for (const [authoredKey, typedValue] of Object.entries(metadata)) {
626
+ const key = authoredKey.toLocaleLowerCase("en-US");
627
+ if (seenKeys.has(key)) {
628
+ throw new Error(`Invalid YAML frontmatter in ${path}: keys must not differ only by case.`);
629
+ }
630
+ seenKeys.add(key);
631
+ if (key === "aliases") continue;
632
+ const value = legacyPropertyValue(typedValue);
633
+ if (value !== undefined && value !== "") values.set(key, value);
634
+ }
635
+ return {
636
+ values,
637
+ aliases: aliasesFromMetadata(metadata),
638
+ tags: normalizedTags(metadata),
639
+ metadata,
640
+ relationDeclarations: parsed.relationDeclarations,
641
+ relationIssues: parsed.relationIssues,
642
+ };
643
+ }
644
+
645
+ /**
646
+ * Blank frontmatter, fenced code, inline code, and HTML comments while
647
+ * preserving newlines so diagnostic line numbers remain stable.
648
+ */
649
+ export function searchableMarkdown(content: string): string {
650
+ const lines = content.split("\n");
651
+ let inFrontmatter = lines[0]?.trim() === "---";
652
+ let inFence: { readonly character: string; readonly length: number } | null = null;
653
+ let inComment = false;
654
+
655
+ const blockMasked = lines.map((line, index) => {
656
+ if (inFrontmatter) {
657
+ if (index > 0 && line.trim() === "---") inFrontmatter = false;
658
+ return "";
659
+ }
660
+
661
+ const fence = /^\s{0,3}(`{3,}|~{3,})/.exec(line);
662
+ if (inFence !== null) {
663
+ const delimiter = /^\s{0,3}(`{3,}|~{3,})[\t ]*$/.exec(line)?.[1];
664
+ if (
665
+ delimiter !== undefined
666
+ && delimiter[0] === inFence.character
667
+ && delimiter.length >= inFence.length
668
+ ) inFence = null;
669
+ return "";
670
+ }
671
+ const openingDelimiter = fence?.[1];
672
+ const openingRemainder = fence === null ? "" : line.slice(fence[0].length);
673
+ if (
674
+ openingDelimiter !== undefined
675
+ && (openingDelimiter[0] === "~" || !openingRemainder.includes("`"))
676
+ ) {
677
+ const delimiter = openingDelimiter;
678
+ inFence = { character: delimiter[0] ?? "`", length: delimiter.length };
679
+ return "";
680
+ }
681
+
682
+ if (/^(?: {4,}|\t)/u.test(line)) return "";
683
+
684
+ let output = line;
685
+ if (inComment) {
686
+ const close = output.indexOf("-->");
687
+ if (close === -1) return "";
688
+ output = output.slice(close + 3);
689
+ inComment = false;
690
+ }
691
+ for (;;) {
692
+ const open = output.indexOf("<!--");
693
+ if (open === -1) break;
694
+ const close = output.indexOf("-->", open + 4);
695
+ if (close === -1) {
696
+ output = output.slice(0, open);
697
+ inComment = true;
698
+ break;
699
+ }
700
+ output = output.slice(0, open) + output.slice(close + 3);
701
+ }
702
+ return output;
703
+ }).join("\n");
704
+ return maskInlineCodeBlocks(maskHtmlCodeBlocks(blockMasked));
705
+ }
706
+
707
+ type BacktickRun = {
708
+ readonly start: number;
709
+ readonly end: number;
710
+ readonly length: number;
711
+ };
712
+
713
+ function maskInlineCodeSpans(value: string): string {
714
+ const runs: BacktickRun[] = [];
715
+ for (let index = 0; index < value.length;) {
716
+ if (value[index] !== "`") {
717
+ index += 1;
718
+ continue;
719
+ }
720
+ const start = index;
721
+ while (value[index] === "`") index += 1;
722
+ let precedingBackslashes = 0;
723
+ for (let before = start - 1; before >= 0 && value[before] === "\\"; before -= 1) {
724
+ precedingBackslashes += 1;
725
+ }
726
+ if (precedingBackslashes % 2 === 1) continue;
727
+ runs.push({ start, end: index, length: index - start });
728
+ }
729
+
730
+ const remaining = new Map<number, number>();
731
+ for (const run of runs) remaining.set(run.length, (remaining.get(run.length) ?? 0) + 1);
732
+ const output: string[] = [];
733
+ let cursor = 0;
734
+ let delimiterLength: number | null = null;
735
+ const appendSegment = (segment: string): void => {
736
+ output.push(delimiterLength === null ? segment : segment.replace(/[^\n]/gu, ""));
737
+ };
738
+ for (const run of runs) {
739
+ appendSegment(value.slice(cursor, run.start));
740
+ remaining.set(run.length, (remaining.get(run.length) ?? 1) - 1);
741
+ if (delimiterLength === null) {
742
+ if ((remaining.get(run.length) ?? 0) > 0) delimiterLength = run.length;
743
+ else output.push(value.slice(run.start, run.end));
744
+ } else if (run.length === delimiterLength) {
745
+ delimiterLength = null;
746
+ }
747
+ cursor = run.end;
748
+ }
749
+ appendSegment(value.slice(cursor));
750
+ return output.join("");
751
+ }
752
+
753
+ function maskInlineCodeBlocks(value: string): string {
754
+ const output: string[] = [];
755
+ let paragraph: string[] = [];
756
+ const flush = (): void => {
757
+ if (paragraph.length === 0) return;
758
+ output.push(...maskInlineCodeSpans(paragraph.join("\n")).split("\n"));
759
+ paragraph = [];
760
+ };
761
+ const startsIndependentBlock = (line: string): boolean =>
762
+ /^\s{0,3}(?:#{1,6}(?:\s|$)|>|(?:[-+*]|\d+[.)])\s+)/u.test(line);
763
+
764
+ for (const line of value.split("\n")) {
765
+ if (line.trim() === "") {
766
+ flush();
767
+ output.push("");
768
+ } else if (startsIndependentBlock(line)) {
769
+ flush();
770
+ output.push(maskInlineCodeSpans(line));
771
+ } else {
772
+ paragraph.push(line);
773
+ }
774
+ }
775
+ flush();
776
+ return output.join("\n");
777
+ }
778
+
779
+ function maskHtmlCodeBlocks(value: string): string {
780
+ const tagPattern = /<\/?(pre|code|script|style)\b[^>]*>/giu;
781
+ const output: string[] = [];
782
+ let cursor = 0;
783
+ let activeTag: string | null = null;
784
+ let depth = 0;
785
+ const append = (segment: string): void => {
786
+ output.push(activeTag === null ? segment : segment.replace(/[^\n]/gu, ""));
787
+ };
788
+
789
+ for (const match of value.matchAll(tagPattern)) {
790
+ const start = match.index ?? 0;
791
+ append(value.slice(cursor, start));
792
+ const tag = (match[1] ?? "").toLowerCase();
793
+ const closing = match[0].startsWith("</");
794
+ if (activeTag === null && !closing) {
795
+ activeTag = tag;
796
+ depth = 1;
797
+ } else if (activeTag === tag) {
798
+ if (closing) depth -= 1;
799
+ else depth += 1;
800
+ if (depth === 0) activeTag = null;
801
+ }
802
+ output.push(match[0].replace(/[^\n]/gu, ""));
803
+ cursor = start + match[0].length;
804
+ }
805
+ append(value.slice(cursor));
806
+ return output.join("");
807
+ }
808
+
809
+ function markdownText(value: string): string {
810
+ return value
811
+ .replace(/!\[[^\]]*\]\([^)]*\)/g, "")
812
+ .replace(/\[([^\]]+)\]\([^)]*\)/g, "$1")
813
+ .replace(/\[\[([^|\]#]+)(?:#[^|\]]+)?(?:\|([^\]]+))?\]\]/g, (_whole, target: string, label: string | undefined) =>
814
+ label ?? posix.basename(target))
815
+ .replace(/[*_~]/g, "")
816
+ .replace(/\s+/g, " ")
817
+ .trim();
818
+ }
819
+
820
+ function firstHeading(searchable: string): string | null {
821
+ for (const line of searchable.split("\n")) {
822
+ const heading = /^#\s+(.+?)\s*$/.exec(line);
823
+ if (heading !== null) return markdownText(heading[1] ?? "");
824
+ }
825
+ return null;
826
+ }
827
+
828
+ function firstParagraph(searchable: string): string {
829
+ const paragraphs = searchable.split(/\n\s*\n/);
830
+ for (const paragraph of paragraphs) {
831
+ const lines = paragraph
832
+ .split("\n")
833
+ .map((line) => line.trim())
834
+ .filter((line) =>
835
+ line !== ""
836
+ && !line.startsWith("#")
837
+ && !line.startsWith("- ")
838
+ && !line.startsWith("* ")
839
+ && !line.startsWith("|")
840
+ && !line.startsWith(">"));
841
+ const text = markdownText(lines.join(" "));
842
+ if (text !== "") return text;
843
+ }
844
+ return "";
845
+ }
846
+
847
+ function concise(value: string, limit = 180): string {
848
+ const text = markdownText(value);
849
+ if (text.length <= limit) return text;
850
+ const prefix = text.slice(0, limit + 1);
851
+ const lastSpace = prefix.lastIndexOf(" ");
852
+ return (lastSpace >= Math.floor(limit * 0.65) ? prefix.slice(0, lastSpace) : text.slice(0, limit)).trimEnd() + "…";
853
+ }
854
+
855
+ export function wikiLinks(searchable: string): readonly WikiLink[] {
856
+ const links: WikiLink[] = [];
857
+ for (const match of searchable.matchAll(/(!?)\[\[([^\]\n]+)\]\]/g)) {
858
+ const offset = match.index ?? 0;
859
+ let precedingBackslashes = 0;
860
+ for (let before = offset - 1; before >= 0 && searchable[before] === "\\"; before -= 1) {
861
+ precedingBackslashes += 1;
862
+ }
863
+ if (precedingBackslashes % 2 === 1) continue;
864
+ const inside = match[2] ?? "";
865
+ const separator = inside.indexOf("|");
866
+ const target = (separator === -1 ? inside : inside.slice(0, separator)).trim();
867
+ links.push({
868
+ target,
869
+ line: searchable.slice(0, offset).split("\n").length,
870
+ embedded: (match[1] ?? "") === "!",
871
+ });
872
+ }
873
+ return links;
874
+ }
875
+
876
+ export function parseNote(path: string, content: string): Note {
877
+ const notePath = normalizeVaultPath(path);
878
+ const metadata = frontmatterOf(content, notePath);
879
+ const searchable = searchableMarkdown(content);
880
+ const fallback = posix.basename(withoutMarkdownExtension(notePath)).replaceAll("-", " ");
881
+ const title = metadata.values.get("title") ?? firstHeading(searchable) ?? fallback;
882
+ const summary = concise(metadata.values.get("description") ?? firstParagraph(searchable));
883
+ return {
884
+ path: notePath,
885
+ id: withoutMarkdownExtension(notePath),
886
+ title,
887
+ aliases: metadata.aliases,
888
+ tags: metadata.tags,
889
+ properties: Object.fromEntries(metadata.values),
890
+ metadata: metadata.metadata,
891
+ content,
892
+ summary,
893
+ searchableText: searchable,
894
+ links: wikiLinks(searchable),
895
+ relationDeclarations: metadata.relationDeclarations,
896
+ relationIssues: metadata.relationIssues,
897
+ };
898
+ }
899
+
900
+ type LinkResolution =
901
+ | { kind: "note"; id: string }
902
+ | { kind: "attachment" }
903
+ | { kind: "broken" }
904
+ | { kind: "ambiguous"; candidates: readonly string[] };
905
+
906
+ function decoded(value: string): string {
907
+ try {
908
+ return decodeURIComponent(value);
909
+ } catch {
910
+ return value;
911
+ }
912
+ }
913
+
914
+ export function lookupNote(notes: readonly Note[], query: string): NoteLookupResult {
915
+ const trimmed = decoded(query.trim()).replaceAll("\\", "/").replace(/^\/+/, "");
916
+ if (trimmed === "") return { kind: "missing", query };
917
+
918
+ const queryId = withoutMarkdownExtension(normalizeVaultPath(trimmed));
919
+ const exact = notes.find((note) => note.id === queryId);
920
+ if (exact !== undefined) return { kind: "found", note: exact };
921
+
922
+ const lowerQueryId = queryId.toLocaleLowerCase("en-US");
923
+ const idMatches = notes.filter((note) => note.id.toLocaleLowerCase("en-US") === lowerQueryId);
924
+ const onlyIdMatch = idMatches.length === 1 ? idMatches[0] : undefined;
925
+ if (onlyIdMatch !== undefined) return { kind: "found", note: onlyIdMatch };
926
+ if (idMatches.length > 1) {
927
+ return {
928
+ kind: "ambiguous",
929
+ query,
930
+ candidates: idMatches.toSorted((left, right) => left.path.localeCompare(right.path)),
931
+ };
932
+ }
933
+
934
+ const lowerLabel = query.trim().toLocaleLowerCase("en-US");
935
+ const lowerBasename = posix.basename(queryId).toLocaleLowerCase("en-US");
936
+ const candidates = notes.filter((note) => {
937
+ const labels = [posix.basename(note.id), note.title, ...note.aliases]
938
+ .map((value) => value.toLocaleLowerCase("en-US"));
939
+ return labels.includes(lowerLabel) || labels.includes(lowerBasename);
940
+ }).toSorted((left, right) => left.path.localeCompare(right.path));
941
+
942
+ const onlyCandidate = candidates.length === 1 ? candidates[0] : undefined;
943
+ if (onlyCandidate !== undefined) return { kind: "found", note: onlyCandidate };
944
+ if (candidates.length > 1) return { kind: "ambiguous", query, candidates };
945
+ return { kind: "missing", query };
946
+ }
947
+
948
+ function resolveTarget(
949
+ source: Note,
950
+ rawTarget: string,
951
+ byId: ReadonlyMap<string, Note>,
952
+ byBasename: ReadonlyMap<string, readonly string[]>,
953
+ ): LinkResolution {
954
+ const targetWithoutAnchor = rawTarget.split("#", 1)[0]?.split("^", 1)[0]?.trim() ?? "";
955
+ if (targetWithoutAnchor === "") return { kind: "note", id: source.id };
956
+
957
+ let target = decoded(targetWithoutAnchor).replaceAll("\\", "/");
958
+ const extension = posix.extname(target).toLowerCase();
959
+ if (extension !== "" && extension !== ".md") return { kind: "attachment" };
960
+ target = withoutMarkdownExtension(target).replace(/^\//, "");
961
+
962
+ if (target.startsWith(".")) {
963
+ const relativeTarget = posix.normalize(posix.join(posix.dirname(source.id), target));
964
+ return byId.has(relativeTarget) ? { kind: "note", id: relativeTarget } : { kind: "broken" };
965
+ }
966
+
967
+ if (byId.has(target)) return { kind: "note", id: target };
968
+ if (target.includes("/")) return { kind: "broken" };
969
+
970
+ const candidates = byBasename.get(target) ?? [];
971
+ if (candidates.length === 1) return { kind: "note", id: candidates[0] ?? target };
972
+ if (candidates.length > 1) return { kind: "ambiguous", candidates };
973
+ return { kind: "broken" };
974
+ }
975
+
976
+ const pairKey = (left: string, right: string): string =>
977
+ left.localeCompare(right) <= 0 ? `${left}\0${right}` : `${right}\0${left}`;
978
+
979
+ function compareAuthoredRelations(
980
+ left: AuthoredRelation,
981
+ right: AuthoredRelation,
982
+ ): number {
983
+ return left.source.localeCompare(right.source)
984
+ || left.predicate.localeCompare(right.predicate)
985
+ || left.target.localeCompare(right.target)
986
+ || left.provenance.line - right.provenance.line
987
+ || left.provenance.authoredTarget.localeCompare(right.provenance.authoredTarget);
988
+ }
989
+
990
+ function compareExternalAuthoredRelations(
991
+ left: ExternalAuthoredRelation,
992
+ right: ExternalAuthoredRelation,
993
+ ): number {
994
+ return left.source.localeCompare(right.source)
995
+ || left.predicate.localeCompare(right.predicate)
996
+ || left.target.localeCompare(right.target)
997
+ || left.provenance.line - right.provenance.line
998
+ || left.provenance.authoredTarget.localeCompare(right.provenance.authoredTarget);
999
+ }
1000
+
1001
+ function compareRelationIssues(left: RelationIssue, right: RelationIssue): number {
1002
+ const predicateComparison = (left.predicate ?? "").localeCompare(right.predicate ?? "");
1003
+ const targetComparison = (left.target ?? "").localeCompare(right.target ?? "");
1004
+ return left.source.localeCompare(right.source)
1005
+ || left.line - right.line
1006
+ || left.kind.localeCompare(right.kind)
1007
+ || predicateComparison
1008
+ || targetComparison;
1009
+ }
1010
+
1011
+ const wordCharacter = (value: string): boolean => /[A-Za-z0-9]/.test(value);
1012
+
1013
+ function phraseOffset(lowerHaystack: string, lowerPhrase: string): number {
1014
+ let offset = lowerHaystack.indexOf(lowerPhrase);
1015
+ while (offset !== -1) {
1016
+ const before = offset === 0 ? "" : lowerHaystack[offset - 1] ?? "";
1017
+ const afterIndex = offset + lowerPhrase.length;
1018
+ const after = afterIndex >= lowerHaystack.length ? "" : lowerHaystack[afterIndex] ?? "";
1019
+ const startsClean = !wordCharacter(lowerPhrase[0] ?? "") || !wordCharacter(before);
1020
+ const endsClean = !wordCharacter(lowerPhrase.at(-1) ?? "") || !wordCharacter(after);
1021
+ if (startsClean && endsClean) return offset;
1022
+ offset = lowerHaystack.indexOf(lowerPhrase, offset + 1);
1023
+ }
1024
+ return -1;
1025
+ }
1026
+
1027
+ function candidatePhrases(note: Note): readonly string[] {
1028
+ const values = [note.title, ...note.aliases]
1029
+ .map((value) => value.trim())
1030
+ .filter((value) => value.length >= 4 && /[A-Za-z]/.test(value));
1031
+ return [...new Set(values)].sort((left, right) => right.length - left.length);
1032
+ }
1033
+
1034
+ type UniquePhrase = {
1035
+ readonly phrase: string;
1036
+ readonly lowerPhrase: string;
1037
+ };
1038
+
1039
+ function uniquePhrasesByTarget(notes: readonly Note[]): ReadonlyMap<string, readonly UniquePhrase[]> {
1040
+ const ownersByPhrase = new Map<string, Map<string, string>>();
1041
+ for (const note of notes) {
1042
+ for (const phrase of candidatePhrases(note)) {
1043
+ const lowerPhrase = phrase.toLocaleLowerCase("en-US");
1044
+ const owners = ownersByPhrase.get(lowerPhrase) ?? new Map<string, string>();
1045
+ if (!owners.has(note.id)) owners.set(note.id, phrase);
1046
+ ownersByPhrase.set(lowerPhrase, owners);
1047
+ }
1048
+ }
1049
+
1050
+ const phrasesByTarget = new Map<string, UniquePhrase[]>();
1051
+ for (const [lowerPhrase, owners] of ownersByPhrase) {
1052
+ if (owners.size !== 1) continue;
1053
+ const owner = owners.entries().next().value;
1054
+ if (owner === undefined) continue;
1055
+ const [targetId, phrase] = owner;
1056
+ const phrases = phrasesByTarget.get(targetId) ?? [];
1057
+ phrases.push({ phrase, lowerPhrase });
1058
+ phrasesByTarget.set(targetId, phrases);
1059
+ }
1060
+ for (const phrases of phrasesByTarget.values()) {
1061
+ phrases.sort((left, right) =>
1062
+ right.lowerPhrase.length - left.lowerPhrase.length
1063
+ || left.lowerPhrase.localeCompare(right.lowerPhrase));
1064
+ }
1065
+ return phrasesByTarget;
1066
+ }
1067
+
1068
+ function checkedAnalysisLimit(
1069
+ value: number | undefined,
1070
+ hardMaximum: number,
1071
+ option:
1072
+ | "maxNotes"
1073
+ | "maxConnectionObservations"
1074
+ | "maxMentionPairs"
1075
+ | "maxMentions",
1076
+ ): number {
1077
+ const limit = value ?? hardMaximum;
1078
+ if (!Number.isSafeInteger(limit) || limit < 0 || limit > hardMaximum) {
1079
+ throw new RangeError(
1080
+ `${option} must be a safe integer from 0 through ${hardMaximum}.`,
1081
+ );
1082
+ }
1083
+ return limit;
1084
+ }
1085
+
1086
+ export function analyzeVault(
1087
+ notes: readonly Note[],
1088
+ options: AnalyzeVaultOptions = {},
1089
+ ): VaultAnalysis {
1090
+ const maxNotes = checkedAnalysisLimit(
1091
+ options.maxNotes,
1092
+ MAX_ANALYZED_NOTES,
1093
+ "maxNotes",
1094
+ );
1095
+ const maxMentionPairs = checkedAnalysisLimit(
1096
+ options.maxMentionPairs,
1097
+ MAX_MENTION_PAIRS,
1098
+ "maxMentionPairs",
1099
+ );
1100
+ const maxMentions = checkedAnalysisLimit(
1101
+ options.maxMentions,
1102
+ MAX_MENTIONS,
1103
+ "maxMentions",
1104
+ );
1105
+ const maxConnectionObservations = checkedAnalysisLimit(
1106
+ options.maxConnectionObservations,
1107
+ MAX_CONNECTION_OBSERVATIONS,
1108
+ "maxConnectionObservations",
1109
+ );
1110
+ if (notes.length > maxNotes) {
1111
+ throw new VaultAnalysisBudgetError(
1112
+ "notes",
1113
+ maxNotes,
1114
+ `Vault analysis exceeds the ${maxNotes} note limit.`,
1115
+ );
1116
+ }
1117
+ const catalogNoteId = withoutMarkdownExtension(
1118
+ normalizeVaultPath(options.catalogNoteId ?? "index"),
1119
+ );
1120
+ const byId = new Map(notes.map((note) => [note.id, note]));
1121
+ const byBasename = new Map<string, string[]>();
1122
+ for (const note of notes) {
1123
+ const basename = posix.basename(note.id);
1124
+ const matches = byBasename.get(basename) ?? [];
1125
+ matches.push(note.id);
1126
+ byBasename.set(basename, matches);
1127
+ }
1128
+ for (const matches of byBasename.values()) matches.sort();
1129
+
1130
+ let connectionObservations = 0;
1131
+ const observeConnection = (): void => {
1132
+ if (connectionObservations >= maxConnectionObservations) {
1133
+ throw new VaultAnalysisBudgetError(
1134
+ "connection-observations",
1135
+ maxConnectionObservations,
1136
+ `Vault analysis exceeds the ${maxConnectionObservations} `
1137
+ + "connection-observation limit.",
1138
+ );
1139
+ }
1140
+ connectionObservations += 1;
1141
+ };
1142
+
1143
+ const issues: LinkIssue[] = [];
1144
+ const relationIssues: RelationIssue[] = [];
1145
+ for (const note of notes) {
1146
+ for (const issue of note.relationIssues ?? []) {
1147
+ observeConnection();
1148
+ relationIssues.push(issue);
1149
+ }
1150
+ }
1151
+ const contextualLinks: ResolvedLink[] = [];
1152
+ const edgeKeys = new Set<string>();
1153
+ for (const source of notes) {
1154
+ for (const link of source.links) {
1155
+ observeConnection();
1156
+ const resolution = resolveTarget(source, link.target, byId, byBasename);
1157
+ if (resolution.kind === "attachment") continue;
1158
+ if (resolution.kind === "broken") {
1159
+ issues.push({ kind: "broken", source: source.path, line: link.line, target: link.target });
1160
+ continue;
1161
+ }
1162
+ if (resolution.kind === "ambiguous") {
1163
+ issues.push({
1164
+ kind: "ambiguous",
1165
+ source: source.path,
1166
+ line: link.line,
1167
+ target: link.target,
1168
+ candidates: resolution.candidates,
1169
+ });
1170
+ continue;
1171
+ }
1172
+ if (
1173
+ source.id === catalogNoteId
1174
+ || resolution.id === catalogNoteId
1175
+ || source.id === resolution.id
1176
+ ) continue;
1177
+ const edgeKey = `${source.id}\0${resolution.id}`;
1178
+ if (edgeKeys.has(edgeKey)) continue;
1179
+ edgeKeys.add(edgeKey);
1180
+ contextualLinks.push({ source: source.path, target: `${resolution.id}.md`, line: link.line });
1181
+ }
1182
+ }
1183
+
1184
+ const authoredRelations: AuthoredRelation[] = [];
1185
+ const externalAuthoredRelations: ExternalAuthoredRelation[] = [];
1186
+ const declarationKeys = new Set<string>();
1187
+ const relationEdgeKeys = new Set<string>();
1188
+ for (const source of notes) {
1189
+ const declarations: RelationDeclaration[] = [];
1190
+ for (const declaration of source.relationDeclarations ?? []) {
1191
+ observeConnection();
1192
+ declarations.push(declaration);
1193
+ }
1194
+ declarations.sort((left, right) =>
1195
+ left.line - right.line
1196
+ || left.predicate.localeCompare(right.predicate)
1197
+ || left.target.localeCompare(right.target));
1198
+ for (const declaration of declarations) {
1199
+ const declarationKey = `${source.id}\0${declaration.predicate}\0${declaration.target}`;
1200
+ if (declarationKeys.has(declarationKey)) continue;
1201
+ declarationKeys.add(declarationKey);
1202
+
1203
+ if (declaration.target.startsWith("kb://")) {
1204
+ if (source.id !== catalogNoteId) {
1205
+ externalAuthoredRelations.push({
1206
+ source: source.id,
1207
+ target: parseQualifiedDocumentUri(declaration.target).uri,
1208
+ predicate: declaration.predicate,
1209
+ provenance: {
1210
+ kind: "frontmatter",
1211
+ source: source.path,
1212
+ line: declaration.line,
1213
+ authoredTarget: declaration.target,
1214
+ },
1215
+ });
1216
+ }
1217
+ continue;
1218
+ }
1219
+
1220
+ const exactTarget = byId.get(declaration.target);
1221
+ if (exactTarget === undefined) {
1222
+ const basenameMatches = declaration.target.includes("/")
1223
+ ? []
1224
+ : (byBasename.get(declaration.target) ?? [])
1225
+ .filter((candidate) => candidate !== declaration.target);
1226
+ if (basenameMatches.length > 0) {
1227
+ relationIssues.push(malformedRelation(
1228
+ source.path,
1229
+ declaration.line,
1230
+ `Relation "${declaration.predicate}" target `
1231
+ + `${JSON.stringify(declaration.target)} is only a basename; `
1232
+ + "store the exact vault-root note ID.",
1233
+ {
1234
+ predicate: declaration.predicate,
1235
+ target: declaration.target,
1236
+ },
1237
+ ));
1238
+ continue;
1239
+ }
1240
+ relationIssues.push({
1241
+ kind: "broken",
1242
+ source: source.path,
1243
+ line: declaration.line,
1244
+ predicate: declaration.predicate,
1245
+ target: declaration.target,
1246
+ });
1247
+ continue;
1248
+ }
1249
+ if (source.id === catalogNoteId || exactTarget.id === catalogNoteId) continue;
1250
+
1251
+ const edgeKey = `${source.id}\0${declaration.predicate}\0${exactTarget.id}`;
1252
+ if (relationEdgeKeys.has(edgeKey)) continue;
1253
+ relationEdgeKeys.add(edgeKey);
1254
+ authoredRelations.push({
1255
+ source: source.id,
1256
+ target: exactTarget.id,
1257
+ predicate: declaration.predicate,
1258
+ provenance: {
1259
+ kind: "frontmatter",
1260
+ source: source.path,
1261
+ line: declaration.line,
1262
+ authoredTarget: declaration.target,
1263
+ },
1264
+ });
1265
+ }
1266
+ }
1267
+
1268
+ const sortedContextualLinks = contextualLinks.toSorted((left, right) =>
1269
+ left.source.localeCompare(right.source)
1270
+ || left.target.localeCompare(right.target)
1271
+ || left.line - right.line);
1272
+ const sortedAuthoredRelations = authoredRelations.toSorted(compareAuthoredRelations);
1273
+ const sortedExternalAuthoredRelations = externalAuthoredRelations
1274
+ .toSorted(compareExternalAuthoredRelations);
1275
+ const backlinks = sortedContextualLinks.toSorted((left, right) =>
1276
+ left.target.localeCompare(right.target)
1277
+ || left.source.localeCompare(right.source)
1278
+ || left.line - right.line);
1279
+ const contentNotes = notes.filter((note) => note.id !== catalogNoteId);
1280
+ const connected = new Set<string>();
1281
+ const linkedPairs = new Set<string>();
1282
+ const inboundById = new Map<string, Backlink[]>();
1283
+ const outboundById = new Map<string, number>();
1284
+ const inboundRelationsById = new Map<string, AuthoredRelation[]>();
1285
+ const outboundRelationsById = new Map<string, number>();
1286
+ for (const link of sortedContextualLinks) {
1287
+ const sourceId = withoutMarkdownExtension(link.source);
1288
+ const targetId = withoutMarkdownExtension(link.target);
1289
+ connected.add(sourceId);
1290
+ connected.add(targetId);
1291
+ linkedPairs.add(pairKey(sourceId, targetId));
1292
+ const inbound = inboundById.get(targetId) ?? [];
1293
+ inbound.push(link);
1294
+ inboundById.set(targetId, inbound);
1295
+ outboundById.set(sourceId, (outboundById.get(sourceId) ?? 0) + 1);
1296
+ }
1297
+ for (const relation of sortedAuthoredRelations) {
1298
+ connected.add(relation.source);
1299
+ connected.add(relation.target);
1300
+ linkedPairs.add(pairKey(relation.source, relation.target));
1301
+ const inbound = inboundRelationsById.get(relation.target) ?? [];
1302
+ inbound.push(relation);
1303
+ inboundRelationsById.set(relation.target, inbound);
1304
+ outboundRelationsById.set(
1305
+ relation.source,
1306
+ (outboundRelationsById.get(relation.source) ?? 0) + 1,
1307
+ );
1308
+ }
1309
+ for (const relation of sortedExternalAuthoredRelations) {
1310
+ connected.add(relation.source);
1311
+ outboundRelationsById.set(
1312
+ relation.source,
1313
+ (outboundRelationsById.get(relation.source) ?? 0) + 1,
1314
+ );
1315
+ }
1316
+
1317
+ const includeInSuggestions = options.includeInSuggestions ?? (() => true);
1318
+ const suggestionNotes = contentNotes.filter(includeInSuggestions);
1319
+ const scopedMentionNotes = options.mentionScope === undefined
1320
+ ? suggestionNotes
1321
+ : suggestionNotes.filter(options.mentionScope);
1322
+ const scopedMentionIds = new Set(scopedMentionNotes.map((note) => note.id));
1323
+ const phrasesByTarget = scopedMentionNotes.length === 0
1324
+ ? new Map<string, readonly UniquePhrase[]>()
1325
+ : uniquePhrasesByTarget(suggestionNotes);
1326
+ const mentions: MentionCandidate[] = [];
1327
+ let mentionPairs = 0;
1328
+ for (const source of suggestionNotes) {
1329
+ const targets = options.mentionScope === undefined
1330
+ || scopedMentionIds.has(source.id)
1331
+ ? suggestionNotes
1332
+ : scopedMentionNotes;
1333
+ let lowerSearchableText: string | undefined;
1334
+ for (const target of targets) {
1335
+ if (source.id === target.id) continue;
1336
+ if (mentionPairs >= maxMentionPairs) {
1337
+ throw new VaultAnalysisBudgetError(
1338
+ "mention-pairs",
1339
+ maxMentionPairs,
1340
+ `Vault analysis exceeds the ${maxMentionPairs} mention-pair limit.`,
1341
+ );
1342
+ }
1343
+ mentionPairs += 1;
1344
+ if (linkedPairs.has(pairKey(source.id, target.id))) continue;
1345
+ lowerSearchableText ??= source.searchableText.toLocaleLowerCase("en-US");
1346
+ for (const { phrase, lowerPhrase } of phrasesByTarget.get(target.id) ?? []) {
1347
+ const offset = phraseOffset(lowerSearchableText, lowerPhrase);
1348
+ if (offset === -1) continue;
1349
+ if (mentions.length >= maxMentions) {
1350
+ throw new VaultAnalysisBudgetError(
1351
+ "mentions",
1352
+ maxMentions,
1353
+ `Vault analysis exceeds the ${maxMentions} mention limit.`,
1354
+ );
1355
+ }
1356
+ mentions.push({
1357
+ source: source.path,
1358
+ line: source.searchableText.slice(0, offset).split("\n").length,
1359
+ target: target.path,
1360
+ phrase,
1361
+ });
1362
+ break;
1363
+ }
1364
+ }
1365
+ }
1366
+
1367
+ return {
1368
+ noteCount: contentNotes.length,
1369
+ contextualLinks: sortedContextualLinks,
1370
+ backlinks,
1371
+ authoredRelations: sortedAuthoredRelations,
1372
+ externalAuthoredRelations: sortedExternalAuthoredRelations,
1373
+ noteConnections: contentNotes
1374
+ .map((note): NoteConnections => ({
1375
+ id: note.id,
1376
+ path: note.path,
1377
+ inboundContextualCount: inboundById.get(note.id)?.length ?? 0,
1378
+ outboundContextualCount: outboundById.get(note.id) ?? 0,
1379
+ backlinks: inboundById.get(note.id) ?? [],
1380
+ inboundRelationCount: inboundRelationsById.get(note.id)?.length ?? 0,
1381
+ outboundRelationCount: outboundRelationsById.get(note.id) ?? 0,
1382
+ relationBacklinks: inboundRelationsById.get(note.id) ?? [],
1383
+ }))
1384
+ .toSorted((left, right) => left.path.localeCompare(right.path)),
1385
+ issues: issues.sort((left, right) =>
1386
+ left.source.localeCompare(right.source) || left.line - right.line || left.target.localeCompare(right.target)),
1387
+ relationIssues: relationIssues.toSorted(compareRelationIssues),
1388
+ orphans: suggestionNotes
1389
+ .filter((note) => !connected.has(note.id))
1390
+ .map((note) => note.path)
1391
+ .sort(),
1392
+ mentions: mentions.sort((left, right) =>
1393
+ left.source.localeCompare(right.source) || left.line - right.line || left.target.localeCompare(right.target)),
1394
+ };
1395
+ }
1396
+
1397
+ const sectionTitle = (directory: string): string =>
1398
+ directory
1399
+ .split("-")
1400
+ .map((part) => part === "" ? "" : (part[0]?.toUpperCase() ?? "") + part.slice(1))
1401
+ .join(" ");
1402
+
1403
+ function safeCatalogCharacter(character: string): string {
1404
+ const codePoint = character.codePointAt(0) ?? 0;
1405
+ return codePoint <= 0x1f
1406
+ || (codePoint >= 0x7f && codePoint <= 0x9f)
1407
+ || codePoint === 0x061c
1408
+ || codePoint === 0x200e
1409
+ || codePoint === 0x200f
1410
+ || (codePoint >= 0x2028 && codePoint <= 0x202e)
1411
+ || (codePoint >= 0x2066 && codePoint <= 0x2069)
1412
+ ? " "
1413
+ : character;
1414
+ }
1415
+
1416
+ function safeCatalogText(value: string, limit: number): string {
1417
+ return [...concise(value, limit)]
1418
+ .map(safeCatalogCharacter)
1419
+ .join("")
1420
+ .replaceAll("<!--", "‹!--")
1421
+ .replaceAll("-->", "--›")
1422
+ .replaceAll("|", " — ")
1423
+ .replaceAll("]]", "]")
1424
+ .replace(/\s+/gu, " ")
1425
+ .trim();
1426
+ }
1427
+
1428
+ function safeWikiTarget(value: string): string {
1429
+ return value.split("/").map((segment) => encodeURIComponent(segment)).join("/");
1430
+ }
1431
+
1432
+ export function renderCatalog(
1433
+ notes: readonly Note[],
1434
+ catalogNoteId = "index",
1435
+ ): string {
1436
+ const normalizedCatalogNoteId = withoutMarkdownExtension(
1437
+ normalizeVaultPath(catalogNoteId),
1438
+ );
1439
+ const groups = new Map<string, Note[]>();
1440
+ for (const note of notes.filter((candidate) => candidate.id !== normalizedCatalogNoteId)) {
1441
+ const directory = note.id.includes("/") ? note.id.split("/", 1)[0] ?? "Notes" : "Notes";
1442
+ const group = groups.get(directory) ?? [];
1443
+ group.push(note);
1444
+ groups.set(directory, group);
1445
+ }
1446
+
1447
+ const lines = [catalogStart, "## Note catalog", ""];
1448
+ if (groups.size === 0) {
1449
+ lines.push("_No durable notes have been filed yet._", "", catalogEnd);
1450
+ return lines.join("\n");
1451
+ }
1452
+
1453
+ for (const [directory, group] of [...groups].sort(([left], [right]) => left.localeCompare(right))) {
1454
+ lines.push(`### ${safeCatalogText(sectionTitle(directory), 120) || "Notes"}`, "");
1455
+ for (const note of group.sort((left, right) =>
1456
+ left.title.localeCompare(right.title) || left.id.localeCompare(right.id))) {
1457
+ const details = [
1458
+ note.properties.type === "plan" && note.properties.status !== undefined
1459
+ ? `Status: ${safeCatalogText(note.properties.status, 60)}.`
1460
+ : "",
1461
+ safeCatalogText(note.summary, 180),
1462
+ ].filter((value) => value !== "").join(" ");
1463
+ const suffix = details === "" ? "" : ` — ${details}`;
1464
+ const label = safeCatalogText(note.title, 240)
1465
+ || safeCatalogText(posix.basename(note.id), 240)
1466
+ || "Untitled";
1467
+ lines.push(`- [[${safeWikiTarget(note.id)}|${label}]]${suffix}`);
1468
+ }
1469
+ lines.push("");
1470
+ }
1471
+ lines.push(catalogEnd);
1472
+ return lines.join("\n");
1473
+ }
1474
+
1475
+ export function replaceCatalog(indexContent: string, catalog: string): string {
1476
+ const start = indexContent.indexOf(catalogStart);
1477
+ const end = indexContent.indexOf(catalogEnd);
1478
+ if (start === -1 && end === -1) return indexContent.trimEnd() + "\n\n" + catalog + "\n";
1479
+ if (start === -1 || end === -1 || end < start) {
1480
+ throw new Error("kb/index.md has a malformed managed catalog boundary");
1481
+ }
1482
+ if (indexContent.indexOf(catalogStart, start + catalogStart.length) !== -1
1483
+ || indexContent.indexOf(catalogEnd, end + catalogEnd.length) !== -1) {
1484
+ throw new Error("kb/index.md has duplicate managed catalog boundaries");
1485
+ }
1486
+ return indexContent.slice(0, start) + catalog + indexContent.slice(end + catalogEnd.length);
1487
+ }