@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/git.ts ADDED
@@ -0,0 +1,1276 @@
1
+ import { spawn } from "node:child_process";
2
+ import { randomUUID } from "node:crypto";
3
+ import { realpath, stat } from "node:fs/promises";
4
+ import { isAbsolute, relative, resolve, sep } from "node:path";
5
+
6
+ import type { Note } from "./graph.js";
7
+
8
+ export const MAX_GIT_HISTORY_COMMITS = 1_000;
9
+ export const MAX_GIT_HISTORY_NOTES = 10_000;
10
+ export const MAX_GIT_HISTORY_OUTPUT_BYTES = 32 * 1024 * 1024;
11
+ export const MAX_GIT_HISTORY_TIMEOUT_MS = 30_000;
12
+ export const MAX_GIT_PATHS_PER_COMMIT = 2_000;
13
+ export const MAX_GIT_PATH_OBSERVATIONS = 100_000;
14
+ export const MAX_GIT_NOTE_ID_UTF8_BYTES = 16 * 1_024;
15
+ export const MAX_GIT_NOTE_IDS_UTF8_BYTES = 16 * 1_024 * 1_024;
16
+
17
+ const DEFAULT_COMMITS = 200;
18
+ const DEFAULT_OUTPUT_BYTES = 8 * 1024 * 1024;
19
+ const DEFAULT_TIMEOUT_MS = 10_000;
20
+ const DEFAULT_COMMITS_PER_NOTE = 5;
21
+ const DEFAULT_COCHANGED_PATHS = 20;
22
+ const DEFAULT_SEARCH_LIMIT = 20;
23
+ const MAX_COMMITS_PER_NOTE = 50;
24
+ const MAX_COCHANGED_PATHS = 100;
25
+ const MAX_SEARCH_LIMIT = 100;
26
+ const MAX_QUERY_LENGTH = 500;
27
+ const commitMarker = "KB-GIT-HISTORY-V1";
28
+ const objectIdPattern = /^[0-9a-f]{40}(?:[0-9a-f]{24})?$/u;
29
+
30
+ export type GitCommandRequest = {
31
+ /** Git argv only. Providers must execute these arguments without a shell. */
32
+ readonly arguments: readonly string[];
33
+ readonly cwd: string;
34
+ readonly timeoutMs: number;
35
+ readonly maxOutputBytes: number;
36
+ };
37
+
38
+ export type GitCommandResult =
39
+ | {
40
+ readonly status: "ok";
41
+ readonly stdout: string | Uint8Array;
42
+ readonly stderr?: string | Uint8Array;
43
+ }
44
+ | {
45
+ readonly status: "unavailable";
46
+ readonly message: string;
47
+ }
48
+ | {
49
+ readonly status: "failed";
50
+ readonly message: string;
51
+ readonly exitCode?: number;
52
+ readonly stderr?: string | Uint8Array;
53
+ readonly reason?: "exit" | "timeout" | "output-limit";
54
+ };
55
+
56
+ export type GitCommandProvider = (
57
+ request: GitCommandRequest,
58
+ ) => Promise<GitCommandResult>;
59
+
60
+ export type GitHistoryDependencies = {
61
+ readonly runGit?: GitCommandProvider;
62
+ };
63
+
64
+ export type IndexGitHistoryOptions = {
65
+ /** Repository working tree containing the vault. */
66
+ readonly repository: string;
67
+ /** Vault root, which must resolve inside repository. */
68
+ readonly root: string;
69
+ /** Live vault notes. IDs are extensionless vault-relative paths. */
70
+ readonly notes: readonly Pick<Note, "id" | "path">[];
71
+ readonly maxCommits?: number;
72
+ /** Aggregate stdout and stderr budget across all Git commands. */
73
+ readonly maxOutputBytes?: number;
74
+ /** Aggregate wall-clock budget across all Git commands. */
75
+ readonly timeoutMs?: number;
76
+ /** Turn an absent Git executable into a typed error instead of an optional result. */
77
+ readonly required?: boolean;
78
+ };
79
+
80
+ export type GitHistoryCommit = {
81
+ readonly hash: string;
82
+ readonly committedAt: string;
83
+ readonly subject: string;
84
+ /** Repository-relative paths, including the note itself. */
85
+ readonly changedPaths: readonly string[];
86
+ /** Present only when changedPaths retains current live-note paths after a detail limit. */
87
+ readonly changedPathDetailsLimited?: true;
88
+ };
89
+
90
+ export type GitHistoryLimitedCommit = {
91
+ readonly hash: string;
92
+ readonly committedAt: string;
93
+ readonly subject: string;
94
+ readonly reason: "changed-path-limit";
95
+ readonly pathLimit: number;
96
+ /** Number of path records Git emitted for this commit. */
97
+ readonly observedPathRecords: number;
98
+ readonly affectedNoteIds: readonly string[];
99
+ };
100
+
101
+ export type GitIndexedNote = {
102
+ readonly id: string;
103
+ readonly path: string;
104
+ readonly repositoryPath: string;
105
+ /** Newest first. */
106
+ readonly commits: readonly GitHistoryCommit[];
107
+ };
108
+
109
+ export type GitHistoryIndex = {
110
+ readonly status: "ready";
111
+ readonly repository: string;
112
+ readonly root: string;
113
+ /** POSIX repository-relative vault path, or the empty string for repository root. */
114
+ readonly vaultPrefix: string;
115
+ readonly head: string;
116
+ readonly scannedCommits: number;
117
+ readonly notes: readonly GitIndexedNote[];
118
+ /** Additive coverage detail for commits whose full co-change set was not retained. */
119
+ readonly limitedCommits?: readonly GitHistoryLimitedCommit[];
120
+ };
121
+
122
+ export type GitHistoryUnavailable = {
123
+ readonly status: "unavailable";
124
+ readonly repository: string;
125
+ readonly root: string;
126
+ readonly vaultPrefix: string;
127
+ readonly reason: string;
128
+ };
129
+
130
+ export type GitHistoryIndexResult = GitHistoryIndex | GitHistoryUnavailable;
131
+
132
+ export type GitHistoryErrorKind =
133
+ | "budget"
134
+ | "confinement"
135
+ | "failed"
136
+ | "malformed"
137
+ | "unavailable";
138
+
139
+ export class GitHistoryError extends Error {
140
+ readonly kind: GitHistoryErrorKind;
141
+
142
+ constructor(kind: GitHistoryErrorKind, message: string, options?: ErrorOptions) {
143
+ super(message, options);
144
+ this.name = "GitHistoryError";
145
+ this.kind = kind;
146
+ }
147
+ }
148
+
149
+ export type GitHistoryNoteCommit = {
150
+ readonly hash: string;
151
+ readonly committedAt: string;
152
+ readonly subject: string;
153
+ readonly cochangedPaths: readonly string[];
154
+ /** Present only when the source commit's complete co-change set was not indexed. */
155
+ readonly cochangeDetailsLimited?: true;
156
+ };
157
+
158
+ export type GitHistoryNoteProvenance = {
159
+ readonly id: string;
160
+ readonly path: string;
161
+ readonly commits: readonly GitHistoryNoteCommit[];
162
+ };
163
+
164
+ export type GitHistoryForNotesOptions = {
165
+ readonly commitsPerNote?: number;
166
+ readonly cochangedPathsPerCommit?: number;
167
+ };
168
+
169
+ export type ValidatedGitHistoryForNotesOptions = Required<GitHistoryForNotesOptions>;
170
+
171
+ export type ValidatedGitHistoryForNotesRequest = {
172
+ readonly noteIds: readonly string[];
173
+ readonly options: ValidatedGitHistoryForNotesOptions;
174
+ };
175
+
176
+ export type GitHistoryForNotesResult =
177
+ | {
178
+ readonly status: "ready";
179
+ readonly head: string;
180
+ readonly notes: readonly GitHistoryNoteProvenance[];
181
+ readonly limitedCommits?: readonly GitHistoryLimitedCommit[];
182
+ }
183
+ | GitHistoryUnavailable;
184
+
185
+ export type SearchGitHistoryOptions = {
186
+ readonly query: string;
187
+ readonly allowedNoteIds?: readonly string[] | ReadonlySet<string>;
188
+ readonly limit?: number;
189
+ readonly commitsPerHit?: number;
190
+ readonly cochangedPathsPerCommit?: number;
191
+ };
192
+
193
+ export type ValidatedSearchGitHistoryOptions = {
194
+ readonly query: string;
195
+ readonly normalizedQuery: string;
196
+ readonly terms: readonly string[];
197
+ readonly allowedNoteIds: ReadonlySet<string> | null;
198
+ readonly limit: number;
199
+ readonly commitsPerHit: number;
200
+ readonly cochangedPathsPerCommit: number;
201
+ };
202
+
203
+ export type GitHistorySearchCommit = GitHistoryNoteCommit & {
204
+ readonly matchedSubject: boolean;
205
+ readonly matchedPaths: readonly string[];
206
+ };
207
+
208
+ export type GitHistorySearchHit = {
209
+ readonly id: string;
210
+ readonly path: string;
211
+ /** Stable score local to the Git lane. */
212
+ readonly score: number;
213
+ readonly commits: readonly GitHistorySearchCommit[];
214
+ };
215
+
216
+ export type GitHistorySearchResult =
217
+ | {
218
+ readonly status: "ready";
219
+ readonly head: string;
220
+ readonly query: string;
221
+ readonly hits: readonly GitHistorySearchHit[];
222
+ readonly limitedCommits?: readonly GitHistoryLimitedCommit[];
223
+ }
224
+ | GitHistoryUnavailable;
225
+
226
+ type MutableBytes = {
227
+ stdout: Buffer[];
228
+ stderr: Buffer[];
229
+ total: number;
230
+ exceeded: boolean;
231
+ };
232
+
233
+ /** Default direct-argv provider. It never invokes a shell. */
234
+ export const runGitCommand: GitCommandProvider = (request) =>
235
+ new Promise((resolveResult) => {
236
+ let settled = false;
237
+ const bytes: MutableBytes = { stdout: [], stderr: [], total: 0, exceeded: false };
238
+ let child;
239
+ try {
240
+ child = spawn("git", [...request.arguments], {
241
+ cwd: request.cwd,
242
+ env: {
243
+ ...process.env,
244
+ GIT_OPTIONAL_LOCKS: "0",
245
+ GIT_PAGER: "cat",
246
+ GIT_TERMINAL_PROMPT: "0",
247
+ LC_ALL: "C",
248
+ },
249
+ shell: false,
250
+ stdio: ["ignore", "pipe", "pipe"],
251
+ });
252
+ } catch (error) {
253
+ const code = errorCode(error);
254
+ resolveResult(code === "ENOENT"
255
+ ? { status: "unavailable", message: "Git is not installed or is not on PATH." }
256
+ : { status: "failed", message: errorMessage(error), reason: "exit" });
257
+ return;
258
+ }
259
+
260
+ const finish = (result: GitCommandResult): void => {
261
+ if (settled) return;
262
+ settled = true;
263
+ clearTimeout(timer);
264
+ resolveResult(result);
265
+ };
266
+ const append = (target: "stdout" | "stderr", chunk: Buffer | string): void => {
267
+ if (settled || bytes.exceeded) return;
268
+ const buffer = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);
269
+ bytes.total += buffer.byteLength;
270
+ if (bytes.total > request.maxOutputBytes) {
271
+ bytes.exceeded = true;
272
+ child.kill("SIGKILL");
273
+ return;
274
+ }
275
+ bytes[target].push(buffer);
276
+ };
277
+ child.stdout.on("data", (chunk: Buffer) => append("stdout", chunk));
278
+ child.stderr.on("data", (chunk: Buffer) => append("stderr", chunk));
279
+ child.once("error", (error: NodeJS.ErrnoException) => {
280
+ finish(error.code === "ENOENT"
281
+ ? { status: "unavailable", message: "Git is not installed or is not on PATH." }
282
+ : { status: "failed", message: error.message, reason: "exit" });
283
+ });
284
+ child.once("close", (exitCode) => {
285
+ const stdout = Buffer.concat(bytes.stdout);
286
+ const stderr = Buffer.concat(bytes.stderr);
287
+ if (bytes.exceeded) {
288
+ finish({
289
+ status: "failed",
290
+ message: `Git output exceeded ${request.maxOutputBytes} bytes.`,
291
+ reason: "output-limit",
292
+ stderr,
293
+ });
294
+ } else if (exitCode === 0) {
295
+ finish({ status: "ok", stdout, stderr });
296
+ } else {
297
+ const failed: GitCommandResult = {
298
+ status: "failed",
299
+ message: `Git exited with code ${exitCode ?? "unknown"}.`,
300
+ reason: "exit",
301
+ stderr,
302
+ ...(exitCode === null ? {} : { exitCode }),
303
+ };
304
+ finish(failed);
305
+ }
306
+ });
307
+ const timer = setTimeout(() => {
308
+ child.kill("SIGKILL");
309
+ finish({
310
+ status: "failed",
311
+ message: `Git exceeded the ${request.timeoutMs}ms timeout.`,
312
+ reason: "timeout",
313
+ });
314
+ }, request.timeoutMs);
315
+ });
316
+
317
+ function errorCode(error: unknown): string | undefined {
318
+ return typeof error === "object" && error !== null && "code" in error
319
+ && typeof error.code === "string"
320
+ ? error.code
321
+ : undefined;
322
+ }
323
+
324
+ function errorMessage(error: unknown): string {
325
+ return error instanceof Error ? error.message : String(error);
326
+ }
327
+
328
+ function checkedInteger(
329
+ value: number | undefined,
330
+ fallback: number,
331
+ minimum: number,
332
+ maximum: number,
333
+ label: string,
334
+ ): number {
335
+ const selected = value ?? fallback;
336
+ if (!Number.isSafeInteger(selected) || selected < minimum || selected > maximum) {
337
+ throw new GitHistoryError(
338
+ "budget",
339
+ `${label} must be an integer from ${minimum} through ${maximum}.`,
340
+ );
341
+ }
342
+ return selected;
343
+ }
344
+
345
+ function inside(parent: string, candidate: string): boolean {
346
+ const path = relative(parent, candidate);
347
+ return path === "" || (!path.startsWith(`..${sep}`) && path !== ".." && !isAbsolute(path));
348
+ }
349
+
350
+ function posixRelative(parent: string, candidate: string): string {
351
+ return relative(parent, candidate).split(sep).join("/");
352
+ }
353
+
354
+ function hasControlCharacter(value: string, allowTab: boolean): boolean {
355
+ return [...value].some((character) => {
356
+ const code = character.codePointAt(0) ?? 0;
357
+ return (code < 32 && !(allowTab && code === 9)) || code === 127;
358
+ });
359
+ }
360
+
361
+ function checkedRepositoryPath(value: string, label: string): string {
362
+ if (value === "" || hasControlCharacter(value, false) || value.includes("\\")) {
363
+ throw new GitHistoryError("malformed", `${label} is not a safe repository-relative path.`);
364
+ }
365
+ if (value.startsWith("/") || value.endsWith("/") || value.split("/").some(
366
+ (segment) => segment === "" || segment === "." || segment === "..",
367
+ )) {
368
+ throw new GitHistoryError("malformed", `${label} is not a normalized repository-relative path.`);
369
+ }
370
+ return value;
371
+ }
372
+
373
+ function checkedNoteIdentity(note: Pick<Note, "id" | "path">): void {
374
+ const path = checkedRepositoryPath(note.path, `Note path ${JSON.stringify(note.path)}`);
375
+ const id = checkedRepositoryPath(note.id, `Note ID ${JSON.stringify(note.id)}`);
376
+ if (!path.endsWith(".md") || path.slice(0, -3) !== id) {
377
+ throw new GitHistoryError(
378
+ "malformed",
379
+ `Note ${JSON.stringify(note.id)} must use its extensionless Markdown path as its ID.`,
380
+ );
381
+ }
382
+ }
383
+
384
+ function decodeOutput(value: string | Uint8Array | undefined, label: string): string {
385
+ if (value === undefined) return "";
386
+ try {
387
+ return typeof value === "string"
388
+ ? value
389
+ : new TextDecoder("utf-8", { fatal: true }).decode(value);
390
+ } catch (error) {
391
+ throw new GitHistoryError("malformed", `${label} was not valid UTF-8.`, { cause: error });
392
+ }
393
+ }
394
+
395
+ type CommandBudget = {
396
+ readonly startedAt: number;
397
+ readonly timeoutMs: number;
398
+ readonly outputLimit: number;
399
+ outputBytes: number;
400
+ };
401
+
402
+ async function checkedCommand(
403
+ provider: GitCommandProvider,
404
+ cwd: string,
405
+ arguments_: readonly string[],
406
+ budget: CommandBudget,
407
+ required: boolean,
408
+ label: string,
409
+ ): Promise<{ readonly status: "ok"; readonly stdout: string } | GitHistoryUnavailable> {
410
+ const timeoutMs = budget.timeoutMs - (Date.now() - budget.startedAt);
411
+ const maxOutputBytes = budget.outputLimit - budget.outputBytes;
412
+ if (timeoutMs <= 0) throw new GitHistoryError("budget", "Git history indexing exceeded its timeout.");
413
+ if (maxOutputBytes <= 0) {
414
+ throw new GitHistoryError("budget", "Git history indexing exceeded its output budget.");
415
+ }
416
+
417
+ let result: GitCommandResult;
418
+ try {
419
+ result = await provider({ cwd, arguments: arguments_, timeoutMs, maxOutputBytes });
420
+ } catch (error) {
421
+ if (errorCode(error) === "ENOENT") {
422
+ result = { status: "unavailable", message: "Git is not installed or is not on PATH." };
423
+ } else {
424
+ throw new GitHistoryError("failed", `${label} failed: ${errorMessage(error)}`, { cause: error });
425
+ }
426
+ }
427
+ if (result.status === "unavailable") {
428
+ if (required) throw new GitHistoryError("unavailable", result.message);
429
+ return {
430
+ status: "unavailable",
431
+ repository: cwd,
432
+ root: cwd,
433
+ vaultPrefix: "",
434
+ reason: result.message,
435
+ };
436
+ }
437
+ if (result.status === "failed") {
438
+ const stderr = decodeOutput(result.stderr, "Git stderr").trim();
439
+ const detail = stderr === "" ? result.message : `${result.message} ${stderr}`;
440
+ throw new GitHistoryError(
441
+ result.reason === "timeout" || result.reason === "output-limit" ? "budget" : "failed",
442
+ `${label} failed: ${detail}`,
443
+ );
444
+ }
445
+ const stdoutBytes = typeof result.stdout === "string"
446
+ ? Buffer.byteLength(result.stdout)
447
+ : result.stdout.byteLength;
448
+ const stderrBytes = typeof result.stderr === "string"
449
+ ? Buffer.byteLength(result.stderr)
450
+ : result.stderr?.byteLength ?? 0;
451
+ budget.outputBytes += stdoutBytes + stderrBytes;
452
+ if (budget.outputBytes > budget.outputLimit) {
453
+ throw new GitHistoryError("budget", "Git history indexing exceeded its output budget.");
454
+ }
455
+ return { status: "ok", stdout: decodeOutput(result.stdout, label) };
456
+ }
457
+
458
+ function singleLine(value: string, label: string): string {
459
+ const normalized = value.replace(/\r?\n$/u, "");
460
+ if (normalized === "" || /[\r\n\0]/u.test(normalized)) {
461
+ throw new GitHistoryError("malformed", `${label} was not one non-empty line.`);
462
+ }
463
+ return normalized;
464
+ }
465
+
466
+ type ParsedCommit = {
467
+ readonly hash: string;
468
+ readonly timestamp: number;
469
+ readonly subject: string;
470
+ readonly changedPaths: readonly string[];
471
+ };
472
+
473
+ type ParsedCommitLimit = Omit<ParsedCommit, "changedPaths"> & {
474
+ readonly reason: "changed-path-limit";
475
+ readonly pathLimit: number;
476
+ readonly observedPathRecords: number;
477
+ };
478
+
479
+ type ParsedCommitRecord =
480
+ | { readonly status: "complete"; readonly commit: ParsedCommit }
481
+ | { readonly status: "limited"; readonly limit: ParsedCommitLimit };
482
+
483
+ type ChangedPathPolicy =
484
+ | { readonly kind: "reject" }
485
+ | { readonly kind: "limit" }
486
+ | { readonly kind: "select"; readonly paths: ReadonlySet<string> };
487
+
488
+ type PathObservationBudget = { observed: number };
489
+
490
+ function commitTime(timestamp: string): number {
491
+ if (!/^(?:0|[1-9]\d*)$/u.test(timestamp)) {
492
+ throw new GitHistoryError("malformed", "Git returned a malformed commit timestamp.");
493
+ }
494
+ const value = Number(timestamp);
495
+ if (!Number.isSafeInteger(value) || value < 0) {
496
+ throw new GitHistoryError("malformed", "Git returned an out-of-range commit timestamp.");
497
+ }
498
+ return value;
499
+ }
500
+
501
+ function normalizedSubject(value: string): string {
502
+ if (value.includes("\uFFFD") || hasControlCharacter(value, true)) {
503
+ throw new GitHistoryError("malformed", "Git returned a malformed commit subject.");
504
+ }
505
+ return [...value.normalize("NFC").trim()].slice(0, 1_000).join("");
506
+ }
507
+
508
+ function parseGitHistoryRecords(
509
+ output: string,
510
+ expectedMarker: string,
511
+ changedPathPolicy: ChangedPathPolicy,
512
+ pathBudget: PathObservationBudget,
513
+ ): readonly ParsedCommitRecord[] {
514
+ if (output === "") return Object.freeze([]);
515
+ if (expectedMarker === "" || expectedMarker.includes("\0")) {
516
+ throw new GitHistoryError("malformed", "Git history record marker is invalid.");
517
+ }
518
+ if (output.includes("\uFFFD")) {
519
+ throw new GitHistoryError("malformed", "Git history output was not valid UTF-8.");
520
+ }
521
+ const tokens = output.split("\0");
522
+ const records: ParsedCommitRecord[] = [];
523
+ let index = 0;
524
+ while (index < tokens.length) {
525
+ let marker = tokens[index] ?? "";
526
+ marker = marker.replace(/^\r?\n/u, "");
527
+ if (marker === "") {
528
+ index += 1;
529
+ continue;
530
+ }
531
+ if (marker !== expectedMarker) {
532
+ throw new GitHistoryError("malformed", "Git history output contained an unexpected record marker.");
533
+ }
534
+ const hash = tokens[index + 1];
535
+ const timestamp = tokens[index + 2];
536
+ const subject = tokens[index + 3];
537
+ if (hash === undefined || timestamp === undefined || subject === undefined || !objectIdPattern.test(hash)) {
538
+ throw new GitHistoryError("malformed", "Git history output contained an incomplete commit record.");
539
+ }
540
+ index += 4;
541
+ const parsedTimestamp = commitTime(timestamp);
542
+ const parsedSubject = normalizedSubject(subject);
543
+ const changedPaths: string[] = [];
544
+ const seenPaths = new Set<string>();
545
+ let firstPath = true;
546
+ let limited = false;
547
+ let observedPathRecords = 0;
548
+ while (index < tokens.length) {
549
+ const token = tokens[index] ?? "";
550
+ const possibleMarker = token.replace(/^\r?\n/u, "");
551
+ if (possibleMarker === expectedMarker) break;
552
+ index += 1;
553
+ if (token === "" || token === "\n" || token === "\r\n") continue;
554
+ // Git inserts one formatting newline before the first -z name-only path.
555
+ const rawPath = firstPath ? token.replace(/^\r?\n/u, "") : token;
556
+ if (rawPath === "") continue;
557
+ firstPath = false;
558
+ const path = checkedRepositoryPath(rawPath, "Git changed path");
559
+ observedPathRecords += 1;
560
+ pathBudget.observed += 1;
561
+ if (pathBudget.observed > MAX_GIT_PATH_OBSERVATIONS) {
562
+ throw new GitHistoryError(
563
+ "budget",
564
+ `Git history exceeded the ${MAX_GIT_PATH_OBSERVATIONS} changed-path observation limit.`,
565
+ );
566
+ }
567
+ if (changedPathPolicy.kind === "select") {
568
+ if (!changedPathPolicy.paths.has(path) || seenPaths.has(path)) continue;
569
+ seenPaths.add(path);
570
+ changedPaths.push(path);
571
+ if (changedPaths.length > MAX_GIT_HISTORY_NOTES) {
572
+ throw new GitHistoryError(
573
+ "budget",
574
+ `Git history selected more than ${MAX_GIT_HISTORY_NOTES} live-note paths in one commit.`,
575
+ );
576
+ }
577
+ continue;
578
+ }
579
+ if (!limited && seenPaths.has(path)) continue;
580
+ if (limited) continue;
581
+ seenPaths.add(path);
582
+ changedPaths.push(path);
583
+ if (changedPaths.length > MAX_GIT_PATHS_PER_COMMIT) {
584
+ if (changedPathPolicy.kind === "reject") {
585
+ throw new GitHistoryError(
586
+ "budget",
587
+ `A Git commit exceeded the ${MAX_GIT_PATHS_PER_COMMIT} changed-path limit.`,
588
+ );
589
+ }
590
+ limited = true;
591
+ changedPaths.length = 0;
592
+ seenPaths.clear();
593
+ }
594
+ }
595
+ records.push(limited
596
+ ? {
597
+ status: "limited",
598
+ limit: Object.freeze({
599
+ hash,
600
+ timestamp: parsedTimestamp,
601
+ subject: parsedSubject,
602
+ reason: "changed-path-limit",
603
+ pathLimit: MAX_GIT_PATHS_PER_COMMIT,
604
+ observedPathRecords,
605
+ }),
606
+ }
607
+ : {
608
+ status: "complete",
609
+ commit: Object.freeze({
610
+ hash,
611
+ timestamp: parsedTimestamp,
612
+ subject: parsedSubject,
613
+ changedPaths: Object.freeze(changedPaths),
614
+ }),
615
+ });
616
+ if (records.length > MAX_GIT_HISTORY_COMMITS) {
617
+ throw new GitHistoryError(
618
+ "budget",
619
+ `Git history exceeded the ${MAX_GIT_HISTORY_COMMITS} commit limit.`,
620
+ );
621
+ }
622
+ }
623
+ return Object.freeze(records);
624
+ }
625
+
626
+ /** Parse the NUL-delimited output emitted by historyArguments. */
627
+ export function parseGitHistoryOutput(
628
+ output: string,
629
+ expectedMarker = commitMarker,
630
+ ): readonly ParsedCommit[] {
631
+ return Object.freeze(parseGitHistoryRecords(
632
+ output,
633
+ expectedMarker,
634
+ { kind: "reject" },
635
+ { observed: 0 },
636
+ ).map((record) => {
637
+ if (record.status === "limited") {
638
+ throw new GitHistoryError(
639
+ "budget",
640
+ `A Git commit exceeded the ${MAX_GIT_PATHS_PER_COMMIT} changed-path limit.`,
641
+ );
642
+ }
643
+ return record.commit;
644
+ }));
645
+ }
646
+
647
+ function parseSelectedGitHistoryOutput(
648
+ output: string,
649
+ expectedMarker: string,
650
+ paths: ReadonlySet<string>,
651
+ pathBudget: PathObservationBudget,
652
+ ): readonly ParsedCommit[] {
653
+ return Object.freeze(parseGitHistoryRecords(
654
+ output,
655
+ expectedMarker,
656
+ { kind: "select", paths },
657
+ pathBudget,
658
+ ).map((record) => {
659
+ if (record.status === "limited") {
660
+ throw new GitHistoryError("malformed", "Git live-note history was unexpectedly limited.");
661
+ }
662
+ return record.commit;
663
+ }));
664
+ }
665
+
666
+ function historyArguments(
667
+ hashes: readonly string[],
668
+ marker: string,
669
+ ): readonly string[] {
670
+ return [
671
+ "show",
672
+ "--no-color",
673
+ "--no-ext-diff",
674
+ "--no-renames",
675
+ "--diff-merges=first-parent",
676
+ `--format=${marker}%x00%H%x00%ct%x00%s%x00`,
677
+ "--name-only",
678
+ "-z",
679
+ ...hashes,
680
+ "--",
681
+ ];
682
+ }
683
+
684
+ function logArguments(
685
+ maxCommits: number,
686
+ vaultPrefix: string,
687
+ marker: string,
688
+ revision: string,
689
+ ): readonly string[] {
690
+ return [
691
+ "--literal-pathspecs",
692
+ "log",
693
+ "--no-color",
694
+ "--no-decorate",
695
+ "--no-renames",
696
+ "--diff-merges=first-parent",
697
+ "--first-parent",
698
+ `--max-count=${maxCommits}`,
699
+ `--format=${marker}%x00%H%x00%ct%x00%s%x00`,
700
+ "--name-only",
701
+ "-z",
702
+ revision,
703
+ "--",
704
+ vaultPrefix === "" ? "." : vaultPrefix,
705
+ ];
706
+ }
707
+
708
+ function freezeCommit(commit: ParsedCommit, changedPathDetailsLimited: boolean): GitHistoryCommit {
709
+ return Object.freeze({
710
+ hash: commit.hash,
711
+ committedAt: new Date(commit.timestamp * 1_000).toISOString(),
712
+ subject: commit.subject,
713
+ changedPaths: commit.changedPaths,
714
+ ...(changedPathDetailsLimited ? { changedPathDetailsLimited: true as const } : {}),
715
+ });
716
+ }
717
+
718
+ function recordHash(record: ParsedCommitRecord): string {
719
+ return record.status === "complete" ? record.commit.hash : record.limit.hash;
720
+ }
721
+
722
+ async function confinedNotes(
723
+ notes: readonly Pick<Note, "id" | "path">[],
724
+ repository: string,
725
+ root: string,
726
+ vaultPrefix: string,
727
+ ): Promise<readonly { readonly id: string; readonly path: string; readonly repositoryPath: string }[]> {
728
+ if (notes.length > MAX_GIT_HISTORY_NOTES) {
729
+ throw new GitHistoryError(
730
+ "budget",
731
+ `Git history accepts at most ${MAX_GIT_HISTORY_NOTES} live notes.`,
732
+ );
733
+ }
734
+ const seenIds = new Set<string>();
735
+ const seenPaths = new Set<string>();
736
+ const confined = [];
737
+ for (const note of notes) {
738
+ checkedNoteIdentity(note);
739
+ if (seenIds.has(note.id) || seenPaths.has(note.path)) {
740
+ throw new GitHistoryError("malformed", `Duplicate live note identity ${JSON.stringify(note.id)}.`);
741
+ }
742
+ seenIds.add(note.id);
743
+ seenPaths.add(note.path);
744
+ let resolvedNote: string;
745
+ try {
746
+ resolvedNote = await realpath(resolve(root, note.path));
747
+ } catch (error) {
748
+ throw new GitHistoryError(
749
+ "confinement",
750
+ `Live note ${JSON.stringify(note.path)} could not be resolved.`,
751
+ { cause: error },
752
+ );
753
+ }
754
+ if (!inside(root, resolvedNote) || !inside(repository, resolvedNote)) {
755
+ throw new GitHistoryError(
756
+ "confinement",
757
+ `Live note ${JSON.stringify(note.path)} resolves outside the vault or repository.`,
758
+ );
759
+ }
760
+ if (!(await stat(resolvedNote)).isFile()) {
761
+ throw new GitHistoryError("confinement", `Live note ${JSON.stringify(note.path)} is not a file.`);
762
+ }
763
+ confined.push(Object.freeze({
764
+ id: note.id,
765
+ path: note.path,
766
+ repositoryPath: vaultPrefix === "" ? note.path : `${vaultPrefix}/${note.path}`,
767
+ }));
768
+ }
769
+ return Object.freeze(confined.toSorted((left, right) => left.id.localeCompare(right.id)));
770
+ }
771
+
772
+ /** Build a bounded, read-only projection of commits that touched live vault notes. */
773
+ export async function indexGitHistory(
774
+ options: IndexGitHistoryOptions,
775
+ dependencies: GitHistoryDependencies = {},
776
+ ): Promise<GitHistoryIndexResult> {
777
+ const maxCommits = checkedInteger(
778
+ options.maxCommits,
779
+ DEFAULT_COMMITS,
780
+ 1,
781
+ MAX_GIT_HISTORY_COMMITS,
782
+ "Git commit limit",
783
+ );
784
+ const timeoutMs = checkedInteger(
785
+ options.timeoutMs,
786
+ DEFAULT_TIMEOUT_MS,
787
+ 1,
788
+ MAX_GIT_HISTORY_TIMEOUT_MS,
789
+ "Git timeout",
790
+ );
791
+ const outputLimit = checkedInteger(
792
+ options.maxOutputBytes,
793
+ DEFAULT_OUTPUT_BYTES,
794
+ 1_024,
795
+ MAX_GIT_HISTORY_OUTPUT_BYTES,
796
+ "Git output limit",
797
+ );
798
+ let repository: string;
799
+ let root: string;
800
+ try {
801
+ repository = await realpath(resolve(options.repository));
802
+ root = await realpath(resolve(options.root));
803
+ } catch (error) {
804
+ throw new GitHistoryError("confinement", "Repository and vault roots must resolve.", { cause: error });
805
+ }
806
+ if (!(await stat(repository)).isDirectory() || !(await stat(root)).isDirectory()) {
807
+ throw new GitHistoryError("confinement", "Repository and vault roots must be directories.");
808
+ }
809
+ if (!inside(repository, root)) {
810
+ throw new GitHistoryError("confinement", "Knowledge-base root must resolve inside the repository.");
811
+ }
812
+ const vaultPrefix = posixRelative(repository, root);
813
+ const provider = dependencies.runGit ?? runGitCommand;
814
+ const budget: CommandBudget = {
815
+ startedAt: Date.now(),
816
+ timeoutMs,
817
+ outputLimit,
818
+ outputBytes: 0,
819
+ };
820
+ const topLevel = await checkedCommand(
821
+ provider,
822
+ repository,
823
+ ["rev-parse", "--show-toplevel"],
824
+ budget,
825
+ options.required ?? false,
826
+ "Git repository discovery",
827
+ );
828
+ if (topLevel.status === "unavailable") {
829
+ return Object.freeze({ ...topLevel, repository, root, vaultPrefix });
830
+ }
831
+ let actualTopLevel: string;
832
+ try {
833
+ actualTopLevel = await realpath(singleLine(topLevel.stdout, "Git repository root"));
834
+ } catch (error) {
835
+ if (error instanceof GitHistoryError) throw error;
836
+ throw new GitHistoryError("malformed", "Git returned an unresolved repository root.", { cause: error });
837
+ }
838
+ if (actualTopLevel !== repository) {
839
+ throw new GitHistoryError(
840
+ "confinement",
841
+ `Git resolved ${JSON.stringify(actualTopLevel)} instead of the requested repository.`,
842
+ );
843
+ }
844
+ const headResult = await checkedCommand(
845
+ provider,
846
+ repository,
847
+ ["rev-parse", "--verify", "HEAD"],
848
+ budget,
849
+ true,
850
+ "Git HEAD discovery",
851
+ );
852
+ if (headResult.status === "unavailable") {
853
+ throw new GitHistoryError("unavailable", headResult.reason);
854
+ }
855
+ const head = singleLine(headResult.stdout, "Git HEAD");
856
+ if (!objectIdPattern.test(head)) {
857
+ throw new GitHistoryError("malformed", "Git returned a malformed HEAD object ID.");
858
+ }
859
+ const liveNotes = await confinedNotes(options.notes, repository, root, vaultPrefix);
860
+ if (liveNotes.length === 0) {
861
+ return Object.freeze({
862
+ status: "ready",
863
+ repository,
864
+ root,
865
+ vaultPrefix,
866
+ head,
867
+ scannedCommits: 0,
868
+ notes: Object.freeze([]),
869
+ });
870
+ }
871
+ // A per-index nonce prevents a repository path from being confused with a
872
+ // framing record while retaining one bounded bulk Git invocation.
873
+ const marker = `${commitMarker}-${randomUUID()}`;
874
+ const pathBudget: PathObservationBudget = { observed: 0 };
875
+ const metadataResult = await checkedCommand(
876
+ provider,
877
+ repository,
878
+ logArguments(maxCommits, vaultPrefix, marker, head),
879
+ budget,
880
+ true,
881
+ "Git vault history",
882
+ );
883
+ if (metadataResult.status === "unavailable") {
884
+ throw new GitHistoryError("unavailable", metadataResult.reason);
885
+ }
886
+ const noteIdByRepositoryPath = new Map<string, string>(
887
+ liveNotes.map(({ id, repositoryPath }) => [repositoryPath, id] as const),
888
+ );
889
+ const vaultCommits = parseSelectedGitHistoryOutput(
890
+ metadataResult.stdout,
891
+ marker,
892
+ new Set(noteIdByRepositoryPath.keys()),
893
+ pathBudget,
894
+ );
895
+ const detailsResult = vaultCommits.length === 0
896
+ ? null
897
+ : await checkedCommand(
898
+ provider,
899
+ repository,
900
+ historyArguments(vaultCommits.map(({ hash }) => hash), marker),
901
+ budget,
902
+ true,
903
+ "Git commit path history",
904
+ );
905
+ if (detailsResult?.status === "unavailable") {
906
+ throw new GitHistoryError("unavailable", detailsResult.reason);
907
+ }
908
+ const detailRecords = detailsResult === null
909
+ ? []
910
+ : parseGitHistoryRecords(
911
+ detailsResult.stdout,
912
+ marker,
913
+ { kind: "limit" },
914
+ pathBudget,
915
+ );
916
+ if (
917
+ detailRecords.length !== vaultCommits.length
918
+ || detailRecords.some((record, index) => recordHash(record) !== vaultCommits[index]?.hash)
919
+ ) {
920
+ throw new GitHistoryError("malformed", "Git returned inconsistent vault and changed-path histories.");
921
+ }
922
+ const commits: GitHistoryCommit[] = [];
923
+ const limitedCommits: GitHistoryLimitedCommit[] = [];
924
+ detailRecords.forEach((detailRecord, index) => {
925
+ const vaultCommit = vaultCommits[index]!;
926
+ if (detailRecord.status === "complete") {
927
+ commits.push(freezeCommit(detailRecord.commit, false));
928
+ return;
929
+ }
930
+ const affectedNoteIds = Object.freeze(vaultCommit.changedPaths.map((path) => {
931
+ const noteId = noteIdByRepositoryPath.get(path);
932
+ if (noteId === undefined) {
933
+ throw new GitHistoryError("malformed", "Git selected a path outside the live-note set.");
934
+ }
935
+ return noteId;
936
+ }));
937
+ commits.push(freezeCommit({
938
+ hash: detailRecord.limit.hash,
939
+ timestamp: detailRecord.limit.timestamp,
940
+ subject: detailRecord.limit.subject,
941
+ changedPaths: vaultCommit.changedPaths,
942
+ }, true));
943
+ limitedCommits.push(Object.freeze({
944
+ hash: detailRecord.limit.hash,
945
+ committedAt: new Date(detailRecord.limit.timestamp * 1_000).toISOString(),
946
+ subject: detailRecord.limit.subject,
947
+ reason: detailRecord.limit.reason,
948
+ pathLimit: detailRecord.limit.pathLimit,
949
+ observedPathRecords: detailRecord.limit.observedPathRecords,
950
+ affectedNoteIds,
951
+ }));
952
+ });
953
+ const commitsByPath = new Map<string, GitHistoryCommit[]>();
954
+ for (const commit of commits) {
955
+ for (const path of commit.changedPaths) {
956
+ const existing = commitsByPath.get(path);
957
+ if (existing === undefined) commitsByPath.set(path, [commit]);
958
+ else existing.push(commit);
959
+ }
960
+ }
961
+ const indexedNotes = liveNotes.map((note): GitIndexedNote => Object.freeze({
962
+ ...note,
963
+ commits: Object.freeze([...(commitsByPath.get(note.repositoryPath) ?? [])]),
964
+ }));
965
+ return Object.freeze({
966
+ status: "ready",
967
+ repository,
968
+ root,
969
+ vaultPrefix,
970
+ head,
971
+ scannedCommits: detailRecords.length,
972
+ notes: Object.freeze(indexedNotes),
973
+ ...(limitedCommits.length === 0
974
+ ? {}
975
+ : { limitedCommits: Object.freeze(limitedCommits) }),
976
+ });
977
+ }
978
+
979
+ function limitedCommitsForNotes(
980
+ limitedCommits: readonly GitHistoryLimitedCommit[] | undefined,
981
+ noteIds: ReadonlySet<string>,
982
+ commitHashes: ReadonlySet<string> | null,
983
+ ): readonly GitHistoryLimitedCommit[] {
984
+ if (limitedCommits === undefined || limitedCommits.length === 0) return Object.freeze([]);
985
+ return Object.freeze(limitedCommits.flatMap((commit) => {
986
+ if (commitHashes !== null && !commitHashes.has(commit.hash)) return [];
987
+ const affectedNoteIds = Object.freeze(commit.affectedNoteIds.filter((id) => noteIds.has(id)));
988
+ return affectedNoteIds.length === 0
989
+ ? []
990
+ : [Object.freeze({ ...commit, affectedNoteIds })];
991
+ }));
992
+ }
993
+
994
+ /** Validate and normalize bounded per-note provenance options without opening Git. */
995
+ export function validateGitHistoryForNotesOptions(
996
+ options: GitHistoryForNotesOptions = {},
997
+ ): ValidatedGitHistoryForNotesOptions {
998
+ if (typeof options !== "object" || options === null || Array.isArray(options)) {
999
+ throw new GitHistoryError("malformed", "Git history options must be an object.");
1000
+ }
1001
+ return Object.freeze({
1002
+ commitsPerNote: checkedInteger(
1003
+ options.commitsPerNote,
1004
+ DEFAULT_COMMITS_PER_NOTE,
1005
+ 1,
1006
+ MAX_COMMITS_PER_NOTE,
1007
+ "Per-note commit limit",
1008
+ ),
1009
+ cochangedPathsPerCommit: checkedInteger(
1010
+ options.cochangedPathsPerCommit,
1011
+ DEFAULT_COCHANGED_PATHS,
1012
+ 0,
1013
+ MAX_COCHANGED_PATHS,
1014
+ "Cochanged-path limit",
1015
+ ),
1016
+ });
1017
+ }
1018
+
1019
+ function isIterableObject(value: unknown): value is Iterable<unknown> {
1020
+ return typeof value === "object"
1021
+ && value !== null
1022
+ && Symbol.iterator in value
1023
+ && typeof value[Symbol.iterator] === "function";
1024
+ }
1025
+
1026
+ function checkedNoteIds(
1027
+ value: unknown,
1028
+ label: string,
1029
+ countMessage: string,
1030
+ ): readonly string[] {
1031
+ if (!Array.isArray(value) && !isIterableObject(value)) {
1032
+ throw new GitHistoryError("malformed", `${label}s must be provided as an array or set.`);
1033
+ }
1034
+ const noteIds: string[] = [];
1035
+ let noteIdBytes = 0;
1036
+ for (const noteId of value) {
1037
+ if (noteIds.length >= MAX_GIT_HISTORY_NOTES) {
1038
+ throw new GitHistoryError("budget", countMessage);
1039
+ }
1040
+ if (typeof noteId !== "string") {
1041
+ throw new GitHistoryError(
1042
+ "malformed",
1043
+ `${label} ${noteIds.length + 1} must be a string.`,
1044
+ );
1045
+ }
1046
+ const noteIdByteLength = Buffer.byteLength(noteId, "utf8");
1047
+ if (noteIdByteLength > MAX_GIT_NOTE_ID_UTF8_BYTES) {
1048
+ throw new GitHistoryError(
1049
+ "budget",
1050
+ `${label} ${noteIds.length + 1} must be at most `
1051
+ + `${MAX_GIT_NOTE_ID_UTF8_BYTES.toLocaleString("en-US")} UTF-8 bytes.`,
1052
+ );
1053
+ }
1054
+ noteIdBytes += noteIdByteLength;
1055
+ if (noteIdBytes > MAX_GIT_NOTE_IDS_UTF8_BYTES) {
1056
+ throw new GitHistoryError(
1057
+ "budget",
1058
+ `${label}s must total at most `
1059
+ + `${MAX_GIT_NOTE_IDS_UTF8_BYTES.toLocaleString("en-US")} UTF-8 bytes.`,
1060
+ );
1061
+ }
1062
+ noteIds.push(noteId);
1063
+ }
1064
+ return Object.freeze(noteIds);
1065
+ }
1066
+
1067
+ /** Validate a direct note-provenance request before opening or indexing Git. */
1068
+ export function validateGitHistoryForNotesRequest(
1069
+ noteIds: readonly string[],
1070
+ options: GitHistoryForNotesOptions = {},
1071
+ ): ValidatedGitHistoryForNotesRequest {
1072
+ return Object.freeze({
1073
+ noteIds: checkedNoteIds(
1074
+ noteIds,
1075
+ "Git history note ID",
1076
+ `At most ${MAX_GIT_HISTORY_NOTES} note IDs may be requested.`,
1077
+ ),
1078
+ options: validateGitHistoryForNotesOptions(options),
1079
+ });
1080
+ }
1081
+
1082
+ function noteCommit(
1083
+ commit: GitHistoryCommit,
1084
+ notePath: string,
1085
+ cochangedPathsPerCommit: number,
1086
+ ): GitHistoryNoteCommit {
1087
+ return Object.freeze({
1088
+ hash: commit.hash,
1089
+ committedAt: commit.committedAt,
1090
+ subject: commit.subject,
1091
+ cochangedPaths: Object.freeze(commit.changedPaths
1092
+ .filter((path) => path !== notePath)
1093
+ .slice(0, cochangedPathsPerCommit)),
1094
+ ...(commit.changedPathDetailsLimited === true
1095
+ ? { cochangeDetailsLimited: true as const }
1096
+ : {}),
1097
+ });
1098
+ }
1099
+
1100
+ /** Return bounded provenance for final note IDs without exposing the whole index. */
1101
+ export function gitHistoryForNotes(
1102
+ index: GitHistoryIndexResult,
1103
+ noteIds: readonly string[],
1104
+ options: GitHistoryForNotesOptions = {},
1105
+ ): GitHistoryForNotesResult {
1106
+ const request = validateGitHistoryForNotesRequest(noteIds, options);
1107
+ if (index.status === "unavailable") return index;
1108
+ const allowed = new Set(request.noteIds);
1109
+ const notes = index.notes
1110
+ .filter((note) => allowed.has(note.id))
1111
+ .map((note): GitHistoryNoteProvenance => Object.freeze({
1112
+ id: note.id,
1113
+ path: note.path,
1114
+ commits: Object.freeze(note.commits.slice(0, request.options.commitsPerNote).map((commit) =>
1115
+ noteCommit(commit, note.repositoryPath, request.options.cochangedPathsPerCommit))),
1116
+ }));
1117
+ const returnedNoteIds = new Set(notes.map(({ id }) => id));
1118
+ const returnedCommitHashes = new Set(notes.flatMap(({ commits }) =>
1119
+ commits.map(({ hash }) => hash)));
1120
+ const limitedCommits = limitedCommitsForNotes(
1121
+ index.limitedCommits,
1122
+ returnedNoteIds,
1123
+ returnedCommitHashes,
1124
+ );
1125
+ return Object.freeze({
1126
+ status: "ready",
1127
+ head: index.head,
1128
+ notes: Object.freeze(notes),
1129
+ ...(limitedCommits.length === 0 ? {} : { limitedCommits }),
1130
+ });
1131
+ }
1132
+
1133
+ function normalizedSearch(value: string): string {
1134
+ return value.normalize("NFC").toLocaleLowerCase("en-US");
1135
+ }
1136
+
1137
+ function queryTerms(query: unknown): { readonly normalized: string; readonly terms: readonly string[] } {
1138
+ if (typeof query !== "string") {
1139
+ throw new GitHistoryError(
1140
+ "malformed",
1141
+ `Git history query must be one to ${MAX_QUERY_LENGTH} characters on one line.`,
1142
+ );
1143
+ }
1144
+ const trimmed = query.trim();
1145
+ if (trimmed === "" || trimmed.length > MAX_QUERY_LENGTH || /[\0\r\n]/u.test(trimmed)) {
1146
+ throw new GitHistoryError(
1147
+ "malformed",
1148
+ `Git history query must be one to ${MAX_QUERY_LENGTH} characters on one line.`,
1149
+ );
1150
+ }
1151
+ const normalized = normalizedSearch(trimmed);
1152
+ const terms = [...new Set(normalized.split(/[^\p{L}\p{N}_./-]+/u).filter(Boolean))].slice(0, 20);
1153
+ return { normalized, terms: Object.freeze(terms) };
1154
+ }
1155
+
1156
+ /** Validate and normalize a history-search request before opening or indexing Git. */
1157
+ export function validateSearchGitHistoryOptions(
1158
+ options: SearchGitHistoryOptions,
1159
+ ): ValidatedSearchGitHistoryOptions {
1160
+ if (typeof options !== "object" || options === null || Array.isArray(options)) {
1161
+ throw new GitHistoryError("malformed", "Git history search options must be an object.");
1162
+ }
1163
+ const limit = checkedInteger(
1164
+ options.limit,
1165
+ DEFAULT_SEARCH_LIMIT,
1166
+ 1,
1167
+ MAX_SEARCH_LIMIT,
1168
+ "Git search limit",
1169
+ );
1170
+ const provenance = validateGitHistoryForNotesOptions({
1171
+ ...(options.commitsPerHit === undefined ? {} : { commitsPerNote: options.commitsPerHit }),
1172
+ ...(options.cochangedPathsPerCommit === undefined
1173
+ ? {}
1174
+ : { cochangedPathsPerCommit: options.cochangedPathsPerCommit }),
1175
+ });
1176
+ const { normalized, terms } = queryTerms(options.query);
1177
+ const checkedAllowed = options.allowedNoteIds === undefined
1178
+ ? null
1179
+ : checkedNoteIds(
1180
+ options.allowedNoteIds,
1181
+ "Git search allowed note ID",
1182
+ `Git search accepts at most ${MAX_GIT_HISTORY_NOTES} allowed note IDs.`,
1183
+ );
1184
+ return Object.freeze({
1185
+ query: options.query.trim(),
1186
+ normalizedQuery: normalized,
1187
+ terms,
1188
+ allowedNoteIds: checkedAllowed === null ? null : new Set(checkedAllowed),
1189
+ limit,
1190
+ commitsPerHit: provenance.commitsPerNote,
1191
+ cochangedPathsPerCommit: provenance.cochangedPathsPerCommit,
1192
+ });
1193
+ }
1194
+
1195
+ function matchStrength(value: string, normalizedQuery: string, terms: readonly string[]): number {
1196
+ const normalized = normalizedSearch(value);
1197
+ if (normalized.includes(normalizedQuery)) return 2 + normalizedQuery.length / 1_000;
1198
+ if (terms.length === 0) return 0;
1199
+ const matched = terms.filter((term) => normalized.includes(term)).length;
1200
+ return matched / terms.length;
1201
+ }
1202
+
1203
+ type ScoredCommit = {
1204
+ readonly score: number;
1205
+ readonly commit: GitHistoryCommit;
1206
+ readonly matchedSubject: boolean;
1207
+ readonly matchedPaths: readonly string[];
1208
+ };
1209
+
1210
+ function scoredCommits(
1211
+ note: GitIndexedNote,
1212
+ normalizedQuery: string,
1213
+ terms: readonly string[],
1214
+ ): readonly ScoredCommit[] {
1215
+ const matches: ScoredCommit[] = [];
1216
+ note.commits.forEach((commit, index) => {
1217
+ const subjectStrength = matchStrength(commit.subject, normalizedQuery, terms);
1218
+ const matchingPaths = commit.changedPaths
1219
+ .map((path) => ({ path, strength: matchStrength(path, normalizedQuery, terms) }))
1220
+ .filter(({ strength }) => strength > 0)
1221
+ .toSorted((left, right) => right.strength - left.strength || left.path.localeCompare(right.path));
1222
+ const bestPath = matchingPaths[0]?.strength ?? 0;
1223
+ if (subjectStrength === 0 && bestPath === 0) return;
1224
+ const recency = 1 / (100 + index);
1225
+ matches.push({
1226
+ score: subjectStrength * 4 + bestPath * 2 + recency,
1227
+ commit,
1228
+ matchedSubject: subjectStrength > 0,
1229
+ matchedPaths: Object.freeze(matchingPaths.map(({ path }) => path)),
1230
+ });
1231
+ });
1232
+ return Object.freeze(matches.toSorted((left, right) =>
1233
+ right.score - left.score || left.commit.hash.localeCompare(right.commit.hash)));
1234
+ }
1235
+
1236
+ /** Rank notes by matching commit subjects and paths changed in the same commits. */
1237
+ export function searchGitHistory(
1238
+ index: GitHistoryIndexResult,
1239
+ options: SearchGitHistoryOptions,
1240
+ ): GitHistorySearchResult {
1241
+ const request = validateSearchGitHistoryOptions(options);
1242
+ if (index.status === "unavailable") return index;
1243
+ const hits: GitHistorySearchHit[] = [];
1244
+ for (const note of index.notes) {
1245
+ if (request.allowedNoteIds !== null && !request.allowedNoteIds.has(note.id)) continue;
1246
+ const matches = scoredCommits(note, request.normalizedQuery, request.terms);
1247
+ if (matches.length === 0) continue;
1248
+ const score = matches.reduce((total, match, matchIndex) =>
1249
+ total + match.score / (matchIndex + 1), 0);
1250
+ hits.push(Object.freeze({
1251
+ id: note.id,
1252
+ path: note.path,
1253
+ score: Number(score.toFixed(6)),
1254
+ commits: Object.freeze(matches.slice(0, request.commitsPerHit).map((match) => Object.freeze({
1255
+ ...noteCommit(match.commit, note.repositoryPath, request.cochangedPathsPerCommit),
1256
+ matchedSubject: match.matchedSubject,
1257
+ matchedPaths: Object.freeze(match.matchedPaths.slice(0, request.cochangedPathsPerCommit)),
1258
+ }))),
1259
+ }));
1260
+ }
1261
+ hits.sort((left, right) => right.score - left.score || left.id.localeCompare(right.id));
1262
+ const searchableNoteIds = new Set(index.notes.flatMap(({ id }) =>
1263
+ request.allowedNoteIds === null || request.allowedNoteIds.has(id) ? [id] : []));
1264
+ const limitedCommits = limitedCommitsForNotes(
1265
+ index.limitedCommits,
1266
+ searchableNoteIds,
1267
+ null,
1268
+ );
1269
+ return Object.freeze({
1270
+ status: "ready",
1271
+ head: index.head,
1272
+ query: request.query,
1273
+ hits: Object.freeze(hits.slice(0, request.limit)),
1274
+ ...(limitedCommits.length === 0 ? {} : { limitedCommits }),
1275
+ });
1276
+ }