@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/vault.ts ADDED
@@ -0,0 +1,636 @@
1
+ import { randomUUID } from "node:crypto";
2
+ import { constants } from "node:fs";
3
+ import {
4
+ lstat,
5
+ open,
6
+ readdir,
7
+ realpath,
8
+ rename,
9
+ rm,
10
+ type FileHandle,
11
+ } from "node:fs/promises";
12
+ import { basename, dirname, join, relative, resolve, sep } from "node:path";
13
+
14
+ import {
15
+ analyzeVault,
16
+ isCanonicalNoteId,
17
+ lookupNote,
18
+ MAX_ANALYZED_NOTES,
19
+ normalizeVaultPath,
20
+ parseNote,
21
+ renderCatalog,
22
+ replaceCatalog,
23
+ type AnalyzeVaultOptions,
24
+ type Note,
25
+ type VaultAnalysis,
26
+ } from "./graph.js";
27
+
28
+ export const MAX_SCANNED_NOTES = MAX_ANALYZED_NOTES;
29
+ export const MAX_NOTE_UTF8_BYTES = 16 * 1_024 * 1_024;
30
+ export const MAX_VAULT_UTF8_BYTES = 256 * 1_024 * 1_024;
31
+
32
+ export type VaultScanBudgetKind =
33
+ | "notes"
34
+ | "note-bytes"
35
+ | "total-bytes";
36
+
37
+ /** A stable failure for callers that need to distinguish bounded disk input. */
38
+ export class VaultScanBudgetError extends RangeError {
39
+ readonly kind: VaultScanBudgetKind;
40
+ readonly limit: number;
41
+
42
+ constructor(
43
+ kind: VaultScanBudgetKind,
44
+ limit: number,
45
+ message: string,
46
+ ) {
47
+ super(message);
48
+ this.name = "VaultScanBudgetError";
49
+ this.kind = kind;
50
+ this.limit = limit;
51
+ }
52
+ }
53
+
54
+ export const defaultIgnoredDirectories = new Set([
55
+ ".git",
56
+ ".next",
57
+ ".turbo",
58
+ ".vercel",
59
+ "coverage",
60
+ "dist",
61
+ "node_modules",
62
+ ]);
63
+
64
+ export type CatalogMode = "managed" | "authored";
65
+
66
+ export type VaultIndexState = "current" | "stale" | "updated" | "authored";
67
+
68
+ export type VaultSnapshot = {
69
+ readonly root: string;
70
+ readonly indexPath: string;
71
+ readonly catalogMode: CatalogMode;
72
+ readonly index: VaultIndexState;
73
+ readonly notes: readonly Note[];
74
+ readonly analysis: VaultAnalysis;
75
+ };
76
+
77
+ export type ScanVaultOptions = Omit<AnalyzeVaultOptions, "mentionScope"> & {
78
+ readonly index?: string;
79
+ /** Override the configured index's `kb_catalog` mode for this operation. */
80
+ readonly catalogMode?: CatalogMode;
81
+ readonly ignoredDirectories?: ReadonlySet<string>;
82
+ /** Maximum UTF-8 bytes accepted from one Markdown note. */
83
+ readonly maxNoteBytes?: number;
84
+ /** Maximum UTF-8 bytes accepted across all Markdown notes. */
85
+ readonly maxTotalBytes?: number;
86
+ /**
87
+ * `false` omits mention pairing; a note query restricts pairing to edges
88
+ * touching every note that query can resolve to.
89
+ */
90
+ readonly mentionScope?: string | false;
91
+ };
92
+
93
+ export async function markdownFiles(
94
+ directory: string,
95
+ ignoredDirectories: ReadonlySet<string> = defaultIgnoredDirectories,
96
+ ): Promise<readonly string[]> {
97
+ const files: string[] = [];
98
+ const entries = await readdir(directory, { withFileTypes: true });
99
+ for (const entry of entries.toSorted((left, right) => left.name.localeCompare(right.name))) {
100
+ if (entry.name.startsWith(".")) continue;
101
+ const entryPath = join(directory, entry.name);
102
+ if (entry.isDirectory()) {
103
+ if (ignoredDirectories.has(entry.name)) continue;
104
+ files.push(...await markdownFiles(entryPath, ignoredDirectories));
105
+ continue;
106
+ }
107
+ if (entry.isFile() && entry.name.endsWith(".md") && entry.name !== "AGENTS.md") {
108
+ files.push(entryPath);
109
+ }
110
+ }
111
+ return files;
112
+ }
113
+
114
+ type DiscoveredNoteFile = {
115
+ readonly absolutePath: string;
116
+ readonly vaultPath: string;
117
+ readonly rawId: string;
118
+ };
119
+
120
+ type ScannedNoteFile = DiscoveredNoteFile & {
121
+ readonly device: bigint;
122
+ readonly inode: bigint;
123
+ };
124
+
125
+ type ScannableFileMetadata = {
126
+ readonly dev: bigint;
127
+ readonly ino: bigint;
128
+ readonly nlink: bigint;
129
+ readonly size: bigint;
130
+ isFile(): boolean;
131
+ isSymbolicLink(): boolean;
132
+ };
133
+
134
+ function checkedScanLimit(
135
+ value: number | undefined,
136
+ hardMaximum: number,
137
+ option: "maxNotes" | "maxNoteBytes" | "maxTotalBytes",
138
+ ): number {
139
+ const limit = value ?? hardMaximum;
140
+ if (!Number.isSafeInteger(limit) || limit < 0 || limit > hardMaximum) {
141
+ throw new RangeError(
142
+ `${option} must be a safe integer from 0 through ${hardMaximum}.`,
143
+ );
144
+ }
145
+ return limit;
146
+ }
147
+
148
+ function normalizedRawNoteId(rawId: string): string {
149
+ return normalizeVaultPath(rawId).normalize("NFC");
150
+ }
151
+
152
+ function validateScannedNotePaths(
153
+ root: string,
154
+ paths: readonly string[],
155
+ ): readonly DiscoveredNoteFile[] {
156
+ const files = paths.map((absolutePath): DiscoveredNoteFile => {
157
+ const vaultPath = relative(root, absolutePath).split(sep).join("/");
158
+ return {
159
+ absolutePath,
160
+ vaultPath,
161
+ rawId: vaultPath.slice(0, -3),
162
+ };
163
+ });
164
+
165
+ const pathByNormalizedId = new Map<string, string>();
166
+ for (const file of files) {
167
+ const normalizedId = normalizedRawNoteId(file.rawId);
168
+ const collision = pathByNormalizedId.get(normalizedId);
169
+ if (collision !== undefined && collision !== file.vaultPath) {
170
+ throw new Error(
171
+ `Vault note paths ${JSON.stringify(collision)} and `
172
+ + `${JSON.stringify(file.vaultPath)} normalize to the same note ID `
173
+ + `${JSON.stringify(normalizedId)}.`,
174
+ );
175
+ }
176
+ pathByNormalizedId.set(normalizedId, file.vaultPath);
177
+ }
178
+
179
+ for (const file of files) {
180
+ if (isCanonicalNoteId(file.rawId)) continue;
181
+ if (file.rawId !== file.rawId.normalize("NFC")) {
182
+ throw new Error(
183
+ `Vault note path ${JSON.stringify(file.vaultPath)} is not NFC; `
184
+ + `its extensionless note ID must be exactly `
185
+ + `${JSON.stringify(file.rawId.normalize("NFC"))}.`,
186
+ );
187
+ }
188
+ if (file.rawId.includes("\\")) {
189
+ throw new Error(
190
+ `Vault note path ${JSON.stringify(file.vaultPath)} contains a backslash; `
191
+ + "note IDs must use exact vault-root directory separators.",
192
+ );
193
+ }
194
+ throw new Error(
195
+ `Vault note path ${JSON.stringify(file.vaultPath)} must have an exact `
196
+ + "canonical extensionless vault-root note ID.",
197
+ );
198
+ }
199
+ return files;
200
+ }
201
+
202
+ function assertScannableNoteFile(
203
+ vaultPath: string,
204
+ metadata: ScannableFileMetadata,
205
+ ): void {
206
+ if (metadata.isSymbolicLink()) {
207
+ throw new Error(`Vault note ${JSON.stringify(vaultPath)} must not be a symbolic link.`);
208
+ }
209
+ if (!metadata.isFile()) {
210
+ throw new Error(`Vault note ${JSON.stringify(vaultPath)} must be a regular file.`);
211
+ }
212
+ if (metadata.nlink !== 1n) {
213
+ throw new Error(`Vault note ${JSON.stringify(vaultPath)} must not be hard-linked.`);
214
+ }
215
+ }
216
+
217
+ function noteBytesError(
218
+ vaultPath: string,
219
+ limit: number,
220
+ ): VaultScanBudgetError {
221
+ return new VaultScanBudgetError(
222
+ "note-bytes",
223
+ limit,
224
+ `Vault note ${JSON.stringify(vaultPath)} exceeds the ${limit}-byte UTF-8 limit.`,
225
+ );
226
+ }
227
+
228
+ function totalBytesError(limit: number): VaultScanBudgetError {
229
+ return new VaultScanBudgetError(
230
+ "total-bytes",
231
+ limit,
232
+ `Vault scan exceeds the ${limit}-byte cumulative UTF-8 limit.`,
233
+ );
234
+ }
235
+
236
+ async function readBoundedNote(
237
+ handle: FileHandle,
238
+ vaultPath: string,
239
+ maxNoteBytes: number,
240
+ remainingTotalBytes: number,
241
+ maxTotalBytes: number,
242
+ ): Promise<{ readonly content: string; readonly bytes: number }> {
243
+ const chunks: Uint8Array[] = [];
244
+ let bytes = 0;
245
+ for (;;) {
246
+ const remaining = Math.min(
247
+ maxNoteBytes - bytes,
248
+ remainingTotalBytes - bytes,
249
+ );
250
+ const buffer = new Uint8Array(Math.min(64 * 1_024, Math.max(1, remaining + 1)));
251
+ const result = await handle.read(buffer, 0, buffer.byteLength, null);
252
+ if (result.bytesRead === 0) break;
253
+ bytes += result.bytesRead;
254
+ if (bytes > maxNoteBytes) throw noteBytesError(vaultPath, maxNoteBytes);
255
+ if (bytes > remainingTotalBytes) {
256
+ throw totalBytesError(maxTotalBytes);
257
+ }
258
+ chunks.push(buffer.slice(0, result.bytesRead));
259
+ }
260
+
261
+ const joined = new Uint8Array(bytes);
262
+ let offset = 0;
263
+ for (const chunk of chunks) {
264
+ joined.set(chunk, offset);
265
+ offset += chunk.byteLength;
266
+ }
267
+ try {
268
+ return {
269
+ content: new TextDecoder("utf-8", { fatal: true }).decode(joined),
270
+ bytes,
271
+ };
272
+ } catch (error) {
273
+ throw new Error(
274
+ `Vault note ${JSON.stringify(vaultPath)} is not valid UTF-8.`,
275
+ { cause: error },
276
+ );
277
+ }
278
+ }
279
+
280
+ export async function readVaultNotes(
281
+ root: string,
282
+ ignoredDirectories: ReadonlySet<string> = defaultIgnoredDirectories,
283
+ limits: Pick<
284
+ ScanVaultOptions,
285
+ "maxNotes" | "maxNoteBytes" | "maxTotalBytes"
286
+ > = {},
287
+ ): Promise<Note[]> {
288
+ const maxNotes = checkedScanLimit(
289
+ limits.maxNotes,
290
+ MAX_SCANNED_NOTES,
291
+ "maxNotes",
292
+ );
293
+ const maxNoteBytes = checkedScanLimit(
294
+ limits.maxNoteBytes,
295
+ MAX_NOTE_UTF8_BYTES,
296
+ "maxNoteBytes",
297
+ );
298
+ const maxTotalBytes = checkedScanLimit(
299
+ limits.maxTotalBytes,
300
+ MAX_VAULT_UTF8_BYTES,
301
+ "maxTotalBytes",
302
+ );
303
+ const paths = await markdownFiles(root, ignoredDirectories);
304
+ if (paths.length > maxNotes) {
305
+ throw new VaultScanBudgetError(
306
+ "notes",
307
+ maxNotes,
308
+ `Vault scan exceeds the ${maxNotes} Markdown note limit.`,
309
+ );
310
+ }
311
+
312
+ const files = validateScannedNotePaths(root, paths);
313
+ let declaredTotal = 0n;
314
+ const preflight: ScannedNoteFile[] = [];
315
+ for (const file of files) {
316
+ const metadata = await lstat(file.absolutePath, { bigint: true });
317
+ assertScannableNoteFile(file.vaultPath, metadata);
318
+ if (metadata.size > BigInt(maxNoteBytes)) {
319
+ throw noteBytesError(file.vaultPath, maxNoteBytes);
320
+ }
321
+ declaredTotal += metadata.size;
322
+ if (declaredTotal > BigInt(maxTotalBytes)) {
323
+ throw totalBytesError(maxTotalBytes);
324
+ }
325
+ preflight.push({
326
+ ...file,
327
+ device: metadata.dev,
328
+ inode: metadata.ino,
329
+ });
330
+ }
331
+
332
+ const notes: Note[] = [];
333
+ let observedTotal = 0;
334
+ for (const file of preflight) {
335
+ const handle = await open(
336
+ file.absolutePath,
337
+ constants.O_RDONLY | constants.O_NOFOLLOW,
338
+ );
339
+ try {
340
+ const beforeRead = await handle.stat({ bigint: true });
341
+ assertScannableNoteFile(file.vaultPath, beforeRead);
342
+ if (beforeRead.dev !== file.device || beforeRead.ino !== file.inode) {
343
+ throw new Error(
344
+ `Vault note ${JSON.stringify(file.vaultPath)} changed during scan; retry.`,
345
+ );
346
+ }
347
+ if (beforeRead.size > BigInt(maxNoteBytes)) {
348
+ throw noteBytesError(file.vaultPath, maxNoteBytes);
349
+ }
350
+ if (BigInt(observedTotal) + beforeRead.size > BigInt(maxTotalBytes)) {
351
+ throw totalBytesError(maxTotalBytes);
352
+ }
353
+ const read = await readBoundedNote(
354
+ handle,
355
+ file.vaultPath,
356
+ maxNoteBytes,
357
+ maxTotalBytes - observedTotal,
358
+ maxTotalBytes,
359
+ );
360
+ const afterRead = await handle.stat({ bigint: true });
361
+ if (
362
+ afterRead.dev !== file.device
363
+ || afterRead.ino !== file.inode
364
+ || afterRead.size !== beforeRead.size
365
+ || afterRead.size !== BigInt(read.bytes)
366
+ ) {
367
+ throw new Error(
368
+ `Vault note ${JSON.stringify(file.vaultPath)} changed during scan; retry.`,
369
+ );
370
+ }
371
+ observedTotal += read.bytes;
372
+ notes.push(parseNote(file.vaultPath, read.content));
373
+ } finally {
374
+ await handle.close();
375
+ }
376
+ }
377
+ return notes;
378
+ }
379
+
380
+ type IndexRevision = {
381
+ readonly content: string;
382
+ readonly device: bigint;
383
+ readonly inode: bigint;
384
+ readonly mode: number;
385
+ };
386
+
387
+ function confined(root: string, path: string): boolean {
388
+ const fromRoot = relative(root, path);
389
+ return fromRoot !== ""
390
+ && fromRoot !== ".."
391
+ && !fromRoot.startsWith(`..${sep}`);
392
+ }
393
+
394
+ async function assertConfinedIndexParents(root: string, path: string): Promise<void> {
395
+ if (!confined(root, path)) throw new Error("The configured index must be a file inside the vault root.");
396
+ const parent = dirname(path);
397
+ const segments = relative(root, parent).split(sep).filter((segment) => segment !== "");
398
+ let current = root;
399
+ for (const segment of segments) {
400
+ current = join(current, segment);
401
+ const metadata = await lstat(current);
402
+ if (metadata.isSymbolicLink()) {
403
+ throw new Error("The configured index path must not traverse a symbolic link.");
404
+ }
405
+ if (!metadata.isDirectory()) {
406
+ throw new Error("Every configured index parent must be a directory.");
407
+ }
408
+ }
409
+ const canonicalParent = await realpath(parent);
410
+ if (!confined(root, join(canonicalParent, basename(path)))) {
411
+ throw new Error("The configured index parent resolves outside the vault root.");
412
+ }
413
+ }
414
+
415
+ async function readIndexRevision(
416
+ root: string,
417
+ path: string,
418
+ maxNoteBytes = MAX_NOTE_UTF8_BYTES,
419
+ ): Promise<IndexRevision> {
420
+ await assertConfinedIndexParents(root, path);
421
+ const handle = await open(path, constants.O_RDONLY | constants.O_NOFOLLOW);
422
+ try {
423
+ const metadata = await handle.stat({ bigint: true });
424
+ if (!metadata.isFile()) throw new Error("The configured index must be a regular file.");
425
+ if (metadata.nlink !== 1n) throw new Error("The configured index must not be hard-linked.");
426
+ const canonicalPath = await realpath(path);
427
+ if (!confined(root, canonicalPath)) {
428
+ throw new Error("The configured index resolves outside the vault root.");
429
+ }
430
+ const vaultPath = relative(root, path).split(sep).join("/");
431
+ if (metadata.size > BigInt(maxNoteBytes)) {
432
+ throw noteBytesError(vaultPath, maxNoteBytes);
433
+ }
434
+ const read = await readBoundedNote(
435
+ handle,
436
+ vaultPath,
437
+ maxNoteBytes,
438
+ maxNoteBytes,
439
+ maxNoteBytes,
440
+ );
441
+ const afterRead = await handle.stat({ bigint: true });
442
+ if (
443
+ afterRead.dev !== metadata.dev
444
+ || afterRead.ino !== metadata.ino
445
+ || afterRead.size !== metadata.size
446
+ || afterRead.size !== BigInt(read.bytes)
447
+ ) {
448
+ throw new Error("The configured index changed during scan; retry.");
449
+ }
450
+ return {
451
+ content: read.content,
452
+ device: metadata.dev,
453
+ inode: metadata.ino,
454
+ mode: Number(metadata.mode & 0o777n),
455
+ };
456
+ } finally {
457
+ await handle.close();
458
+ }
459
+ }
460
+
461
+ function sameRevision(left: IndexRevision, right: IndexRevision): boolean {
462
+ return left.device === right.device
463
+ && left.inode === right.inode
464
+ && left.content === right.content;
465
+ }
466
+
467
+ function parsedCatalogMode(value: unknown, source: string): CatalogMode | undefined {
468
+ if (value === undefined) return undefined;
469
+ if (value === "managed" || value === "authored") return value;
470
+ throw new Error(
471
+ `${source} must be exactly "managed" or "authored".`,
472
+ );
473
+ }
474
+
475
+ function declaredCatalogMode(indexNote: Note): CatalogMode | undefined {
476
+ const declaration = Object.entries(indexNote.metadata).find(([name]) =>
477
+ name.toLocaleLowerCase("en-US") === "kb_catalog");
478
+ return parsedCatalogMode(
479
+ declaration?.[1],
480
+ `The configured index frontmatter property "kb_catalog"`,
481
+ );
482
+ }
483
+
484
+ async function atomicReplace(
485
+ root: string,
486
+ path: string,
487
+ content: string,
488
+ expected: IndexRevision,
489
+ ): Promise<void> {
490
+ const beforeWrite = await readIndexRevision(root, path);
491
+ if (!sameRevision(beforeWrite, expected)) {
492
+ throw new Error("The configured index changed during refresh; retry without overwriting the editor's changes.");
493
+ }
494
+ const directory = dirname(path);
495
+ const temporaryPath = join(directory, `.${basename(path)}.${process.pid}.${randomUUID()}.tmp`);
496
+ await assertConfinedIndexParents(root, path);
497
+ const handle = await open(
498
+ temporaryPath,
499
+ constants.O_WRONLY | constants.O_CREAT | constants.O_EXCL | constants.O_NOFOLLOW,
500
+ expected.mode,
501
+ );
502
+ let closed = false;
503
+ try {
504
+ await handle.writeFile(content, "utf8");
505
+ await handle.sync();
506
+ await handle.close();
507
+ closed = true;
508
+ const beforeRename = await readIndexRevision(root, path);
509
+ if (!sameRevision(beforeRename, expected)) {
510
+ throw new Error("The configured index changed during refresh; retry without overwriting the editor's changes.");
511
+ }
512
+ await assertConfinedIndexParents(root, path);
513
+ await rename(temporaryPath, path);
514
+ } catch (error) {
515
+ if (!closed) await handle.close().catch(() => undefined);
516
+ await rm(temporaryPath, { force: true }).catch(() => undefined);
517
+ throw error;
518
+ }
519
+ }
520
+
521
+ async function snapshot(
522
+ rootInput: string,
523
+ options: ScanVaultOptions,
524
+ writeIndex: boolean,
525
+ ): Promise<VaultSnapshot> {
526
+ const requestedRoot = resolve(rootInput);
527
+ const root = await realpath(requestedRoot);
528
+ const rootMetadata = await lstat(root);
529
+ if (!rootMetadata.isDirectory()) throw new Error("The vault root must be a directory.");
530
+ const indexPath = resolve(root, options.index ?? "index.md");
531
+ const relativeIndex = relative(root, indexPath);
532
+ if (!confined(root, indexPath)) {
533
+ throw new Error("The configured index must be a file inside the vault root.");
534
+ }
535
+ if (!indexPath.toLowerCase().endsWith(".md")) {
536
+ throw new Error("The configured index must be a Markdown file.");
537
+ }
538
+ const vaultIndexPath = relativeIndex.split(sep).join("/");
539
+ const catalogNoteId = vaultIndexPath.toLowerCase().endsWith(".md")
540
+ ? vaultIndexPath.slice(0, -3)
541
+ : vaultIndexPath;
542
+ const notes = await readVaultNotes(root, options.ignoredDirectories, {
543
+ ...(options.maxNotes === undefined ? {} : { maxNotes: options.maxNotes }),
544
+ ...(options.maxNoteBytes === undefined
545
+ ? {}
546
+ : { maxNoteBytes: options.maxNoteBytes }),
547
+ ...(options.maxTotalBytes === undefined
548
+ ? {}
549
+ : { maxTotalBytes: options.maxTotalBytes }),
550
+ });
551
+ const indexRevision = await readIndexRevision(
552
+ root,
553
+ indexPath,
554
+ options.maxNoteBytes ?? MAX_NOTE_UTF8_BYTES,
555
+ );
556
+ const currentIndex = indexRevision.content;
557
+ const indexNote = parseNote(vaultIndexPath, currentIndex);
558
+ const catalogMode = parsedCatalogMode(
559
+ options.catalogMode,
560
+ "ScanVaultOptions.catalogMode",
561
+ ) ?? declaredCatalogMode(indexNote) ?? "managed";
562
+ let index: VaultIndexState = "authored";
563
+
564
+ if (catalogMode === "managed") {
565
+ const expectedIndex = replaceCatalog(
566
+ currentIndex,
567
+ renderCatalog(notes, catalogNoteId),
568
+ );
569
+ const stale = currentIndex !== expectedIndex;
570
+ index = stale ? "stale" : "current";
571
+
572
+ if (writeIndex && stale) {
573
+ await atomicReplace(root, indexPath, expectedIndex, indexRevision);
574
+ index = "updated";
575
+ const parsed = parseNote(vaultIndexPath, expectedIndex);
576
+ const noteIndex = notes.findIndex((note) => note.path === vaultIndexPath);
577
+ if (noteIndex === -1) notes.push(parsed);
578
+ else notes[noteIndex] = parsed;
579
+ }
580
+ }
581
+
582
+ const mentionScope = options.mentionScope;
583
+ const mentionIds = new Set<string>();
584
+ if (typeof mentionScope === "string") {
585
+ const lookup = lookupNote(notes, mentionScope);
586
+ if (lookup.kind === "found") mentionIds.add(lookup.note.id);
587
+ else if (lookup.kind === "ambiguous") {
588
+ for (const note of lookup.candidates) mentionIds.add(note.id);
589
+ }
590
+ }
591
+ const mentionScopePredicate = mentionScope === undefined
592
+ ? undefined
593
+ : (note: Note): boolean =>
594
+ mentionScope !== false && mentionIds.has(note.id);
595
+
596
+ return {
597
+ root,
598
+ indexPath,
599
+ catalogMode,
600
+ index,
601
+ notes,
602
+ analysis: analyzeVault(notes, {
603
+ catalogNoteId,
604
+ ...(options.includeInSuggestions === undefined
605
+ ? {}
606
+ : { includeInSuggestions: options.includeInSuggestions }),
607
+ ...(mentionScopePredicate === undefined
608
+ ? {}
609
+ : { mentionScope: mentionScopePredicate }),
610
+ ...(options.maxNotes === undefined ? {} : { maxNotes: options.maxNotes }),
611
+ ...(options.maxConnectionObservations === undefined
612
+ ? {}
613
+ : { maxConnectionObservations: options.maxConnectionObservations }),
614
+ ...(options.maxMentionPairs === undefined
615
+ ? {}
616
+ : { maxMentionPairs: options.maxMentionPairs }),
617
+ ...(options.maxMentions === undefined
618
+ ? {}
619
+ : { maxMentions: options.maxMentions }),
620
+ }),
621
+ };
622
+ }
623
+
624
+ export async function scanVault(
625
+ root = ".",
626
+ options: ScanVaultOptions = {},
627
+ ): Promise<VaultSnapshot> {
628
+ return snapshot(root, options, false);
629
+ }
630
+
631
+ export async function refreshVault(
632
+ root = ".",
633
+ options: ScanVaultOptions = {},
634
+ ): Promise<VaultSnapshot> {
635
+ return snapshot(root, options, true);
636
+ }