@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
@@ -0,0 +1,197 @@
1
+ import { createHash } from "node:crypto";
2
+ import { lstat, realpath } from "node:fs/promises";
3
+ import { isAbsolute, relative, resolve, sep } from "node:path";
4
+
5
+ import { runGitCommand, type GitCommandProvider, type GitCommandResult } from "../git.js";
6
+ import {
7
+ readCaptureBundle,
8
+ type CaptureBundleInspection,
9
+ type ReadCaptureBundleOptions,
10
+ } from "./bundle-reader.js";
11
+
12
+ const MAX_GIT_DOCUMENT_BYTES = 64 * 1_024 * 1_024;
13
+ const MAX_DIFF_BYTES = 2 * 1_024 * 1_024;
14
+ const GIT_TIMEOUT_MS = 10_000;
15
+ const REF_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._/-]{0,255}$/u;
16
+ const OBJECT_ID_PATTERN = /^[0-9a-f]{40}(?:[0-9a-f]{24})?$/u;
17
+
18
+ export type CaptureBundleDiff = {
19
+ readonly status: "changed" | "missing-at-ref" | "unchanged";
20
+ readonly ref: string;
21
+ readonly repositoryPath: string;
22
+ readonly currentSha256: string;
23
+ readonly referenceSha256: string | null;
24
+ readonly diff: string | null;
25
+ };
26
+
27
+ export type CaptureBundleDiffOptions = {
28
+ readonly bundle: string;
29
+ readonly repository: string;
30
+ readonly ref?: string;
31
+ readonly readOptions?: Pick<ReadCaptureBundleOptions, "maxDocumentBytes">;
32
+ };
33
+
34
+ export type CaptureBundleDiffDependencies = {
35
+ readonly runGit?: GitCommandProvider;
36
+ readonly readBundle?: typeof readCaptureBundle;
37
+ };
38
+
39
+ function gitRef(value: string | undefined): string {
40
+ const ref = value ?? "HEAD";
41
+ if (
42
+ !REF_PATTERN.test(ref)
43
+ || ref.includes("..")
44
+ || ref.includes("//")
45
+ || ref.endsWith("/")
46
+ || ref.endsWith(".lock")
47
+ ) throw new TypeError("capture diff ref is not a bounded Git revision name");
48
+ return ref;
49
+ }
50
+
51
+ function utf8(value: string | Uint8Array | undefined): Buffer {
52
+ if (value === undefined) return Buffer.alloc(0);
53
+ return typeof value === "string" ? Buffer.from(value, "utf8") : Buffer.from(value);
54
+ }
55
+
56
+ function sha256(value: Uint8Array): string {
57
+ return createHash("sha256").update(value).digest("hex");
58
+ }
59
+
60
+ function gitMessage(result: Exclude<GitCommandResult, { readonly status: "ok" }>, label: string): Error {
61
+ return new Error(`${label}: ${result.message}`);
62
+ }
63
+
64
+ async function canonicalRepository(input: string): Promise<string> {
65
+ const repository = await realpath(resolve(input));
66
+ const stats = await lstat(repository);
67
+ if (!stats.isDirectory() || stats.isSymbolicLink()) throw new Error("capture diff repository must be a real directory");
68
+ return repository;
69
+ }
70
+
71
+ function repositoryPath(repository: string, path: string): string {
72
+ const fromRepository = relative(repository, path);
73
+ if (
74
+ fromRepository === ""
75
+ || fromRepository === ".."
76
+ || fromRepository.startsWith(`..${sep}`)
77
+ || isAbsolute(fromRepository)
78
+ ) throw new Error("capture bundle must be inside the selected repository");
79
+ return fromRepository.split(sep).join("/");
80
+ }
81
+
82
+ async function assertCaptureBundleStable(
83
+ options: CaptureBundleDiffOptions,
84
+ readBundle: typeof readCaptureBundle,
85
+ inspection: CaptureBundleInspection,
86
+ ): Promise<void> {
87
+ const confirmed = await readBundle(options.bundle, options.readOptions);
88
+ if (
89
+ confirmed.root !== inspection.root
90
+ || confirmed.document.path !== inspection.document.path
91
+ || confirmed.document.sha256 !== inspection.document.sha256
92
+ ) {
93
+ throw new Error("Capture bundle changed while its Git comparison was generated; retry.");
94
+ }
95
+ }
96
+
97
+ /** Compare one verified stored capture with its exact bytes at a bounded Git ref. */
98
+ export async function diffCaptureBundle(
99
+ options: CaptureBundleDiffOptions,
100
+ dependencies: CaptureBundleDiffDependencies = {},
101
+ ): Promise<CaptureBundleDiff> {
102
+ const ref = gitRef(options.ref);
103
+ const selectedDirectory = await canonicalRepository(options.repository);
104
+ const readBundle = dependencies.readBundle ?? readCaptureBundle;
105
+ const inspection = await readBundle(options.bundle, options.readOptions);
106
+ const runGit = dependencies.runGit ?? runGitCommand;
107
+ const repositoryCheck = await runGit({
108
+ arguments: ["rev-parse", "--show-toplevel"],
109
+ cwd: selectedDirectory,
110
+ timeoutMs: GIT_TIMEOUT_MS,
111
+ maxOutputBytes: 4 * 1_024,
112
+ });
113
+ if (repositoryCheck.status !== "ok") {
114
+ throw gitMessage(repositoryCheck, "Capture diff repository is not a Git work tree");
115
+ }
116
+ const topLevel = utf8(repositoryCheck.stdout).toString("utf8").trim();
117
+ if (!isAbsolute(topLevel)) throw new Error("Capture diff repository did not resolve to a canonical Git work tree");
118
+ const repository = await canonicalRepository(topLevel);
119
+ const selectedFromRepository = relative(repository, selectedDirectory);
120
+ if (
121
+ selectedFromRepository === ".."
122
+ || selectedFromRepository.startsWith(`..${sep}`)
123
+ || isAbsolute(selectedFromRepository)
124
+ ) {
125
+ throw new Error("Capture diff repository did not resolve inside its Git work tree");
126
+ }
127
+ const path = repositoryPath(repository, resolve(inspection.root, inspection.document.path));
128
+ const refCheck = await runGit({
129
+ arguments: ["rev-parse", "--verify", "--end-of-options", `${ref}^{commit}`],
130
+ cwd: repository,
131
+ timeoutMs: GIT_TIMEOUT_MS,
132
+ maxOutputBytes: 4 * 1_024,
133
+ });
134
+ if (refCheck.status !== "ok") throw gitMessage(refCheck, "Could not resolve capture diff Git ref");
135
+ const objectId = utf8(refCheck.stdout).toString("utf8").trim();
136
+ if (!OBJECT_ID_PATTERN.test(objectId)) {
137
+ throw new Error("Could not resolve capture diff Git ref: Git returned a malformed commit object ID");
138
+ }
139
+ const literalPathspec = `:(literal)${path}`;
140
+ const specifier = `${objectId}:${path}`;
141
+ const tree = await runGit({
142
+ arguments: ["ls-tree", "-z", objectId, "--", literalPathspec],
143
+ cwd: repository,
144
+ timeoutMs: GIT_TIMEOUT_MS,
145
+ maxOutputBytes: 8 * 1_024,
146
+ });
147
+ if (tree.status !== "ok") throw gitMessage(tree, "Could not inspect capture path at the requested Git ref");
148
+ if (utf8(tree.stdout).byteLength === 0) {
149
+ await assertCaptureBundleStable(options, readBundle, inspection);
150
+ return Object.freeze({
151
+ status: "missing-at-ref",
152
+ ref,
153
+ repositoryPath: path,
154
+ currentSha256: inspection.document.sha256,
155
+ referenceSha256: null,
156
+ diff: null,
157
+ });
158
+ }
159
+ const historical = await runGit({
160
+ arguments: ["show", "--no-ext-diff", "--no-textconv", "--format=", specifier],
161
+ cwd: repository,
162
+ timeoutMs: GIT_TIMEOUT_MS,
163
+ maxOutputBytes: MAX_GIT_DOCUMENT_BYTES,
164
+ });
165
+ if (historical.status !== "ok") {
166
+ throw gitMessage(historical, "Could not read capture at the requested Git ref");
167
+ }
168
+ const referenceBytes = utf8(historical.stdout);
169
+ const referenceSha256 = sha256(referenceBytes);
170
+ if (referenceSha256 === inspection.document.sha256) {
171
+ await assertCaptureBundleStable(options, readBundle, inspection);
172
+ return Object.freeze({
173
+ status: "unchanged",
174
+ ref,
175
+ repositoryPath: path,
176
+ currentSha256: inspection.document.sha256,
177
+ referenceSha256,
178
+ diff: "",
179
+ });
180
+ }
181
+ const result = await runGit({
182
+ arguments: ["diff", "--no-ext-diff", "--no-textconv", "--unified=3", objectId, "--", literalPathspec],
183
+ cwd: repository,
184
+ timeoutMs: GIT_TIMEOUT_MS,
185
+ maxOutputBytes: MAX_DIFF_BYTES,
186
+ });
187
+ if (result.status !== "ok") throw gitMessage(result, "Could not diff capture bundle");
188
+ await assertCaptureBundleStable(options, readBundle, inspection);
189
+ return Object.freeze({
190
+ status: "changed",
191
+ ref,
192
+ repositoryPath: path,
193
+ currentSha256: inspection.document.sha256,
194
+ referenceSha256,
195
+ diff: utf8(result.stdout).toString("utf8"),
196
+ });
197
+ }
@@ -0,0 +1,432 @@
1
+ import { captureUrl, type CaptureArguments, type CaptureScope } from "./args.js";
2
+ import type { AcquisitionMethod, AcquiredPage } from "./acquire.js";
3
+ import { countWords, type CaptureStatus, type ExtractedPage } from "./extract.js";
4
+ import type { Article } from "./lib.js";
5
+ import { decodeBytes, safeFetch } from "./network.js";
6
+ import {
7
+ classifyPlatformUrl,
8
+ parseBlueskyCapture,
9
+ parseHackerNewsCapture,
10
+ parseRedditCapture,
11
+ renderCapturedDocument,
12
+ type CapturedContentEntry,
13
+ type CapturedDocument,
14
+ type CapturedEntry,
15
+ type PlatformUrl,
16
+ } from "./platforms.js";
17
+
18
+ export type PublicStructuredCapture = {
19
+ readonly extraction: ExtractedPage;
20
+ readonly evidence: string;
21
+ };
22
+
23
+ export type JsonFetcher = (url: URL, maxBytes: number, timeoutMs?: number) => Promise<unknown>;
24
+
25
+ type AdapterDependencies = {
26
+ readonly fetchJson?: JsonFetcher;
27
+ };
28
+
29
+ const isRecord = (value: unknown): value is Record<string, unknown> =>
30
+ typeof value === "object" && value !== null && !Array.isArray(value);
31
+
32
+ const itemId = (value: unknown): string | null => {
33
+ if (typeof value === "string" && /^\d+$/.test(value)) return value;
34
+ if (typeof value === "number" && Number.isSafeInteger(value) && value >= 0) return String(value);
35
+ return null;
36
+ };
37
+
38
+ type HackerNewsPendingItem = { readonly id: string; readonly depth: number };
39
+
40
+ const enqueueHackerNewsChildren = (
41
+ value: unknown,
42
+ depth: number,
43
+ maximumQueueSize: number,
44
+ queue: HackerNewsPendingItem[],
45
+ scheduled: Set<string>,
46
+ ): { readonly duplicate: boolean; readonly truncated: boolean } => {
47
+ if (!isRecord(value) || !Array.isArray(value.kids)) return { duplicate: false, truncated: false };
48
+ let duplicate = false;
49
+ for (const child of value.kids) {
50
+ const id = itemId(child);
51
+ if (id === null) continue;
52
+ if (scheduled.has(id)) {
53
+ duplicate = true;
54
+ continue;
55
+ }
56
+ if (queue.length >= maximumQueueSize) return { duplicate, truncated: true };
57
+ scheduled.add(id);
58
+ queue.push({ id, depth });
59
+ }
60
+ return { duplicate, truncated: false };
61
+ };
62
+
63
+ async function defaultJsonFetcher(
64
+ options: CaptureArguments,
65
+ url: URL,
66
+ maxBytes: number,
67
+ timeoutMs = options.timeoutMs,
68
+ ): Promise<unknown> {
69
+ const response = await safeFetch(url, {
70
+ timeoutMs,
71
+ maxBytes,
72
+ allowPrivateNetwork: options.allowPrivateNetwork,
73
+ userAgent: options.userAgent,
74
+ accept: "application/json",
75
+ retries: 2,
76
+ });
77
+ const text = decodeBytes(response.bytes, response.contentType);
78
+ try {
79
+ return JSON.parse(text) as unknown;
80
+ } catch (error) {
81
+ throw new Error(`invalid JSON from ${url.origin}`, { cause: error });
82
+ }
83
+ }
84
+
85
+ function serializedBytes(value: unknown): number {
86
+ try {
87
+ return new TextEncoder().encode(JSON.stringify(value)).byteLength;
88
+ } catch {
89
+ return Number.POSITIVE_INFINITY;
90
+ }
91
+ }
92
+
93
+ function walkDocument(document: CapturedDocument): {
94
+ readonly pageItems: number;
95
+ readonly scopedItems: number;
96
+ readonly incomplete: boolean;
97
+ readonly rootIncomplete: boolean;
98
+ readonly blockedRoot: boolean;
99
+ } {
100
+ let pageItems = 0;
101
+ let scopedItems = 0;
102
+ let incomplete = false;
103
+ let rootIncomplete = false;
104
+ let blockedRoot = false;
105
+ const active = new WeakSet<object>();
106
+ const visit = (entry: CapturedEntry, location: "ancestor" | "root" | "quote" | "reply"): void => {
107
+ if (active.has(entry)) {
108
+ incomplete = true;
109
+ return;
110
+ }
111
+ active.add(entry);
112
+ if (entry.kind === "boundary" || entry.kind === "more") {
113
+ incomplete = true;
114
+ if (location === "root") rootIncomplete = true;
115
+ active.delete(entry);
116
+ return;
117
+ }
118
+ const unavailable = entry.kind === "unavailable";
119
+ const unavailableButRepresented = unavailable && (entry.reason === "deleted" || entry.reason === "dead" || entry.reason === "removed");
120
+ const captured = entry.kind === "content" || unavailableButRepresented;
121
+ if (location === "root" && captured) pageItems += 1;
122
+ if (location === "reply" && captured) scopedItems += 1;
123
+ if (unavailable && (entry.reason === "not-found" || entry.reason === "blocked")) incomplete = true;
124
+ if (location === "root" && unavailable && (entry.reason === "not-found" || entry.reason === "blocked")) {
125
+ rootIncomplete = true;
126
+ }
127
+ if (location === "root" && unavailable && entry.reason === "blocked") blockedRoot = true;
128
+ if (entry.kind === "content") {
129
+ for (const quote of entry.quotes) visit(quote, "quote");
130
+ for (const reply of entry.replies) visit(reply, "reply");
131
+ } else if (entry.kind === "unavailable") {
132
+ for (const reply of entry.replies) visit(reply, "reply");
133
+ }
134
+ active.delete(entry);
135
+ };
136
+ for (const entry of document.ancestors) visit(entry, "ancestor");
137
+ for (const entry of document.roots) visit(entry, "root");
138
+ return { pageItems, scopedItems, incomplete, rootIncomplete, blockedRoot };
139
+ }
140
+
141
+ const rootContent = (document: CapturedDocument): CapturedContentEntry | null => {
142
+ const root = document.roots[0];
143
+ return root?.kind === "content" ? root : null;
144
+ };
145
+
146
+ function structuredStatus(
147
+ document: CapturedDocument,
148
+ scope: CaptureScope,
149
+ adapterWarnings: readonly string[],
150
+ ): {
151
+ readonly status: CaptureStatus;
152
+ readonly capturedItems: number;
153
+ readonly expectedItems: number | null;
154
+ readonly declaredItems: number | null;
155
+ } {
156
+ const walked = walkDocument(document);
157
+ const root = rootContent(document);
158
+ if (scope === "page") {
159
+ return {
160
+ status: walked.blockedRoot
161
+ ? "blocked"
162
+ : walked.rootIncomplete || walked.pageItems === 0 || adapterWarnings.length > 0 ? "partial" : "complete",
163
+ capturedItems: walked.pageItems,
164
+ expectedItems: null,
165
+ declaredItems: null,
166
+ };
167
+ }
168
+ const declaredItems = root?.metrics.replies ?? null;
169
+ const expectedItems = declaredItems === null ? null : Math.max(declaredItems, walked.scopedItems);
170
+ if (walked.blockedRoot) {
171
+ return { status: "blocked", capturedItems: walked.scopedItems, expectedItems, declaredItems };
172
+ }
173
+ const shortOfDeclared = declaredItems !== null && walked.scopedItems < declaredItems;
174
+ return {
175
+ status: walked.incomplete || shortOfDeclared || adapterWarnings.length > 0 ? "partial" : "complete",
176
+ capturedItems: walked.scopedItems,
177
+ expectedItems,
178
+ declaredItems,
179
+ };
180
+ }
181
+
182
+ export function structuredCaptureFromDocument(
183
+ options: CaptureArguments,
184
+ document: CapturedDocument,
185
+ evidence: unknown,
186
+ method: AcquisitionMethod,
187
+ adapterWarnings: readonly string[],
188
+ extractor = `${document.platform}-public-api`,
189
+ ): PublicStructuredCapture {
190
+ const rendered = renderCapturedDocument(document);
191
+ const content = rendered.replace(/^# [^\n]+\n\n/, "").trim();
192
+ const root = rootContent(document);
193
+ const article: Article = {
194
+ content,
195
+ title: document.title,
196
+ author: root?.author?.name ?? null,
197
+ published: root?.createdAt ?? null,
198
+ description: null,
199
+ };
200
+ const completeness = structuredStatus(document, options.scope, [...adapterWarnings, ...document.warnings]);
201
+ const warnings = [...adapterWarnings, ...document.warnings];
202
+ if (completeness.declaredItems !== null && completeness.capturedItems > completeness.declaredItems) {
203
+ warnings.push(
204
+ `The source declared ${completeness.declaredItems} scoped items, but ${completeness.capturedItems} distinct items were captured; the expected count was normalized to the observed count.`,
205
+ );
206
+ }
207
+ if (completeness.status !== "complete") {
208
+ warnings.push(`Structured ${document.platform} capture is ${completeness.status}; limits or unavailable branches remain.`);
209
+ }
210
+ const acquisition: AcquiredPage = {
211
+ body: JSON.stringify(evidence),
212
+ contentType: "application/json",
213
+ finalUrl: captureUrl(options),
214
+ method,
215
+ warnings,
216
+ };
217
+ const wordCount = countWords(content);
218
+ const statusWeight: Readonly<Record<CaptureStatus, number>> = {
219
+ complete: 100_000,
220
+ partial: 60_000,
221
+ "auth-required": 0,
222
+ blocked: -10_000,
223
+ unsupported: -20_000,
224
+ };
225
+ return {
226
+ extraction: {
227
+ article,
228
+ canonicalUrl: new URL(document.sourceUrl),
229
+ platform: document.platform,
230
+ status: completeness.status,
231
+ score: statusWeight[completeness.status] + Math.min(content.length, 50_000) + completeness.capturedItems * 50,
232
+ wordCount,
233
+ expectedItems: completeness.expectedItems,
234
+ capturedItems: completeness.capturedItems,
235
+ extractor,
236
+ warnings,
237
+ acquisition,
238
+ },
239
+ evidence: `${JSON.stringify(evidence, null, 2)}\n`,
240
+ };
241
+ }
242
+
243
+ async function captureHackerNews(
244
+ options: CaptureArguments,
245
+ classified: Extract<PlatformUrl, { readonly platform: "hacker-news" }>,
246
+ fetchJson: JsonFetcher,
247
+ ): Promise<PublicStructuredCapture> {
248
+ const endpoint = (id: string): URL => new URL(`https://hacker-news.firebaseio.com/v0/item/${encodeURIComponent(id)}.json`);
249
+ const deadline = Date.now() + options.timeoutMs;
250
+ let remainingBytes = options.maxHtmlBytes;
251
+ const remainingTime = (): number => {
252
+ const value = deadline - Date.now();
253
+ if (value <= 0) throw new Error(`Hacker News capture exceeded the ${options.timeoutMs}ms total deadline`);
254
+ return value;
255
+ };
256
+ const rootAllocation = Math.min(remainingBytes, 1024 * 1024);
257
+ if (rootAllocation < 1) throw new Error("Hacker News capture has no remaining response-byte budget");
258
+ remainingBytes -= rootAllocation;
259
+ const root = await fetchJson(endpoint(classified.itemId), rootAllocation, remainingTime());
260
+ const rootBytes = serializedBytes(root);
261
+ if (!Number.isFinite(rootBytes) || rootBytes > rootAllocation) {
262
+ throw new Error("Hacker News root item exceeded its bounded JSON allocation");
263
+ }
264
+ remainingBytes += rootAllocation - rootBytes;
265
+ if (!isRecord(root) || itemId(root.id) === null) throw new Error("Hacker News API returned no root item");
266
+
267
+ if (options.scope === "page") {
268
+ const evidence = { root, descendants: [] };
269
+ const parsed = parseHackerNewsCapture(evidence, classified.href, {
270
+ limits: { maxItems: options.maxItems, maxDepth: options.maxDepth },
271
+ });
272
+ if (!parsed.ok) throw new Error(parsed.error.message);
273
+ return structuredCaptureFromDocument(options, parsed.document, evidence, "hacker-news-api", []);
274
+ }
275
+
276
+ const descendants: unknown[] = [];
277
+ const warnings: string[] = [];
278
+ const scheduled = new Set<string>([classified.itemId]);
279
+ const queue: HackerNewsPendingItem[] = [];
280
+ const initialChildren = enqueueHackerNewsChildren(
281
+ root,
282
+ 1,
283
+ Math.max(0, options.maxItems - 1),
284
+ queue,
285
+ scheduled,
286
+ );
287
+ let duplicateChildren = initialChildren.duplicate;
288
+ let limited = initialChildren.truncated;
289
+ while (queue.length > 0 && descendants.length + 1 < options.maxItems) {
290
+ const remaining = options.maxItems - descendants.length - 1;
291
+ const batchSize = Math.min(8, remaining, queue.length, remainingBytes);
292
+ if (batchSize < 1) {
293
+ limited = true;
294
+ break;
295
+ }
296
+ const batch = queue.splice(0, batchSize);
297
+ const allocation = Math.min(64 * 1024, Math.floor(remainingBytes / batch.length));
298
+ remainingBytes -= allocation * batch.length;
299
+ const fetched = await Promise.all(batch.map(async ({ id, depth }) => {
300
+ try {
301
+ const value = await fetchJson(endpoint(id), allocation, remainingTime());
302
+ const bytes = serializedBytes(value);
303
+ if (!Number.isFinite(bytes) || bytes > allocation) {
304
+ return { id, depth, value: null, warning: `Hacker News item ${id} exceeded its bounded JSON allocation.` };
305
+ }
306
+ remainingBytes += allocation - bytes;
307
+ return { id, depth, value, warning: null };
308
+ } catch (error) {
309
+ const message = error instanceof Error ? error.message : String(error);
310
+ return { id, depth, value: null, warning: `Could not fetch Hacker News item ${id}: ${message}` };
311
+ }
312
+ }));
313
+ const childrenToSchedule: { readonly value: unknown; readonly depth: number }[] = [];
314
+ for (const result of fetched) {
315
+ if (result.warning !== null) warnings.push(result.warning);
316
+ if (result.value === null) continue;
317
+ descendants.push(result.value);
318
+ childrenToSchedule.push({ value: result.value, depth: result.depth });
319
+ }
320
+ const maximumQueueSize = Math.max(0, options.maxItems - descendants.length - 1);
321
+ for (const result of childrenToSchedule) {
322
+ const atDepthLimit = result.depth >= options.maxDepth - 1;
323
+ const enqueued = enqueueHackerNewsChildren(
324
+ result.value,
325
+ result.depth + 1,
326
+ atDepthLimit ? queue.length : maximumQueueSize,
327
+ queue,
328
+ scheduled,
329
+ );
330
+ duplicateChildren = duplicateChildren || enqueued.duplicate;
331
+ limited = limited || enqueued.truncated;
332
+ }
333
+ }
334
+ if (duplicateChildren) warnings.push("Hacker News duplicate or cyclic child IDs were skipped.");
335
+ if (queue.length > 0 || limited) {
336
+ warnings.push("Hacker News descendants exceeded the configured item, depth, byte, or total-deadline limit.");
337
+ }
338
+ const evidence = { root, descendants };
339
+ const parsed = parseHackerNewsCapture(evidence, classified.href, {
340
+ limits: { maxItems: options.maxItems, maxDepth: options.maxDepth },
341
+ });
342
+ if (!parsed.ok) throw new Error(parsed.error.message);
343
+ const document: CapturedDocument = {
344
+ ...parsed.document,
345
+ warnings: [...parsed.document.warnings, ...warnings],
346
+ };
347
+ return structuredCaptureFromDocument(options, document, evidence, "hacker-news-api", warnings);
348
+ }
349
+
350
+ async function captureBluesky(
351
+ options: CaptureArguments,
352
+ classified: Extract<PlatformUrl, { readonly platform: "bluesky" }>,
353
+ fetchJson: JsonFetcher,
354
+ ): Promise<PublicStructuredCapture> {
355
+ let did = classified.actor;
356
+ const evidence: Record<string, unknown> = {};
357
+ if (!did.startsWith("did:")) {
358
+ const resolveUrl = new URL("https://public.api.bsky.app/xrpc/com.atproto.identity.resolveHandle");
359
+ resolveUrl.searchParams.set("handle", did);
360
+ const resolution = await fetchJson(resolveUrl, Math.min(options.maxHtmlBytes, 1024 * 1024));
361
+ evidence.resolution = resolution;
362
+ if (!isRecord(resolution) || typeof resolution.did !== "string" || !resolution.did.startsWith("did:")) {
363
+ throw new Error(`Bluesky could not resolve ${classified.actor}`);
364
+ }
365
+ did = resolution.did;
366
+ }
367
+ const threadUrl = new URL("https://public.api.bsky.app/xrpc/app.bsky.feed.getPostThread");
368
+ threadUrl.searchParams.set("uri", `at://${did}/app.bsky.feed.post/${classified.postId}`);
369
+ threadUrl.searchParams.set("depth", options.scope === "page" ? "0" : String(Math.min(options.maxDepth, 1000)));
370
+ threadUrl.searchParams.set("parentHeight", String(Math.min(options.maxDepth, 1000)));
371
+ const thread = await fetchJson(threadUrl, options.maxHtmlBytes);
372
+ evidence.thread = thread;
373
+ const parsed = parseBlueskyCapture(thread, classified.href, {
374
+ limits: { maxItems: options.maxItems, maxDepth: options.maxDepth },
375
+ });
376
+ if (!parsed.ok) throw new Error(parsed.error.message);
377
+ return structuredCaptureFromDocument(options, parsed.document, evidence, "bluesky-api", []);
378
+ }
379
+
380
+ function rootOnlyRedditInput(input: unknown): unknown {
381
+ if (!Array.isArray(input)) return input;
382
+ const values: readonly unknown[] = input;
383
+ const post: unknown = values[0];
384
+ return post === undefined ? values : [post];
385
+ }
386
+
387
+ function redditHasPagination(input: unknown): boolean {
388
+ if (!Array.isArray(input)) return false;
389
+ const comments: unknown = input[1];
390
+ if (!isRecord(comments)) return false;
391
+ const data = isRecord(comments.data) ? comments.data : null;
392
+ return data !== null && data.after !== undefined && data.after !== null;
393
+ }
394
+
395
+ /** Best-effort Reddit listing JSON. This endpoint is public but unofficial and may be denied by Reddit. */
396
+ async function captureReddit(
397
+ options: CaptureArguments,
398
+ classified: Extract<PlatformUrl, { readonly platform: "reddit" }>,
399
+ fetchJson: JsonFetcher,
400
+ ): Promise<PublicStructuredCapture> {
401
+ const endpoint = new URL(`https://www.reddit.com/comments/${encodeURIComponent(classified.postId)}.json`);
402
+ endpoint.searchParams.set("raw_json", "1");
403
+ endpoint.searchParams.set("limit", String(Math.max(1, options.maxItems - 1)));
404
+ endpoint.searchParams.set("depth", String(options.scope === "page" ? 0 : options.maxDepth));
405
+ if (classified.commentId !== null) endpoint.searchParams.set("comment", classified.commentId);
406
+ const evidence = await fetchJson(endpoint, options.maxHtmlBytes, options.timeoutMs);
407
+ const parserInput = options.scope === "page" ? rootOnlyRedditInput(evidence) : evidence;
408
+ const parsed = parseRedditCapture(parserInput, classified.href, {
409
+ limits: { maxItems: options.maxItems, maxDepth: options.maxDepth },
410
+ });
411
+ if (!parsed.ok) throw new Error(parsed.error.message);
412
+ const warnings = options.scope !== "page" && redditHasPagination(evidence)
413
+ ? ["Reddit JSON returned a pagination cursor; additional comments remain uncaptured."]
414
+ : [];
415
+ const storedEvidence = options.scope === "page" ? parserInput : evidence;
416
+ return structuredCaptureFromDocument(options, parsed.document, storedEvidence, "reddit-json", warnings, "reddit-json");
417
+ }
418
+
419
+ /** Use stable public APIs where they preserve more structure than page scraping. */
420
+ export async function acquirePublicStructured(
421
+ options: CaptureArguments,
422
+ dependencies: AdapterDependencies = {},
423
+ ): Promise<PublicStructuredCapture | null> {
424
+ const classified = classifyPlatformUrl(captureUrl(options).href);
425
+ if (classified === null) return null;
426
+ const fetchJson = dependencies.fetchJson
427
+ ?? ((url, maxBytes, timeoutMs) => defaultJsonFetcher(options, url, maxBytes, timeoutMs));
428
+ if (classified.platform === "hacker-news") return captureHackerNews(options, classified, fetchJson);
429
+ if (classified.platform === "bluesky") return captureBluesky(options, classified, fetchJson);
430
+ if (classified.platform === "reddit") return captureReddit(options, classified, fetchJson);
431
+ return null;
432
+ }