akm-cli 0.9.0-rc.9 → 0.9.0

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 (450) hide show
  1. package/CHANGELOG.md +1063 -44
  2. package/README.md +51 -25
  3. package/SECURITY.md +14 -1
  4. package/STABILITY.md +497 -0
  5. package/dist/akm +148 -35
  6. package/dist/{akm-migrate-storage → akm-migrate} +6 -9
  7. package/dist/assets/hints/cli-hints-full.md +223 -95
  8. package/dist/assets/hints/cli-hints-short.md +85 -22
  9. package/dist/assets/improve-strategies/default.json +1 -1
  10. package/dist/assets/improve-strategies/reflect-distill.json +1 -1
  11. package/dist/assets/prompts/memory-infer-user.md +2 -3
  12. package/dist/assets/stash-skeleton/README.md +6 -5
  13. package/dist/assets/stash-skeleton/facts/conventions/assets/agent.md +2 -0
  14. package/dist/assets/stash-skeleton/facts/conventions/assets/command.md +2 -0
  15. package/dist/assets/stash-skeleton/facts/conventions/assets/fact.md +2 -0
  16. package/dist/assets/stash-skeleton/facts/conventions/assets/knowledge.md +2 -0
  17. package/dist/assets/stash-skeleton/facts/conventions/assets/lesson.md +2 -0
  18. package/dist/assets/stash-skeleton/facts/conventions/assets/memory.md +2 -0
  19. package/dist/assets/stash-skeleton/facts/conventions/assets/script.md +2 -0
  20. package/dist/assets/stash-skeleton/facts/conventions/assets/skill.md +2 -0
  21. package/dist/assets/stash-skeleton/facts/conventions/assets/workflow.md +2 -0
  22. package/dist/assets/stash-skeleton/facts/conventions/backlinks.md +2 -0
  23. package/dist/assets/stash-skeleton/facts/conventions/domains.md +2 -0
  24. package/dist/assets/stash-skeleton/facts/conventions/organization.md +20 -9
  25. package/dist/assets/tasks/core/extract.yml +1 -1
  26. package/dist/assets/tasks/core/version-check.yml +1 -1
  27. package/dist/assets/tasks/improve/akm-graph-refresh-weekly.yml +5 -0
  28. package/dist/assets/tasks/improve/akm-improve-catchup.yml +8 -0
  29. package/dist/assets/tasks/improve/akm-improve-consolidate.yml +5 -0
  30. package/dist/assets/tasks/improve/akm-improve-frequent.yml +5 -0
  31. package/dist/assets/tasks/improve/akm-improve-nightly.yml +5 -0
  32. package/dist/assets/templates/html/health.html +1 -3
  33. package/dist/assets/workflows/workflow-template.md +32 -15
  34. package/dist/cli/invocation.js +40 -15
  35. package/dist/cli/parse-args.js +0 -22
  36. package/dist/cli/retired-commands.js +121 -0
  37. package/dist/cli/shared.js +154 -22
  38. package/dist/cli/unknown-flags.js +236 -0
  39. package/dist/cli-node.mjs +2 -1
  40. package/dist/cli.js +696 -258
  41. package/dist/commands/agent/agent-dispatch.js +14 -3
  42. package/dist/commands/agent/contribute-cli.js +73 -88
  43. package/dist/commands/completions.js +79 -22
  44. package/dist/commands/config-cli.js +17 -150
  45. package/dist/commands/env/env-cli.js +59 -143
  46. package/dist/commands/env/env.js +12 -163
  47. package/dist/commands/env/marker-path.js +6 -0
  48. package/dist/commands/env/secret-cli.js +36 -66
  49. package/dist/commands/env/secret.js +24 -57
  50. package/dist/commands/feedback-cli.js +141 -87
  51. package/dist/commands/health/accept-rate.js +58 -0
  52. package/dist/commands/health/advisories.js +3 -4
  53. package/dist/commands/health/checks.js +85 -23
  54. package/dist/commands/health/html-report.js +7 -10
  55. package/dist/commands/health/improve-metrics.js +25 -83
  56. package/dist/commands/health/md-report.js +5 -9
  57. package/dist/commands/health/metrics.js +62 -20
  58. package/dist/commands/health/renderers.js +47 -0
  59. package/dist/commands/health/report-view-model.js +4 -5
  60. package/dist/commands/health/stash-exposure.js +1 -1
  61. package/dist/commands/health/surfaces.js +3 -48
  62. package/dist/commands/health/task-runs.js +3 -67
  63. package/dist/commands/health/types-improve.js +7 -0
  64. package/dist/commands/health.js +99 -28
  65. package/dist/commands/improve/anti-collapse.js +2 -2
  66. package/dist/commands/improve/autonomy-gate.js +68 -0
  67. package/dist/commands/improve/collapse-detector.js +41 -40
  68. package/dist/commands/improve/consolidate/eligibility.js +1 -23
  69. package/dist/commands/improve/consolidate/merge.js +4 -0
  70. package/dist/commands/improve/consolidate.js +140 -1000
  71. package/dist/commands/improve/distill/promote-memory.js +12 -12
  72. package/dist/commands/improve/distill/quality-gate.js +6 -6
  73. package/dist/commands/improve/distill.js +58 -69
  74. package/dist/commands/improve/eligibility.js +95 -55
  75. package/dist/commands/improve/extract-cli.js +14 -133
  76. package/dist/commands/improve/improve-cli.js +98 -114
  77. package/dist/commands/improve/improve-result-file.js +1 -28
  78. package/dist/commands/improve/improve-strategies.js +8 -5
  79. package/dist/commands/improve/improve.js +128 -91
  80. package/dist/commands/improve/loop-stages.js +182 -20
  81. package/dist/commands/improve/memory/derived-ref.js +45 -43
  82. package/dist/commands/improve/memory/memory-belief.js +1 -1
  83. package/dist/commands/improve/memory/memory-contradiction-detect.js +4 -12
  84. package/dist/commands/improve/memory/memory-improve.js +6 -5
  85. package/dist/commands/improve/outcome-loop.js +22 -65
  86. package/dist/commands/improve/preparation.js +114 -123
  87. package/dist/commands/improve/proactive-maintenance.js +2 -5
  88. package/dist/commands/improve/reflect.js +56 -160
  89. package/dist/commands/improve/salience.js +11 -122
  90. package/dist/commands/improve/source-identity.js +10 -38
  91. package/dist/commands/lint/base-linter.js +20 -124
  92. package/dist/commands/lint/env-key-rules.js +31 -47
  93. package/dist/commands/lint/index.js +249 -43
  94. package/dist/commands/{events.js → log.js} +33 -38
  95. package/dist/commands/migrate-cli.js +92 -12
  96. package/dist/commands/migration-tool.js +46 -0
  97. package/dist/commands/observability-cli.js +70 -209
  98. package/dist/commands/proposal/drain.js +101 -29
  99. package/dist/commands/proposal/proposal-cli.js +76 -48
  100. package/dist/commands/proposal/proposal.js +54 -18
  101. package/dist/commands/proposal/propose-cli.js +88 -0
  102. package/dist/commands/proposal/propose.js +23 -15
  103. package/dist/commands/proposal/repository.js +701 -278
  104. package/dist/commands/proposal/validators/proposal-quality-validators.js +2 -8
  105. package/dist/commands/proposal/validators/proposal-validators.js +55 -7
  106. package/dist/commands/proposal/validators/proposals.js +4 -7
  107. package/dist/commands/read/curate.js +34 -53
  108. package/dist/commands/read/knowledge.js +150 -95
  109. package/dist/commands/read/registry-search.js +2 -2
  110. package/dist/commands/read/remember-cli.js +42 -15
  111. package/dist/commands/read/search-cli.js +180 -78
  112. package/dist/commands/read/search.js +58 -43
  113. package/dist/commands/read/show.js +197 -141
  114. package/dist/commands/registry-cli.js +12 -51
  115. package/dist/commands/remember.js +14 -57
  116. package/dist/commands/sources/add-cli.js +100 -31
  117. package/dist/commands/sources/bundle-cli.js +166 -0
  118. package/dist/commands/sources/bundle-config-ops.js +7 -2
  119. package/dist/commands/sources/info.js +18 -5
  120. package/dist/commands/sources/init.js +12 -12
  121. package/dist/commands/sources/installed-stashes.js +382 -98
  122. package/dist/commands/sources/schema-repair.js +3 -2
  123. package/dist/commands/sources/self-update.js +131 -38
  124. package/dist/commands/sources/source-add.js +72 -17
  125. package/dist/commands/sources/source-clone.js +129 -45
  126. package/dist/commands/sources/source-manage.js +43 -23
  127. package/dist/commands/sources/sources-cli.js +57 -208
  128. package/dist/commands/sources/stash-cli.js +46 -53
  129. package/dist/commands/tasks/tasks-cli.js +91 -97
  130. package/dist/commands/tasks/tasks.js +276 -421
  131. package/dist/commands/workflow-cli.js +175 -450
  132. package/dist/core/adapter/adapters/akm-adapter.js +47 -28
  133. package/dist/core/adapter/adapters/akm-lint.js +42 -27
  134. package/dist/core/adapter/adapters/akm-metadata.js +15 -44
  135. package/dist/core/adapter/adapters/akm-task-adapter.js +15 -13
  136. package/dist/core/adapter/adapters/akm-workflow-adapter.js +55 -71
  137. package/dist/core/adapter/adapters/dotenv-adapter.js +1 -1
  138. package/dist/core/adapter/adapters/generic-files-adapter.js +2 -0
  139. package/dist/core/adapter/adapters/index.js +6 -6
  140. package/dist/core/adapter/adapters/llm-wiki-adapter.js +14 -8
  141. package/dist/core/adapter/adapters/okf-adapter.js +187 -19
  142. package/dist/core/adapter/adapters/shared.js +3 -19
  143. package/dist/core/adapter/adapters/tool-dir-shared.js +8 -3
  144. package/dist/core/adapter/adapters/website-snapshot-adapter.js +1 -0
  145. package/dist/core/adapter/detect-adapter.js +17 -0
  146. package/dist/core/adapter/recognize-match.js +6 -4
  147. package/dist/core/adapter/validate-context.js +214 -0
  148. package/dist/core/asset/akm-markdown.js +63 -0
  149. package/dist/core/asset/asset-placement.js +20 -6
  150. package/dist/core/asset/asset-ref.js +11 -9
  151. package/dist/core/asset/frontmatter-lint.js +30 -0
  152. package/dist/core/asset/frontmatter.js +25 -9
  153. package/dist/core/asset/markdown.js +40 -51
  154. package/dist/core/asset/resolve-ref.js +89 -18
  155. package/dist/core/asset/stash-meta.js +1 -1
  156. package/dist/core/bundle-id.js +51 -0
  157. package/dist/core/common.js +152 -38
  158. package/dist/core/config/config-io.js +12 -1
  159. package/dist/core/config/config-schema.js +35 -8
  160. package/dist/core/config/config-sources.js +55 -11
  161. package/dist/core/config/config-walker.js +25 -9
  162. package/dist/core/config/config.js +9 -48
  163. package/dist/core/config/experimental.js +21 -0
  164. package/dist/core/config/schema/embedding.js +5 -1
  165. package/dist/core/config/schema/experimental.js +30 -0
  166. package/dist/core/config/schema/improve-processes.js +0 -6
  167. package/dist/core/config/schema/improve.js +21 -3
  168. package/dist/core/config/schema/index-config.js +8 -15
  169. package/dist/core/config/schema/output.js +4 -1
  170. package/dist/core/config/schema/setup.js +9 -18
  171. package/dist/core/config/schema/sources-bundles.js +49 -33
  172. package/dist/core/config/schema/workflow.js +3 -3
  173. package/dist/core/env-secret-ref.js +76 -46
  174. package/dist/core/errors.js +18 -12
  175. package/dist/core/events.js +46 -128
  176. package/dist/core/file-change.js +6 -5
  177. package/dist/core/fs-txn.js +83 -7
  178. package/dist/core/git-message.js +2 -2
  179. package/dist/core/improve-result.js +1 -100
  180. package/dist/core/lesson-lint.js +1 -17
  181. package/dist/core/logs-db.js +2 -1
  182. package/dist/core/migration-operation.js +16 -0
  183. package/dist/core/mutation-target.js +78 -0
  184. package/dist/core/parse.js +4 -1
  185. package/dist/core/paths.js +17 -20
  186. package/dist/core/recognition-util.js +12 -14
  187. package/dist/core/redaction.js +34 -0
  188. package/dist/core/standards/resolve-standards-context.js +2 -14
  189. package/dist/core/standards/resolve-stash-standards.js +2 -2
  190. package/dist/core/standards/resolve-type-conventions.js +2 -2
  191. package/dist/core/state/migrations.js +41 -18
  192. package/dist/core/state-db.js +5 -14
  193. package/dist/core/structured.js +1 -1
  194. package/dist/core/subprocess.js +6 -4
  195. package/dist/core/text-truncation.js +9 -5
  196. package/dist/core/type-presentation.js +3 -3
  197. package/dist/core/warn.js +0 -3
  198. package/dist/core/write-source.js +771 -95
  199. package/dist/indexer/bundle-identity-guard.js +3 -2
  200. package/dist/indexer/db/graph-db.js +0 -24
  201. package/dist/indexer/ensure-index.js +1 -0
  202. package/dist/indexer/graph/graph-boost.js +9 -34
  203. package/dist/indexer/graph/graph-extraction.js +8 -5
  204. package/dist/indexer/index-writer-lock.js +53 -17
  205. package/dist/indexer/index-written-assets.js +16 -22
  206. package/dist/indexer/indexer.js +497 -239
  207. package/dist/indexer/installations.js +14 -96
  208. package/dist/indexer/passes/dir-staleness.js +16 -9
  209. package/dist/indexer/passes/memory-inference.js +11 -9
  210. package/dist/indexer/passes/metadata.js +113 -47
  211. package/dist/indexer/scan/doc-to-entry.js +38 -1
  212. package/dist/indexer/scan/drain-dir.js +13 -23
  213. package/dist/indexer/search/db-search.js +99 -54
  214. package/dist/indexer/search/fts-query.js +47 -24
  215. package/dist/indexer/search/ranking-contributors.js +42 -20
  216. package/dist/indexer/search/ranking.js +18 -99
  217. package/dist/indexer/search/search-fields.js +7 -2
  218. package/dist/indexer/search/search-source.js +82 -93
  219. package/dist/indexer/usage/usage-events.js +0 -89
  220. package/dist/indexer/walk/file-context.js +2 -1
  221. package/dist/indexer/walk/matchers.js +30 -43
  222. package/dist/indexer/walk/path-resolver.js +7 -2
  223. package/dist/indexer/walk/walker.js +38 -12
  224. package/dist/integrations/agent/builders.js +0 -6
  225. package/dist/integrations/agent/config.js +2 -2
  226. package/dist/integrations/agent/detect.js +49 -19
  227. package/dist/integrations/agent/engine-fallback.js +76 -0
  228. package/dist/integrations/agent/profiles.js +14 -0
  229. package/dist/integrations/agent/prompts.js +12 -8
  230. package/dist/integrations/agent/runner-dispatch.js +4 -2
  231. package/dist/integrations/agent/runner.js +0 -1
  232. package/dist/integrations/agent/spawn.js +5 -6
  233. package/dist/integrations/github.js +1 -1
  234. package/dist/integrations/harnesses/aider/agent-builder.js +6 -4
  235. package/dist/integrations/harnesses/amazonq/agent-builder.js +7 -4
  236. package/dist/integrations/harnesses/claude/session-log.js +0 -10
  237. package/dist/integrations/harnesses/codex/agent-builder.js +5 -2
  238. package/dist/integrations/harnesses/copilot/agent-builder.js +5 -3
  239. package/dist/integrations/harnesses/gemini/agent-builder.js +5 -3
  240. package/dist/integrations/harnesses/index.js +3 -7
  241. package/dist/integrations/harnesses/opencode/agent-builder.js +21 -2
  242. package/dist/integrations/harnesses/opencode/session-log.js +0 -15
  243. package/dist/integrations/harnesses/opencode-sdk/sdk-runner.js +13 -4
  244. package/dist/integrations/harnesses/openhands/agent-builder.js +9 -6
  245. package/dist/integrations/harnesses/pi/agent-builder.js +6 -4
  246. package/dist/integrations/lockfile.js +88 -7
  247. package/dist/integrations/session-logs/index.js +3 -28
  248. package/dist/llm/client.js +136 -100
  249. package/dist/llm/embedders/remote.js +13 -5
  250. package/dist/llm/feature-gate.js +4 -12
  251. package/dist/llm/graph-extract.js +5 -11
  252. package/dist/llm/memory-infer.js +144 -1
  253. package/dist/llm/metadata-enhance.js +5 -7
  254. package/dist/llm/structured-call.js +1 -1
  255. package/dist/llm/usage-persist.js +26 -5
  256. package/dist/llm/usage-telemetry.js +25 -2
  257. package/dist/output/cli-hints.js +1 -2
  258. package/dist/output/context.js +22 -7
  259. package/dist/output/format-exempt.js +80 -0
  260. package/dist/output/generic-render.js +259 -0
  261. package/dist/output/render-registry.js +57 -0
  262. package/dist/output/renderers.js +14 -36
  263. package/dist/output/shapes/curate.js +10 -1
  264. package/dist/output/shapes/events.js +12 -7
  265. package/dist/output/shapes/helpers.js +56 -83
  266. package/dist/output/shapes/migrate.js +8 -0
  267. package/dist/output/shapes/passthrough.js +7 -41
  268. package/dist/output/shapes/proposal/producer.js +15 -7
  269. package/dist/output/shapes.js +2 -9
  270. package/dist/output/text/{init.js → bundle-create.js} +3 -1
  271. package/dist/output/text/bundle-show.js +7 -0
  272. package/dist/output/text/command-format.js +164 -96
  273. package/dist/output/text/env.js +1 -3
  274. package/dist/output/text/events.js +8 -7
  275. package/dist/output/text/health-format.js +103 -0
  276. package/dist/output/text/health.js +7 -0
  277. package/dist/output/text/helpers.js +10 -8
  278. package/dist/output/text/lint-format.js +43 -0
  279. package/dist/output/text/{save.js → lint.js} +2 -2
  280. package/dist/output/text/migrate.js +88 -0
  281. package/dist/output/text/proposal/producer.js +4 -2
  282. package/dist/output/text/proposal-format.js +44 -72
  283. package/dist/output/text/registry-commands.js +1 -2
  284. package/dist/output/text/show-directives.js +15 -7
  285. package/dist/output/text/status-list.js +32 -0
  286. package/dist/output/text/sync.js +5 -0
  287. package/dist/output/text/workflow-format.js +24 -203
  288. package/dist/output/text/workflow.js +1 -7
  289. package/dist/output/text.js +16 -17
  290. package/dist/registry/factory.js +4 -6
  291. package/dist/registry/origin-resolve.js +16 -27
  292. package/dist/registry/providers/skills-sh.js +3 -3
  293. package/dist/registry/providers/static-index.js +13 -23
  294. package/dist/registry/resolve.js +42 -7
  295. package/dist/registry/semver.js +34 -84
  296. package/dist/runtime.js +2 -23
  297. package/dist/scripts/akm-migrate-node.js +60290 -0
  298. package/dist/scripts/akm-migrate.js +59628 -0
  299. package/dist/setup/detect.js +42 -15
  300. package/dist/setup/registry-stash-loader.js +2 -2
  301. package/dist/setup/setup.js +236 -136
  302. package/dist/setup/steps/connection.js +7 -9
  303. package/dist/setup/steps/platforms.js +9 -9
  304. package/dist/setup/steps/semantic.js +15 -3
  305. package/dist/setup/steps/sources.js +12 -13
  306. package/dist/setup/steps/stashdir.js +2 -3
  307. package/dist/setup/steps/tasks.js +237 -120
  308. package/dist/sources/freshness.js +1 -1
  309. package/dist/sources/provider-factory.js +11 -17
  310. package/dist/sources/providers/filesystem.js +2 -3
  311. package/dist/sources/providers/git-install.js +278 -34
  312. package/dist/sources/providers/git-provider.js +25 -23
  313. package/dist/sources/providers/git-stash.js +395 -106
  314. package/dist/sources/providers/git.js +2 -2
  315. package/dist/sources/providers/npm.js +16 -19
  316. package/dist/sources/providers/provider-utils.js +7 -4
  317. package/dist/sources/providers/sync-from-ref.js +3 -9
  318. package/dist/sources/providers/website.js +6 -1
  319. package/dist/sources/resolve.js +6 -5
  320. package/dist/sources/snapshot-fetchers/bluesky.js +146 -0
  321. package/dist/sources/snapshot-fetchers/content-extract.js +566 -0
  322. package/dist/sources/snapshot-fetchers/fetcher-util.js +41 -0
  323. package/dist/sources/snapshot-fetchers/github.js +100 -0
  324. package/dist/sources/snapshot-fetchers/host-guard.js +291 -0
  325. package/dist/sources/snapshot-fetchers/registry.js +17 -1
  326. package/dist/sources/snapshot-fetchers/robots.js +348 -0
  327. package/dist/sources/snapshot-fetchers/rss.js +282 -0
  328. package/dist/sources/snapshot-fetchers/secret-seam.js +42 -0
  329. package/dist/sources/snapshot-fetchers/website-ingest.js +566 -268
  330. package/dist/sources/snapshot-fetchers/x.js +910 -0
  331. package/dist/storage/database.js +7 -0
  332. package/dist/storage/engines/sqlite-migrations.js +23 -111
  333. package/dist/storage/managed-db.js +2 -2
  334. package/dist/storage/repositories/canaries-repository.js +1 -1
  335. package/dist/storage/repositories/events-repository.js +27 -11
  336. package/dist/storage/repositories/improve-runs-repository.js +6 -12
  337. package/dist/storage/repositories/index-connection.js +17 -6
  338. package/dist/storage/repositories/index-entries-repository.js +151 -240
  339. package/dist/storage/repositories/index-entry-mapper.js +15 -11
  340. package/dist/storage/repositories/index-fts-repository.js +5 -2
  341. package/dist/storage/repositories/index-llm-cache-repository.js +0 -1
  342. package/dist/storage/repositories/index-meta-repository.js +2 -3
  343. package/dist/storage/repositories/index-schema.js +10 -25
  344. package/dist/storage/repositories/index-utility-repository.js +15 -28
  345. package/dist/storage/repositories/index-vec-repository.js +6 -1
  346. package/dist/storage/repositories/outcome-repository.js +119 -0
  347. package/dist/storage/repositories/proposals-repository.js +296 -59
  348. package/dist/storage/repositories/registry-cache.js +19 -0
  349. package/dist/storage/repositories/salience-repository.js +172 -0
  350. package/dist/storage/repositories/task-history-repository.js +15 -13
  351. package/dist/storage/repositories/workflow-runs-repository.js +52 -40
  352. package/dist/tasks/backends/cron.js +105 -15
  353. package/dist/tasks/backends/index.js +1 -1
  354. package/dist/tasks/backends/launchd.js +85 -38
  355. package/dist/tasks/backends/schtasks.js +135 -15
  356. package/dist/tasks/embedded.js +56 -40
  357. package/dist/tasks/parser.js +7 -157
  358. package/dist/tasks/resolve-akm-bin.js +137 -59
  359. package/dist/tasks/runner.js +79 -42
  360. package/dist/tasks/scheduler-invocation.js +220 -10
  361. package/dist/tasks/schema.js +24 -1
  362. package/dist/tasks/task-id.js +1 -3
  363. package/dist/tasks/validator.js +20 -6
  364. package/dist/workflows/authoring/authoring.js +94 -143
  365. package/dist/workflows/authoring/scope-key.js +1 -1
  366. package/dist/workflows/exec/frozen-judge.js +28 -2
  367. package/dist/workflows/exec/native-executor.js +77 -57
  368. package/dist/workflows/exec/param-secrets.js +9 -9
  369. package/dist/workflows/exec/run-workflow.js +133 -79
  370. package/dist/workflows/exec/step-work.js +219 -346
  371. package/dist/{migrate-storage-node.mjs → workflows/exec/unit-dispatch.js} +1 -5
  372. package/dist/workflows/ir/compile.js +141 -270
  373. package/dist/workflows/ir/freeze.js +40 -30
  374. package/dist/workflows/ir/params.js +135 -11
  375. package/dist/workflows/ir/plan-hash.js +1 -1
  376. package/dist/workflows/ir/schema.js +25 -26
  377. package/dist/workflows/parser.js +872 -307
  378. package/dist/workflows/program/expressions.js +20 -208
  379. package/dist/workflows/program/schema.js +7 -10
  380. package/dist/workflows/renderer.js +95 -68
  381. package/dist/workflows/resource-limits.js +2 -0
  382. package/dist/workflows/runtime/checkin.js +3 -3
  383. package/dist/workflows/runtime/plan-classifier.js +16 -75
  384. package/dist/workflows/runtime/runs.js +186 -127
  385. package/dist/workflows/runtime/unit-checkin.js +1 -1
  386. package/dist/workflows/runtime/unit-phases.js +2 -2
  387. package/dist/workflows/runtime/workflow-asset-loader.js +232 -83
  388. package/dist/workflows/schema.js +1 -11
  389. package/dist/workflows/validate-summary.js +30 -36
  390. package/dist/workflows/validator.js +21 -62
  391. package/docs/README.md +68 -0
  392. package/docs/migration/README.md +8 -0
  393. package/docs/migration/release-notes/0.7.0.md +11 -11
  394. package/docs/migration/release-notes/0.9.0.md +208 -27
  395. package/docs/migration/v0.7-to-v0.8.md +46 -47
  396. package/docs/migration/v0.8-to-v0.9.md +564 -208
  397. package/docs/migration/v0.9.0-troubleshooting.md +561 -0
  398. package/docs/reference/README.md +12 -0
  399. package/docs/reference/cli.md +2253 -0
  400. package/docs/reference/configuration.md +358 -0
  401. package/docs/reference/data-and-telemetry.md +105 -42
  402. package/docs/reference/workflows.md +647 -0
  403. package/package.json +22 -11
  404. package/schemas/akm-asset-envelope.json +93 -0
  405. package/schemas/akm-config.json +81 -128
  406. package/schemas/akm-workflow.json +74 -73
  407. package/dist/assets/tasks/core/backup.yml +0 -5
  408. package/dist/assets/tasks/graph-refresh-weekly.yml +0 -10
  409. package/dist/cli/config-migrate.js +0 -1878
  410. package/dist/cli/config-validate.js +0 -41
  411. package/dist/commands/backup-cli.js +0 -56
  412. package/dist/commands/bundle/bundle-cli.js +0 -68
  413. package/dist/commands/bundle/bundle.js +0 -219
  414. package/dist/commands/graph/graph-cli.js +0 -124
  415. package/dist/commands/graph/graph.js +0 -489
  416. package/dist/commands/improve/extract-watch.js +0 -140
  417. package/dist/commands/mv-cli.js +0 -1221
  418. package/dist/commands/sources/history.js +0 -201
  419. package/dist/commands/tasks/default-tasks.js +0 -186
  420. package/dist/core/migration-backup.js +0 -1231
  421. package/dist/indexer/usage/unmigrated-vaults-guard.js +0 -95
  422. package/dist/llm/memory-infer-impl.js +0 -138
  423. package/dist/migrate/legacy/config-source-migration.js +0 -257
  424. package/dist/migrate/legacy/content-migration.js +0 -350
  425. package/dist/migrate/legacy/legacy-layout.js +0 -779
  426. package/dist/migrate/legacy/legacy-paths.js +0 -25
  427. package/dist/migrate/legacy/legacy-stash-json.js +0 -80
  428. package/dist/migrate/legacy/proposal-fs-import.js +0 -168
  429. package/dist/migrate/legacy/task-target-ref-migration.js +0 -278
  430. package/dist/migrate/legacy/three-db-cutover.js +0 -845
  431. package/dist/migrate/legacy/workflow-migrations-bodies.js +0 -52
  432. package/dist/migrate/legacy/workflow-migrations-frozen.js +0 -21
  433. package/dist/migrate/legacy-ref-grammar.js +0 -214
  434. package/dist/output/shapes/distill.js +0 -14
  435. package/dist/output/shapes/history.js +0 -11
  436. package/dist/output/text/distill.js +0 -6
  437. package/dist/output/text/enable-disable.js +0 -8
  438. package/dist/output/text/history.js +0 -6
  439. package/dist/registry/build-index.js +0 -382
  440. package/dist/schemas/akm-config.json +0 -4704
  441. package/dist/schemas/akm-task.json +0 -87
  442. package/dist/schemas/akm-workflow.json +0 -372
  443. package/dist/scripts/migrate-storage.js +0 -3816
  444. package/dist/workflows/authoring/workflow-program-template.yaml +0 -31
  445. package/dist/workflows/cli.js +0 -53
  446. package/dist/workflows/exec/brief.js +0 -481
  447. package/dist/workflows/exec/report.js +0 -1460
  448. package/dist/workflows/exec/watch.js +0 -116
  449. package/dist/workflows/program/parser.js +0 -813
  450. package/dist/workflows/program/project.js +0 -104
@@ -2,17 +2,19 @@
2
2
  // License, v. 2.0. If a copy of the MPL was not distributed with this
3
3
  // file, You can obtain one at https://mozilla.org/MPL/2.0/.
4
4
  import { createHash } from "node:crypto";
5
- import { lookup as dnsLookup } from "node:dns/promises";
6
5
  import fs from "node:fs";
7
- import { isIP } from "node:net";
8
6
  import path from "node:path";
9
- import { fetchWithRetry, ResponseTooLargeError, readBodyWithByteCap, resolveStashDir } from "../../core/common.js";
7
+ import { fetchWithRetry, isWithin, ResponseTooLargeError, readBodyWithByteCap, resolveStashDir, todayIso, } from "../../core/common.js";
10
8
  import { ConfigError, UsageError } from "../../core/errors.js";
11
9
  import { getRegistryIndexCacheDir } from "../../core/paths.js";
12
- import { warn } from "../../core/warn.js";
10
+ import { warn, warnVerbose } from "../../core/warn.js";
13
11
  import { withFreshnessCache } from "../freshness.js";
14
12
  import { sanitizeString } from "../providers/provider-utils.js";
13
+ import { htmlToMarkdownAndLinks } from "./content-extract.js";
14
+ import { escapeMarkdownStructure } from "./fetcher-util.js";
15
+ import { assertResolvedHostAllowed, assertWebsiteRequestUrl, isLoopbackWebsiteHostname, } from "./host-guard.js";
15
16
  import { loadWikiSnapshotFetchers } from "./registry.js";
17
+ import { createAllowAllRobotsPolicy, createRobotsPolicy, isPathAllowedByRobots, ROBOTS_BODY_TIMEOUT_MS, ROBOTS_BYTE_CAP, } from "./robots.js";
16
18
  /** Refresh website snapshots every 12 hours to balance freshness with scraping load. */
17
19
  const CACHE_REFRESH_INTERVAL_MS = 12 * 60 * 60 * 1000;
18
20
  /** Allow up to 7 days of stale snapshots when refresh fails so search remains available during outages. */
@@ -35,6 +37,32 @@ const WEBSITE_PAGE_BYTE_CAP = 5 * 1024 * 1024;
35
37
  */
36
38
  const WEBSITE_CRAWL_WALL_CLOCK_MS = 10 * 60 * 1000;
37
39
  const WEBSITE_MAX_REDIRECTS = 8;
40
+ /**
41
+ * Coerces the user-facing `crawlTimeoutMs` option.
42
+ *
43
+ * Returns `null` for an explicit opt-out (`false`, or `0`), the configured
44
+ * number of milliseconds when positive, and `undefined` to mean "unset, use
45
+ * the default". Anything else is ignored rather than failing a crawl over a
46
+ * malformed knob.
47
+ */
48
+ function coerceCrawlTimeoutMs(value) {
49
+ if (value === false || value === 0)
50
+ return null;
51
+ if (value === true || value === undefined || value === null)
52
+ return undefined;
53
+ const parsed = typeof value === "number" ? value : typeof value === "string" ? Number.parseInt(value, 10) : Number.NaN;
54
+ if (!Number.isFinite(parsed))
55
+ return undefined;
56
+ if (parsed <= 0)
57
+ return null;
58
+ return parsed;
59
+ }
60
+ /**
61
+ * How many times a URL may be pushed back for not fitting its origin's
62
+ * `Crawl-delay` in the remaining budget before it is reported unfetched.
63
+ * Bounds the requeue loop when every remaining URL is rate-limited.
64
+ */
65
+ const MAX_CRAWL_DEFERRALS = 3;
38
66
  /**
39
67
  * Body-read deadline for a single page (30s). The per-request fetch timeout
40
68
  * (15s) bounds only the connection/header phase; without this a server that
@@ -59,7 +87,7 @@ function resolveFetcherStashDir(explicitStashDir) {
59
87
  if (explicitStashDir)
60
88
  return explicitStashDir;
61
89
  try {
62
- return resolveStashDir({ readOnly: true });
90
+ return resolveStashDir();
63
91
  }
64
92
  catch {
65
93
  return null;
@@ -88,9 +116,19 @@ export async function ensureWebsiteMirror(config, options) {
88
116
  refresh: async () => {
89
117
  fs.mkdirSync(cachePaths.rootDir, { recursive: true });
90
118
  await scrapeWebsiteToStash(normalizedUrl, cachePaths.stashDir, {
119
+ fetcherStashDir: resolveFetcherStashDir(),
91
120
  maxPages: coercePositiveInt(config.options?.maxPages, MAX_PAGES_DEFAULT),
92
121
  maxDepth: coercePositiveInt(config.options?.maxDepth, MAX_DEPTH_DEFAULT),
122
+ respectRobots: coerceRespectRobots(config.options?.respectRobots),
93
123
  allowPrivateHosts: options?.allowPrivateHosts,
124
+ wallClockCapMs: options?.wallClockCapMs,
125
+ crawlTimeoutMs: coerceCrawlTimeoutMs(config.options?.crawlTimeoutMs),
126
+ resolveSecret: options?.resolveSecret,
127
+ // As-supplied, pre-normalization start URL (see crawlWebsite's
128
+ // `rawStartUrl` doc comment): threaded through purely for the C-02
129
+ // robots.txt check, which must see the trailing slash the user
130
+ // actually typed before `normalizeSiteUrl` strips it.
131
+ rawStartUrl: rawUrl,
94
132
  });
95
133
  fs.writeFileSync(cachePaths.manifestPath, `${JSON.stringify({ url: normalizedUrl, fetchedAt: new Date().toISOString() }, null, 2)}\n`, { encoding: "utf8", mode: 0o600 });
96
134
  },
@@ -102,13 +140,16 @@ function hasExtractedSite(stashDir) {
102
140
  const knowledgeDir = path.join(stashDir, "knowledge");
103
141
  if (!fs.statSync(stashDir).isDirectory() || !fs.statSync(knowledgeDir).isDirectory())
104
142
  return false;
105
- for (const entry of fs.readdirSync(knowledgeDir, { withFileTypes: true })) {
106
- if (entry.isFile() && entry.name.endsWith(".md"))
107
- return true;
108
- if (entry.isDirectory()) {
109
- const subEntries = fs.readdirSync(path.join(knowledgeDir, entry.name));
110
- if (subEntries.some((e) => e.endsWith(".md")))
143
+ const pending = [knowledgeDir];
144
+ while (pending.length > 0) {
145
+ const dir = pending.pop();
146
+ if (!dir)
147
+ break;
148
+ for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
149
+ if (entry.isFile() && entry.name.endsWith(".md"))
111
150
  return true;
151
+ if (entry.isDirectory())
152
+ pending.push(path.join(dir, entry.name));
112
153
  }
113
154
  }
114
155
  return false;
@@ -117,7 +158,73 @@ function hasExtractedSite(stashDir) {
117
158
  return false;
118
159
  }
119
160
  }
161
+ /**
162
+ * Iterate the snapshot-fetcher registry against a parsed URL, returning the
163
+ * first fetcher that produces content, or null when none match. A fetcher that
164
+ * throws is logged and treated as a non-match — one broken fetcher must not
165
+ * fail the whole source.
166
+ */
167
+ async function dispatchSnapshotFetchers(parsed, context, stashDir) {
168
+ for (const fetcher of await loadWikiSnapshotFetchers(stashDir)) {
169
+ try {
170
+ if (!fetcher.matches(parsed, context))
171
+ continue;
172
+ const snapshot = await fetcher.fetch(parsed, context);
173
+ if (snapshot)
174
+ return snapshot;
175
+ }
176
+ catch (error) {
177
+ if (context.signal?.aborted)
178
+ throw error;
179
+ warn("[akm] snapshot fetcher %s threw on %s: %s", fetcher.name, parsed.toString(), error instanceof Error ? error.message : String(error));
180
+ }
181
+ }
182
+ return null;
183
+ }
184
+ /**
185
+ * Run the snapshot-fetcher registry against a URL. Returns null when no
186
+ * fetcher matches or produces content, so the caller falls back to a crawl.
187
+ */
188
+ async function fetchSnapshotViaRegistry(startUrl, stashDir, allowPrivateHosts, resolveSecret) {
189
+ let parsed;
190
+ try {
191
+ parsed = new URL(startUrl);
192
+ }
193
+ catch {
194
+ return null;
195
+ }
196
+ const context = {
197
+ stashDir: stashDir ?? "",
198
+ timeoutMs: 15_000,
199
+ ...(resolveSecret ? { resolveSecret } : {}),
200
+ ...(allowPrivateHosts ? { allowPrivateHosts: true } : {}),
201
+ };
202
+ return dispatchSnapshotFetchers(parsed, context, stashDir);
203
+ }
204
+ /** Materialize a single fetcher snapshot as the source's whole stash. */
205
+ function writeSnapshotToStash(stashDir, snapshot) {
206
+ const preferredName = snapshot.preferredName ?? deriveImportPath(snapshot.url);
207
+ const relPath = avoidReservedBasename(preferredName);
208
+ const knowledgeDir = path.join(stashDir, "knowledge");
209
+ const filePath = path.resolve(knowledgeDir, `${relPath}.md`);
210
+ if (!isWithin(filePath, knowledgeDir)) {
211
+ throw new UsageError(`Snapshot fetcher returned an unsafe preferred name: ${JSON.stringify(preferredName)}`);
212
+ }
213
+ fs.rmSync(stashDir, { recursive: true, force: true });
214
+ fs.mkdirSync(path.dirname(filePath), { recursive: true });
215
+ const slug = relPath.split("/").pop() ?? "index";
216
+ fs.writeFileSync(filePath, buildMarkdownSnapshot({ url: snapshot.url, title: snapshot.title, markdown: snapshot.markdown }, slug, snapshot.tags), "utf8");
217
+ }
120
218
  async function scrapeWebsiteToStash(startUrl, stashDir, options) {
219
+ // Offer the URL to the specialized fetchers before falling back to a crawl.
220
+ // Without this, `akm bundle add <feed|profile URL>` reaches only the generic
221
+ // crawler and the RSS/Bluesky/X/YouTube fetchers are unreachable outside the
222
+ // `akm import` path. A fetcher returning null falls through to the crawl.
223
+ const fetched = await fetchSnapshotViaRegistry(startUrl, options.fetcherStashDir ?? null, options.allowPrivateHosts, options.resolveSecret);
224
+ if (fetched) {
225
+ writeSnapshotToStash(stashDir, fetched);
226
+ return;
227
+ }
121
228
  const pages = await crawlWebsite(startUrl, options);
122
229
  if (pages.length === 0) {
123
230
  throw new Error(`No content could be scraped from ${startUrl}`);
@@ -145,24 +252,37 @@ export async function fetchWebsiteMarkdownSnapshot(rawUrl, options) {
145
252
  stashDir: stashDir ?? "",
146
253
  timeoutMs: options?.timeoutMs ?? 15_000,
147
254
  signal: options?.signal,
255
+ ...(options?.resolveSecret ? { resolveSecret: options.resolveSecret } : {}),
256
+ ...(options?.allowPrivateHosts ? { allowPrivateHosts: true } : {}),
148
257
  };
149
- for (const fetcher of await loadWikiSnapshotFetchers(stashDir)) {
258
+ const snapshot = await dispatchSnapshotFetchers(parsedUrl, context, stashDir);
259
+ if (snapshot)
260
+ return websiteMarkdownSnapshotFromResult(snapshot);
261
+ const fetchedResponse = await fetchWebsiteResponse(normalizedUrl, 0, {
262
+ allowPrivateHosts: options?.allowPrivateHosts,
263
+ signal: options?.signal,
264
+ });
265
+ const finalUrl = normalizeCrawlUrl(fetchedResponse.finalUrl) ?? normalizedUrl;
266
+ if (finalUrl !== normalizedUrl) {
267
+ let redirectedSnapshot;
150
268
  try {
151
- if (!fetcher.matches(parsedUrl, context))
152
- continue;
153
- const snapshot = await fetcher.fetch(parsedUrl, context);
154
- if (!snapshot)
155
- continue;
156
- return websiteMarkdownSnapshotFromResult(snapshot);
269
+ redirectedSnapshot = await dispatchSnapshotFetchers(new URL(finalUrl), context, stashDir);
157
270
  }
158
271
  catch (error) {
159
- warn("[akm] wiki-fetcher %s threw on %s: %s", fetcher.name, normalizedUrl, error instanceof Error ? error.message : String(error));
272
+ await fetchedResponse.response.body?.cancel().catch(() => undefined);
273
+ throw error;
274
+ }
275
+ if (redirectedSnapshot) {
276
+ await fetchedResponse.response.body?.cancel().catch(() => undefined);
277
+ return websiteMarkdownSnapshotFromResult(redirectedSnapshot);
160
278
  }
161
279
  }
162
- const fetched = await fetchWebsitePage(normalizedUrl, { allowPrivateHosts: options?.allowPrivateHosts });
163
- if (!fetched) {
280
+ const fetched = await websitePageFromResponse(fetchedResponse, normalizedUrl, {
281
+ allowPrivateHosts: options?.allowPrivateHosts,
282
+ signal: options?.signal,
283
+ });
284
+ if (!fetched)
164
285
  throw new UsageError(`No content could be fetched from ${normalizedUrl}`);
165
- }
166
286
  return websiteMarkdownSnapshotFromResult({
167
287
  url: fetched.page.url,
168
288
  title: fetched.page.title,
@@ -184,13 +304,168 @@ function websiteMarkdownSnapshotFromResult(snapshot) {
184
304
  }, slug || "website", snapshot.tags),
185
305
  };
186
306
  }
307
+ function sleep(ms) {
308
+ return new Promise((resolve) => setTimeout(resolve, ms));
309
+ }
310
+ /**
311
+ * Evaluates a URL against robots.txt, checking both `normalizedUrl` — the
312
+ * form akm treats as canonical for dedup/storage, with any bare trailing
313
+ * slash already stripped by `normalizeCrawlUrl`/`normalizeSiteUrl` — and,
314
+ * when it differs, `rawUrl`: the URL exactly as discovered (a link's literal
315
+ * `href`), as-supplied (the user's typed start URL), or redirected-to (a
316
+ * `Location` header), before any such stripping. Delegates the actual
317
+ * allow/disallow decision to {@link decideRobotsAllowance}'s asymmetric
318
+ * matrix — see its doc comment — rather than a plain AND of both forms: a
319
+ * `Disallow: /dir/`-shaped rule needs the un-stripped `rawUrl` to ever match
320
+ * (closing that gap), while an `Allow: /docs/`-shaped rule needs it too, in
321
+ * the *other* direction (a normalized-disallowed-but-raw-allowed URL must
322
+ * still be treated as allowed here, not just at the point where akm chooses
323
+ * which literal URL to fetch — a URL reaching this function has already been
324
+ * fetched, under whichever form `crawlWebsite`/`resolveCrawlRobotsDecision`
325
+ * selected, so only the `allowed` verdict matters here, never `fetchUrl`).
326
+ *
327
+ * `normalizeCrawlUrl`/`normalizeSiteUrl` strip a bare trailing slash before
328
+ * any robots check ever runs, so a `Disallow: /dir/`-shaped rule — which
329
+ * requires a literal trailing `/` in the target, see
330
+ * `matchesCompiledPattern`'s prefix check — can never match the stripped
331
+ * alias. Checking the un-stripped `rawUrl` closes that gap without changing
332
+ * what akm treats as the canonical URL for storage/dedup, and (crucially)
333
+ * without over-blocking a URL that never had a trailing slash to begin with
334
+ * — e.g. a `/secret` link that happens to redirect to `/secret/`: the
335
+ * pre-redirect request itself is unaffected by a `Disallow: /secret/` rule,
336
+ * only the redirect target is.
337
+ */
338
+ async function isCrawlUrlAllowedByRobots(robots, normalizedUrl, rawUrl) {
339
+ return (await resolveCrawlRobotsDecision(robots, normalizedUrl, rawUrl)).allowed;
340
+ }
341
+ /**
342
+ * Decides whether `normalizedUrl` (akm's canonical form — dedup/cache key,
343
+ * with any bare trailing slash already stripped by `normalizeCrawlUrl`/
344
+ * `normalizeSiteUrl`) may be crawled, and which literal URL to actually
345
+ * request.
346
+ *
347
+ * A `Disallow: /dir/`-shaped rule requires a literal trailing `/` in the
348
+ * target (see `matchesCompiledPattern`'s prefix check), so it can never match
349
+ * the slash-stripped normalized alias — checking the un-stripped `rawUrl` (a
350
+ * link's literal `href`, the user's as-typed start URL, or a redirect
351
+ * `Location`) closes that gap. Symmetrically, an `Allow: /docs/`-shaped rule
352
+ * requires that same trailing `/` to match, so a start URL or link typed as
353
+ * `.../docs/` under `Disallow: / \n Allow: /docs/` is allowed in its raw form
354
+ * but disallowed once normalized — over-blocking a site the owner explicitly
355
+ * opened to crawlers.
356
+ *
357
+ * Resolution matrix (raw form only consulted when it differs from normalized):
358
+ * - normalized allowed, raw allowed (or no distinct raw) => allowed, fetch normalized
359
+ * - normalized allowed, raw disallowed => BLOCKED (raw wins: closes the Disallow: /dir/ gap)
360
+ * - normalized disallowed, raw allowed => allowed, fetch the RAW url (closes the Allow: /docs/ gap)
361
+ * - normalized disallowed, raw disallowed => BLOCKED
362
+ *
363
+ * Only the third row switches the fetch target; every other row fetches the
364
+ * normalized form akm already treats as canonical. Do not collapse this to a
365
+ * plain OR of the two checks — that would also flip the second row to
366
+ * "allowed" and reopen the `Disallow: /dir/` bypass this matrix exists to
367
+ * close.
368
+ */
369
+ function decideRobotsAllowance(rules, normalizedUrl, rawUrl) {
370
+ const normalizedAllowed = isPathAllowedByRobots(rules, normalizedUrl);
371
+ if (!rawUrl || rawUrl === normalizedUrl) {
372
+ return { allowed: normalizedAllowed, fetchUrl: normalizedUrl };
373
+ }
374
+ const rawAllowed = isPathAllowedByRobots(rules, rawUrl);
375
+ if (!normalizedAllowed && rawAllowed) {
376
+ return { allowed: true, fetchUrl: rawUrl };
377
+ }
378
+ return { allowed: normalizedAllowed && rawAllowed, fetchUrl: normalizedUrl };
379
+ }
380
+ /**
381
+ * Async wrapper of {@link decideRobotsAllowance} for call sites holding a
382
+ * `RobotsPolicy` (which resolves/caches rules per origin) rather than an
383
+ * already-fetched `RobotsRuleSet`.
384
+ */
385
+ async function resolveCrawlRobotsDecision(robots, normalizedUrl, rawUrl) {
386
+ const rules = await robots.rulesFor(normalizedUrl);
387
+ return decideRobotsAllowance(rules, normalizedUrl, rawUrl);
388
+ }
389
+ /**
390
+ * C-02/C-03: fail fast, before any page fetch, when the start URL itself is
391
+ * off-limits. A 5xx robots.txt (RobotsPolicy caches `DISALLOW_ALL_RULES` for
392
+ * that case) gets a distinct message calling out the server error, per spec
393
+ * §4.6.
394
+ *
395
+ * Checks both `start`'s (normalized) URL and `rawStartUrl` — the URL exactly
396
+ * as the user supplied it in config, before `validateWebsiteUrl` ->
397
+ * `normalizeSiteUrl` stripped any trailing slash — via
398
+ * `decideRobotsAllowance`. Without this, a start URL typed as `.../secret/`
399
+ * under `Disallow: /secret/` would never match that rule and would be
400
+ * crawled instead of rejected with the spec §4.6 C-02 UsageError; conversely,
401
+ * a start URL typed as `.../docs/` under `Disallow: / \n Allow: /docs/`
402
+ * would be normalized to `.../docs`, fail to match `Allow: /docs/`, and be
403
+ * rejected even though the site owner explicitly opened `/docs/` to
404
+ * crawlers. `crawlWebsite`'s queue gate applies the same decision (and, in
405
+ * the Allow case, actually fetches the raw URL this function only validates
406
+ * against) — see its call to `resolveCrawlRobotsDecision`.
407
+ */
408
+ async function assertStartUrlAllowedByRobots(robots, start, rawStartUrl) {
409
+ const startUrl = start.toString();
410
+ const robotsUrl = new URL("/robots.txt", start.origin).toString();
411
+ const rules = await robots.rulesFor(startUrl);
412
+ if (rules.disallowAll) {
413
+ throw new UsageError(`Refusing to crawl ${startUrl}: ${robotsUrl} returned a server error, which robots.txt conventions ` +
414
+ `treat as a full disallow until it recovers. Set respectRobots: false on this website source to bypass ` +
415
+ `robots.txt.`);
416
+ }
417
+ let rawStartUrlNormalized;
418
+ if (rawStartUrl) {
419
+ try {
420
+ rawStartUrlNormalized = new URL(rawStartUrl).toString();
421
+ }
422
+ catch {
423
+ rawStartUrlNormalized = undefined;
424
+ }
425
+ }
426
+ const { allowed } = decideRobotsAllowance(rules, startUrl, rawStartUrlNormalized);
427
+ if (!allowed) {
428
+ throw new UsageError(`Refusing to crawl ${startUrl}: disallowed by ${robotsUrl}. Set respectRobots: false on this website ` +
429
+ `source to bypass robots.txt.`);
430
+ }
431
+ }
187
432
  async function crawlWebsite(startUrl, options) {
188
433
  const start = new URL(normalizeSiteUrl(startUrl));
189
434
  const allowedOrigin = start.origin;
190
- const queue = [{ url: start.toString(), depth: 0 }];
435
+ const queue = [
436
+ { url: start.toString(), rawUrl: options.rawStartUrl ?? start.toString(), depth: 0, deferrals: 0 },
437
+ ];
191
438
  const visited = new Set();
192
439
  const pages = [];
193
- const deadline = Date.now() + WEBSITE_CRAWL_WALL_CLOCK_MS;
440
+ // Precedence: the test-only seam, then the user's `crawlTimeoutMs`, then the
441
+ // default. `crawlTimeoutMs: 0` / `false` disables the cap outright, for a
442
+ // deliberately long-running crawl the user is willing to babysit.
443
+ const configuredCapMs = options.crawlTimeoutMs === null ? null : (options.crawlTimeoutMs ?? WEBSITE_CRAWL_WALL_CLOCK_MS);
444
+ const wallClockCapMs = options.wallClockCapMs ?? configuredCapMs;
445
+ const capDisabled = wallClockCapMs === null;
446
+ const deadline = capDisabled ? Number.POSITIVE_INFINITY : Date.now() + wallClockCapMs;
447
+ // Between-iteration deadline checks cannot interrupt work already in
448
+ // flight: a single request's `Retry-After` sleep, or a slow body read, can
449
+ // run far past the cap on its own. This signal makes the cap a HARD limit —
450
+ // it aborts the in-flight fetch and the retry sleep alike.
451
+ const abortController = new AbortController();
452
+ const capTimer = capDisabled
453
+ ? undefined
454
+ : setTimeout(() => abortController.abort(new Error(`Website crawl exceeded its ${wallClockCapMs / 1000}s limit`)), wallClockCapMs);
455
+ const crawlSignal = abortController.signal;
456
+ const robots = options.respectRobots === false
457
+ ? createAllowAllRobotsPolicy()
458
+ : createRobotsPolicy((robotsUrl) => loadRobotsTxt(robotsUrl, { allowPrivateHosts: options.allowPrivateHosts, signal: crawlSignal }));
459
+ await assertStartUrlAllowedByRobots(robots, start, options.rawStartUrl);
460
+ // Counts actual `fetchWebsitePage` invocations (regardless of outcome) so
461
+ // Crawl-delay pacing skips the first fetch and never charges a delay slot
462
+ // to a URL that robots.txt skipped without ever being fetched (C-11).
463
+ let fetchAttempts = 0;
464
+ // URLs pushed back because their Crawl-delay would not fit in the remaining
465
+ // budget, and URLs that ran out of retries entirely. Reported at the end so
466
+ // a rate-limited origin is visible rather than silently missing.
467
+ const deferred = new Set();
468
+ const unfetched = new Set();
194
469
  while (queue.length > 0 && pages.length < options.maxPages) {
195
470
  if (Date.now() > deadline)
196
471
  break;
@@ -200,8 +475,49 @@ async function crawlWebsite(startUrl, options) {
200
475
  const normalized = normalizeCrawlUrl(next.url);
201
476
  if (!normalized || visited.has(normalized))
202
477
  continue;
478
+ const decision = await resolveCrawlRobotsDecision(robots, normalized, next.rawUrl);
479
+ if (!decision.allowed) {
480
+ // Deliberately NOT marked visited. `/docs/` and `/docs` share a
481
+ // normalized key but get different robots verdicts (a `Disallow: /docs/`
482
+ // rule matches only the trailing-slash form). Marking the key visited
483
+ // here would let whichever alias happened to be discovered first — and
484
+ // was then rejected — permanently suppress the allowed alias, making
485
+ // crawl coverage depend on link order. Robots rules are cached per
486
+ // origin, so re-evaluating a repeated disallowed alias is cheap.
487
+ warnVerbose("[akm] website crawl: skipping %s (disallowed by robots.txt)", normalized);
488
+ continue;
489
+ }
490
+ if (fetchAttempts > 0) {
491
+ const delayMs = await robots.crawlDelayMs(normalized);
492
+ if (delayMs > 0) {
493
+ if (Date.now() + delayMs >= deadline) {
494
+ // Sleeping this one out would blow the wall-clock cap. Defer it to
495
+ // the back of the queue instead of ending the crawl here: other
496
+ // origins may have no Crawl-delay and can still be fetched with the
497
+ // time that remains. Anything still deferred when the deadline
498
+ // arrives is reported as unfetched below rather than silently
499
+ // dropped. `deferred` is bounded so a queue of delayed URLs cannot
500
+ // spin forever re-appending to itself.
501
+ if (next.deferrals < MAX_CRAWL_DEFERRALS) {
502
+ queue.push({ ...next, deferrals: next.deferrals + 1 });
503
+ deferred.add(normalized);
504
+ }
505
+ else {
506
+ unfetched.add(normalized);
507
+ }
508
+ continue;
509
+ }
510
+ await sleep(delayMs);
511
+ }
512
+ }
203
513
  visited.add(normalized);
204
- const fetched = await fetchWebsitePage(normalized, { allowPrivateHosts: options.allowPrivateHosts });
514
+ deferred.delete(normalized);
515
+ fetchAttempts++;
516
+ const fetched = await fetchWebsitePage(decision.fetchUrl, {
517
+ allowPrivateHosts: options.allowPrivateHosts,
518
+ robots,
519
+ signal: crawlSignal,
520
+ });
205
521
  if (!fetched)
206
522
  continue;
207
523
  pages.push(fetched.page);
@@ -212,20 +528,59 @@ async function crawlWebsite(startUrl, options) {
212
528
  break;
213
529
  if (link.origin !== allowedOrigin)
214
530
  continue;
215
- const candidate = normalizeCrawlUrl(link.toString());
531
+ const rawLinkUrl = link.toString();
532
+ const candidate = normalizeCrawlUrl(rawLinkUrl);
216
533
  if (!candidate || visited.has(candidate) || isAssetLikePath(link.pathname))
217
534
  continue;
218
- queue.push({ url: candidate, depth: next.depth + 1 });
535
+ queue.push({ url: candidate, rawUrl: rawLinkUrl, depth: next.depth + 1, deferrals: 0 });
219
536
  }
220
537
  }
221
- if (Date.now() > deadline) {
222
- warn("[akm] website crawl stopped at the %ds wall-clock cap with %d/%d pages collected from %s.", WEBSITE_CRAWL_WALL_CLOCK_MS / 1000, pages.length, options.maxPages, startUrl);
538
+ if (!capDisabled && Date.now() > deadline) {
539
+ warn("[akm] website crawl stopped at the %ds wall-clock cap with %d/%d pages collected from %s. " +
540
+ "Raise crawlTimeoutMs on this website source, or set it to 0 to disable the cap.", wallClockCapMs / 1000, pages.length, options.maxPages, startUrl);
541
+ }
542
+ if (capTimer)
543
+ clearTimeout(capTimer);
544
+ // A URL still deferred when the loop ends never got fetched — report it
545
+ // rather than letting a rate-limited origin go missing without a trace.
546
+ for (const url of deferred)
547
+ unfetched.add(url);
548
+ if (unfetched.size > 0) {
549
+ warn("[akm] website crawl: %d URL(s) were not fetched — their origin's Crawl-delay did not fit in the " +
550
+ "remaining time budget. First: %s", unfetched.size, [...unfetched].slice(0, 3).join(", "));
223
551
  }
224
552
  return pages;
225
553
  }
554
+ /**
555
+ * Sentinel thrown by `fetchWebsiteResponse` when a redirect hop's target is
556
+ * disallowed by robots.txt (see the doc comment above the check in
557
+ * `fetchWebsiteResponse`). Never escapes `fetchWebsitePage`, which maps it to
558
+ * `null` — the same "page skipped, no error" outcome as any other
559
+ * robots-disallowed URL. Not exported; purely an internal control-flow
560
+ * signal between the two functions.
561
+ */
562
+ class RobotsDisallowedRedirectError extends Error {
563
+ constructor(url) {
564
+ super(`robots.txt disallows redirect target ${url}`);
565
+ this.name = "RobotsDisallowedRedirectError";
566
+ }
567
+ }
226
568
  async function fetchWebsitePage(pageUrl, options) {
227
- const response = await fetchWebsiteResponse(pageUrl, 0, options);
569
+ let fetchedResponse;
570
+ try {
571
+ fetchedResponse = await fetchWebsiteResponse(pageUrl, 0, options);
572
+ }
573
+ catch (err) {
574
+ if (err instanceof RobotsDisallowedRedirectError)
575
+ return null;
576
+ throw err;
577
+ }
578
+ return websitePageFromResponse(fetchedResponse, pageUrl, options);
579
+ }
580
+ async function websitePageFromResponse(fetched, pageUrl, options) {
581
+ const { response } = fetched;
228
582
  if (!response.ok) {
583
+ await response.body?.cancel().catch(() => undefined);
229
584
  if (response.status === 404)
230
585
  return null;
231
586
  throw new Error(`Failed to fetch website content (${response.status}) from ${pageUrl}`);
@@ -235,6 +590,7 @@ async function fetchWebsitePage(pageUrl, options) {
235
590
  try {
236
591
  body = await readBodyWithByteCap(response, WEBSITE_PAGE_BYTE_CAP, {
237
592
  bodyTimeoutMs: WEBSITE_PAGE_BODY_TIMEOUT_MS,
593
+ signal: options?.signal,
238
594
  });
239
595
  }
240
596
  catch (err) {
@@ -242,24 +598,40 @@ async function fetchWebsitePage(pageUrl, options) {
242
598
  return null;
243
599
  throw err;
244
600
  }
245
- const finalUrl = normalizeCrawlUrl(response.url || pageUrl) ?? pageUrl;
601
+ const rawFinalUrl = fetched.finalUrl;
602
+ const finalUrl = normalizeCrawlUrl(rawFinalUrl) ?? pageUrl;
246
603
  assertWebsiteRequestUrl(finalUrl, Error, options);
247
- if (contentType.includes("text/html") || contentType.includes("application/xhtml+xml") || looksLikeMarkup(body)) {
604
+ // Re-check robots.txt against the FINAL (post-redirect) URL, not just the
605
+ // pre-redirect URL crawlWebsite already gated. normalizeCrawlUrl strips
606
+ // trailing slashes before the initial gate, so a rule shaped like
607
+ // `Disallow: /secret/` correctly lets `/secret` through that gate; if the
608
+ // server then redirects to `/secret/` (a common trailing-slash
609
+ // canonicalization), the disallowed page would otherwise be fetched and
610
+ // stored without ever being weighed against robots.txt. See spec §4.6 C-04.
611
+ // Checks `rawFinalUrl` (the un-normalized `response.url`, slash intact) as
612
+ // well as `finalUrl`, per `isCrawlUrlAllowedByRobots` — `normalizeCrawlUrl`
613
+ // would otherwise strip the very trailing slash a `Disallow: /secret/`
614
+ // rule needs to match.
615
+ if (options?.robots && !(await isCrawlUrlAllowedByRobots(options.robots, finalUrl, rawFinalUrl))) {
616
+ warnVerbose("[akm] website crawl: skipping %s (disallowed by robots.txt after redirect)", finalUrl);
617
+ return null;
618
+ }
619
+ if (contentType.includes("text/html") ||
620
+ contentType.includes("application/xhtml+xml") ||
621
+ (!contentType && looksLikeMarkup(body))) {
248
622
  const title = extractHtmlTitle(body) || new URL(finalUrl).hostname;
623
+ // One parse yields both the content Markdown and the whole-document links.
624
+ const { markdown, links } = htmlToMarkdownAndLinks(body, finalUrl);
249
625
  return {
250
- page: {
251
- url: finalUrl,
252
- title,
253
- markdown: htmlToMarkdown(body, finalUrl),
254
- },
255
- links: extractSameDocumentLinks(body, finalUrl),
626
+ page: { url: finalUrl, title, markdown },
627
+ links,
256
628
  };
257
629
  }
258
630
  return {
259
631
  page: {
260
632
  url: finalUrl,
261
633
  title: extractTextTitle(body) || new URL(finalUrl).hostname,
262
- markdown: body.trim(),
634
+ markdown: plainTextToMarkdown(body),
263
635
  },
264
636
  links: [],
265
637
  };
@@ -277,25 +649,145 @@ async function fetchWebsiteResponse(pageUrl, redirectCount = 0, options) {
277
649
  "User-Agent": "akm-cli website provider",
278
650
  },
279
651
  redirect: "manual",
652
+ ...(options?.signal ? { signal: options.signal } : {}),
280
653
  }, { timeout: 15_000, retries: 1 });
281
654
  if (response.status >= 300 && response.status < 400) {
282
655
  if (redirectCount >= WEBSITE_MAX_REDIRECTS) {
656
+ await response.body?.cancel().catch(() => undefined);
283
657
  throw new Error(`Too many redirects while fetching ${pageUrl}`);
284
658
  }
285
659
  const location = response.headers.get("location");
286
660
  if (!location) {
661
+ await response.body?.cancel().catch(() => undefined);
287
662
  throw new Error(`Redirect response from ${pageUrl} did not include a Location header`);
288
663
  }
664
+ await response.body?.cancel().catch(() => undefined);
289
665
  const nextUrl = new URL(location, pageUrl).toString();
290
- assertWebsiteRequestUrl(nextUrl, Error, options);
666
+ try {
667
+ assertWebsiteRequestUrl(nextUrl, Error, options);
668
+ }
669
+ catch (error) {
670
+ if (options?.robots) {
671
+ warnVerbose("[akm] website crawl: skipping unsafe redirect to %s", nextUrl);
672
+ throw new RobotsDisallowedRedirectError(nextUrl);
673
+ }
674
+ throw error;
675
+ }
676
+ // Robots-check every intermediate redirect hop, not just the pre-redirect
677
+ // queue URL (`crawlWebsite`'s gate) and the FINAL URL (the post-redirect
678
+ // recheck below in `fetchWebsitePage`). Without this, a chain like
679
+ // `/go` -> 302 `/secret/` -> 302 `/public` issues a live GET to
680
+ // `/secret/` even when robots.txt disallows it, because that hop is
681
+ // never the queue URL and never the final URL. Only gated when a
682
+ // `RobotsPolicy` was actually threaded in (`crawlWebsite`); single-URL
683
+ // `fetchWebsiteMarkdownSnapshot` fetches stay deliberately ungated per
684
+ // spec §1.
685
+ if (options?.robots) {
686
+ const normalizedNext = normalizeCrawlUrl(nextUrl);
687
+ if (!normalizedNext) {
688
+ // `normalizeCrawlUrl` returns null for anything that isn't http(s) —
689
+ // a redirect `Location` can legally point at `mailto:`, `tel:`, a
690
+ // bare relative path that resolves to an opaque scheme, etc.
691
+ // `RobotsPolicy.rulesFor` computes `new URL(url).origin` and then
692
+ // resolves `/robots.txt` against it; for a non-http(s) URL that
693
+ // origin is the literal string "null", and re-resolving against it
694
+ // throws an unhandled TypeError that aborts the whole crawl. Refuse
695
+ // the hop outright instead of ever handing such a URL to the policy —
696
+ // `fetchWebsitePage` maps this to a graceful skip. Regression
697
+ // introduced by a67412c, which fell back to the raw `nextUrl` here.
698
+ warnVerbose("[akm] website crawl: skipping redirect to %s (not an http(s) URL)", nextUrl);
699
+ throw new RobotsDisallowedRedirectError(nextUrl);
700
+ }
701
+ if (!(await isCrawlUrlAllowedByRobots(options.robots, normalizedNext, nextUrl))) {
702
+ warnVerbose("[akm] website crawl: skipping redirect to %s (disallowed by robots.txt)", nextUrl);
703
+ throw new RobotsDisallowedRedirectError(nextUrl);
704
+ }
705
+ }
291
706
  return fetchWebsiteResponse(nextUrl, redirectCount + 1, options);
292
707
  }
293
- return response;
708
+ return { response, finalUrl: pageUrl };
709
+ }
710
+ /**
711
+ * Fetches and classifies `<origin>/robots.txt`. Reuses `fetchWebsiteResponse`
712
+ * (spec §6.2: no second fetch path) so robots.txt gets the exact same SSRF
713
+ * guards, retry, and redirect handling as a page fetch.
714
+ *
715
+ * Steps 1–2 (the guard on the INITIAL URL) run OUTSIDE the try/catch on
716
+ * purpose: a guard rejection there must propagate as-is, never be downgraded
717
+ * to "unavailable" (spec §4.5 F-12, §6.2). Guard rejections on a LATER
718
+ * redirect hop happen inside `fetchWebsiteResponse`, which the try/catch
719
+ * below does cover — the guard has already refused to fetch that host, so
720
+ * only the error *reporting* is downgraded (F-11).
721
+ */
722
+ export async function loadRobotsTxt(robotsUrl, options) {
723
+ assertWebsiteRequestUrl(robotsUrl, UsageError, options);
724
+ await assertResolvedHostAllowed(new URL(robotsUrl).hostname, options);
725
+ try {
726
+ const { response } = await fetchWebsiteResponse(robotsUrl, 0, options);
727
+ if (response.status >= 200 && response.status < 300) {
728
+ try {
729
+ const text = await readBodyWithByteCap(response, ROBOTS_BYTE_CAP, {
730
+ bodyTimeoutMs: ROBOTS_BODY_TIMEOUT_MS,
731
+ signal: options?.signal,
732
+ });
733
+ return { kind: "body", text };
734
+ }
735
+ catch (err) {
736
+ if (err instanceof ResponseTooLargeError) {
737
+ warn("[akm] robots.txt at %s exceeded the %d-byte cap; treating it as unavailable.", robotsUrl, ROBOTS_BYTE_CAP);
738
+ return { kind: "unavailable" };
739
+ }
740
+ throw err;
741
+ }
742
+ }
743
+ if (response.status >= 500 && response.status < 600) {
744
+ await response.body?.cancel().catch(() => undefined);
745
+ // RFC 9309 §2.3.1.4: an unreachable robots.txt is a full disallow, not
746
+ // an allow-all. `fetchWithRetry` already retried this once, so a
747
+ // transient blip does not trip it.
748
+ warn("[akm] robots.txt at %s returned %d; treating the crawl as fully disallowed until it recovers. " +
749
+ "Set respectRobots: false on this website source to bypass robots.txt.", robotsUrl, response.status);
750
+ return { kind: "unreachable" };
751
+ }
752
+ // 4xx (404 is the common, silent case), and any other non-2xx/5xx status.
753
+ await response.body?.cancel().catch(() => undefined);
754
+ return { kind: "unavailable" };
755
+ }
756
+ catch (err) {
757
+ if (options?.signal?.aborted)
758
+ throw err;
759
+ warnVerbose("[akm] failed to fetch robots.txt at %s: %s", robotsUrl, err instanceof Error ? err.message : String(err));
760
+ return { kind: "unavailable" };
761
+ }
762
+ }
763
+ /**
764
+ * Coerces `SourceConfigEntry.options.respectRobots` to a boolean. The bundle
765
+ * descriptor is boolean-validated at config load (schema), but the legacy
766
+ * `sources[].options` bag is `z.record(z.unknown())` and accepts anything, so
767
+ * the runtime read still validates. A misspelled non-boolean opt-out fails
768
+ * loudly (`ConfigError`) rather than silently defaulting either way — the
769
+ * user would otherwise think robots.txt handling is something other than
770
+ * what akm is actually doing (spec §4.7).
771
+ */
772
+ export function coerceRespectRobots(value) {
773
+ if (value === undefined || value === null)
774
+ return true;
775
+ if (typeof value === "boolean")
776
+ return value;
777
+ if (typeof value === "string") {
778
+ const normalized = value.trim().toLowerCase();
779
+ if (normalized === "true")
780
+ return true;
781
+ if (normalized === "false")
782
+ return false;
783
+ }
784
+ throw new ConfigError(`Invalid value for respectRobots: expected a boolean (or "true"/"false"), got ${JSON.stringify(value)}.`);
294
785
  }
295
786
  function buildMarkdownSnapshot(page, slug, tags) {
296
787
  const title = sanitizeString(page.title, 200) || slug;
297
- const description = sanitizeString(`Snapshot of ${page.url}`, 500);
788
+ const heading = title.replace(/([\\[\]`*_])/g, "\\$1").replace(/<(?=[a-zA-Z/!?])/g, "&lt;");
298
789
  const host = sanitizeString(new URL(page.url).hostname, 120);
790
+ const description = sanitizeString(`Website snapshot from ${host}`, 500);
299
791
  const content = page.markdown.trim() || `Source: ${page.url}`;
300
792
  const normalizedTags = Array.from(new Set(["website", host, ...(tags ?? [])]));
301
793
  return [
@@ -304,11 +796,14 @@ function buildMarkdownSnapshot(page, slug, tags) {
304
796
  `description: ${JSON.stringify(description)}`,
305
797
  `sourceUrl: ${JSON.stringify(page.url)}`,
306
798
  `title: ${JSON.stringify(title)}`,
799
+ `updated: ${todayIso()}`,
800
+ "lint_skip:",
801
+ " - stale-path",
307
802
  "tags:",
308
803
  ...normalizedTags.map((tag) => ` - ${JSON.stringify(tag)}`),
309
804
  "---",
310
805
  "",
311
- `# ${title}`,
806
+ `# ${heading}`,
312
807
  "",
313
808
  `Source: ${page.url}`,
314
809
  "",
@@ -436,92 +931,16 @@ function coercePositiveInt(value, fallback) {
436
931
  function looksLikeMarkup(body) {
437
932
  return /<html[\s>]|<body[\s>]|<\/[a-z][\w:-]*>/i.test(body);
438
933
  }
439
- function extractHtmlTitle(html) {
440
- const title = html.match(/<title[^>]*>([\s\S]*?)<\/title>/i)?.[1];
441
- if (title)
442
- return decodeHtmlEntities(stripTags(title)).trim();
443
- const h1 = html.match(/<h1[^>]*>([\s\S]*?)<\/h1>/i)?.[1];
444
- if (h1)
445
- return decodeHtmlEntities(stripTags(h1)).trim();
446
- return undefined;
447
- }
448
- function extractTextTitle(text) {
449
- for (const line of text.split(/\r?\n/)) {
450
- const trimmed = line.trim();
451
- if (!trimmed)
452
- continue;
453
- if (trimmed.startsWith("#"))
454
- return trimmed.replace(/^#+\s*/, "");
455
- return trimmed.slice(0, 120);
456
- }
457
- return undefined;
934
+ function plainTextToMarkdown(body) {
935
+ const inlineSafe = body
936
+ .replace(/\r\n?/g, "\n")
937
+ .replace(/([\\[\]`])/g, "\\$1")
938
+ .replace(/<(?=[a-zA-Z/!?])/g, "&lt;");
939
+ return escapeMarkdownStructure(inlineSafe).trim();
458
940
  }
459
- function extractSameDocumentLinks(html, pageUrl) {
460
- const links = [];
461
- const hrefPattern = /<a\b[^>]*href\s*=\s*(['"])(.*?)\1[^>]*>/gi;
462
- for (const match of html.matchAll(hrefPattern)) {
463
- const href = match[2]?.trim();
464
- if (!href || href.startsWith("#"))
465
- continue;
466
- try {
467
- const resolved = new URL(href, pageUrl);
468
- if (!isSafeLinkUrl(resolved))
469
- continue;
470
- links.push(resolved);
471
- }
472
- catch {
473
- /* ignore malformed links */
474
- }
475
- }
476
- return links;
477
- }
478
- function htmlToMarkdown(html, pageUrl) {
479
- let text = html;
480
- text = stripDangerousBlockTag(text, "script");
481
- text = stripDangerousBlockTag(text, "style");
482
- text = stripDangerousBlockTag(text, "noscript");
483
- text = stripDangerousBlockTag(text, "template");
484
- text = text.replace(/<pre\b[^>]*><code\b[^>]*>([\s\S]*?)<\/code><\/pre>/gi, (_match, code) => {
485
- const decoded = decodeHtmlEntities(stripTags(code)).trim();
486
- return decoded ? `\n\n\`\`\`\n${decoded}\n\`\`\`\n\n` : "\n\n";
487
- });
488
- text = text.replace(/<code\b[^>]*>([\s\S]*?)<\/code>/gi, (_match, code) => {
489
- const decoded = decodeHtmlEntities(stripTags(code)).trim();
490
- return decoded ? `\`${decoded}\`` : "";
491
- });
492
- text = text.replace(/<a\b[^>]*href\s*=\s*(['"])(.*?)\1[^>]*>([\s\S]*?)<\/a>/gi, (_match, _q, href, body) => {
493
- const label = decodeHtmlEntities(stripTags(body)).trim();
494
- if (!label)
495
- return "";
496
- try {
497
- const resolved = new URL(href, pageUrl);
498
- if (!isSafeLinkUrl(resolved))
499
- return label;
500
- return `[${label}](${resolved})`;
501
- }
502
- catch {
503
- return label;
504
- }
505
- });
506
- text = text.replace(/<h([1-6])\b[^>]*>([\s\S]*?)<\/h\1>/gi, (_match, level, body) => {
507
- const heading = decodeHtmlEntities(stripTags(body)).trim();
508
- return heading ? `\n\n${"#".repeat(Number(level))} ${heading}\n\n` : "\n\n";
509
- });
510
- text = text.replace(/<li\b[^>]*>([\s\S]*?)<\/li>/gi, (_match, body) => {
511
- const item = decodeHtmlEntities(stripTags(body)).trim();
512
- return item ? `\n- ${item}` : "";
513
- });
514
- text = text.replace(/<(p|div|section|article|main|header|footer|blockquote|table|tr)\b[^>]*>/gi, "\n\n");
515
- text = text.replace(/<\/(p|div|section|article|main|header|footer|blockquote|table|tr)>/gi, "\n\n");
516
- text = text.replace(/<br\s*\/?>/gi, "\n");
517
- text = text.replace(/<\/?(ul|ol)\b[^>]*>/gi, "\n");
518
- text = decodeHtmlEntities(stripTags(text));
519
- text = text
520
- .replace(/\r/g, "")
521
- .replace(/[ \t]+\n/g, "\n")
522
- .replace(/\n{3,}/g, "\n\n")
523
- .trim();
524
- return text;
941
+ /** True for URL paths that are plainly binary assets, never crawlable pages. */
942
+ function isAssetLikePath(pathname) {
943
+ return /\.(css|js|json|png|jpe?g|gif|svg|ico|webp|pdf|zip|tar|gz|mp4|mp3|woff2?)$/i.test(pathname);
525
944
  }
526
945
  function stripTags(value) {
527
946
  return value.replace(/<[^>]+>/g, " ");
@@ -546,148 +965,25 @@ function decodeHtmlEntities(value) {
546
965
  return namedEntities[normalized] ?? match;
547
966
  });
548
967
  }
549
- function isAssetLikePath(pathname) {
550
- return /\.(css|js|json|png|jpe?g|gif|svg|ico|webp|pdf|zip|tar|gz|mp4|mp3|woff2?)$/i.test(pathname);
551
- }
552
- function isSafeLinkUrl(url) {
553
- return url.protocol === "http:" || url.protocol === "https:";
554
- }
555
- function assertWebsiteRequestUrl(rawUrl, ErrorType = Error, options) {
556
- const parsedUrl = new URL(rawUrl);
557
- const hostname = parsedUrl.hostname.toLowerCase();
558
- if (hostname.endsWith(".invalid")) {
559
- throw new ErrorType(`Refusing to fetch reserved invalid hostname: ${parsedUrl.hostname}`);
560
- }
561
- if (isForbiddenWebsiteHostname(hostname, options)) {
562
- throw new ErrorType(`Refusing to fetch non-public website host: ${parsedUrl.hostname}`);
563
- }
564
- }
565
- async function defaultResolveHostname(hostname) {
566
- const records = await dnsLookup(hostname, { all: true });
567
- return records.map((record) => record.address);
568
- }
569
- /**
570
- * Resolve-then-validate SSRF guard against DNS rebinding / private-range
571
- * bypasses. {@link assertWebsiteRequestUrl} only rejects IP-literal and
572
- * well-known-name hosts; a hostname like `private-host.example.com` that
573
- * resolves to `10.0.0.1` passes those checks and then fetch connects to the
574
- * private address. Here we resolve EVERY A/AAAA record and validate each
575
- * against the same forbidden-range rules, failing CLOSED on an empty answer or
576
- * resolver error.
577
- *
578
- * TOCTOU residual (documented, not fully closable here): Bun's `fetch` exposes
579
- * no custom `lookup`/agent hook, so we cannot pin the socket to the exact IP we
580
- * validated — a hostile resolver could return a public IP to this lookup and a
581
- * private IP microseconds later at connect time (classic rebinding). This still
582
- * removes the TRIVIAL `hostname A 10.0.0.1` bypass, which is the strongest
583
- * guarantee available without a pinned-connection fetch API. Re-run on every
584
- * redirect hop (the crawler recurses through `fetchWebsiteResponse`).
585
- */
586
- export async function assertResolvedHostAllowed(hostname, options) {
587
- if (options?.allowPrivateHosts === true)
588
- return;
589
- const bare = stripIpv6Brackets(hostname.toLowerCase());
590
- // IP-literal hosts are already fully validated by assertWebsiteRequestUrl's
591
- // range checks; resolving them is a no-op (and dnsLookup would just echo it).
592
- if (isIP(bare) !== 0)
593
- return;
594
- const resolve = options?.resolveHostname ?? defaultResolveHostname;
595
- let addresses;
596
- try {
597
- addresses = await resolve(bare);
598
- }
599
- catch {
600
- throw new Error(`Refusing to fetch ${hostname}: DNS resolution failed`);
601
- }
602
- if (addresses.length === 0) {
603
- throw new Error(`Refusing to fetch ${hostname}: hostname resolved to no addresses`);
604
- }
605
- for (const address of addresses) {
606
- const version = isIP(address);
607
- const forbidden = version === 4 ? isForbiddenIpv4(address) : version === 6 ? isForbiddenIpv6(stripIpv6Brackets(address)) : true;
608
- if (forbidden) {
609
- throw new Error(`Refusing to fetch ${hostname}: resolves to non-public or unparseable address ${address}`);
610
- }
611
- }
612
- }
613
- // WHATWG URL.hostname wraps IPv6 literals in brackets (e.g. "[::1]"), but
614
- // node:net's isIP() only recognizes the bare address form and returns 0 for
615
- // anything bracketed — silently skipping all IPv6 forbidden-host checks
616
- // below for every hostname parsed off a URL. Strip the brackets before any
617
- // isIP()/isForbiddenIpv6() call so those checks actually run.
618
- function stripIpv6Brackets(hostname) {
619
- return hostname.startsWith("[") && hostname.endsWith("]") ? hostname.slice(1, -1) : hostname;
968
+ function extractHtmlTitle(html) {
969
+ const title = html.match(/<title[^>]*>([\s\S]*?)<\/title>/i)?.[1];
970
+ if (title)
971
+ return decodeHtmlEntities(stripTags(title)).trim();
972
+ const h1 = html.match(/<h1[^>]*>([\s\S]*?)<\/h1>/i)?.[1];
973
+ if (h1)
974
+ return decodeHtmlEntities(stripTags(h1)).trim();
975
+ return undefined;
620
976
  }
621
- function isForbiddenWebsiteHostname(hostname, options) {
622
- if (options?.allowPrivateHosts === true)
623
- return false;
624
- if (hostname === "localhost" || hostname.endsWith(".localhost") || hostname === "metadata.google.internal") {
625
- return true;
977
+ function extractTextTitle(text) {
978
+ for (const line of text.split(/\r?\n/)) {
979
+ const trimmed = line.trim();
980
+ if (!trimmed)
981
+ continue;
982
+ if (trimmed.startsWith("#"))
983
+ return trimmed.replace(/^#+\s*/, "");
984
+ return trimmed.slice(0, 120);
626
985
  }
627
- const bareHostname = stripIpv6Brackets(hostname);
628
- const ipVersion = isIP(bareHostname);
629
- if (ipVersion === 4)
630
- return isForbiddenIpv4(bareHostname);
631
- if (ipVersion === 6)
632
- return isForbiddenIpv6(bareHostname);
633
- return false;
634
- }
635
- function isLoopbackWebsiteHostname(hostname) {
636
- if (hostname === "localhost" || hostname.endsWith(".localhost"))
637
- return true;
638
- const bareHostname = stripIpv6Brackets(hostname);
639
- const ipVersion = isIP(bareHostname);
640
- if (ipVersion === 4)
641
- return bareHostname.startsWith("127.");
642
- if (ipVersion === 6)
643
- return bareHostname === "::1";
644
- return false;
645
- }
646
- function isForbiddenIpv4(hostname) {
647
- const parts = hostname.split(".").map((part) => Number.parseInt(part, 10));
648
- if (parts.length !== 4 || parts.some((part) => !Number.isInteger(part) || part < 0 || part > 255))
649
- return true;
650
- const a = parts[0];
651
- const b = parts[1];
652
- return (a === 0 ||
653
- a === 10 ||
654
- a === 127 ||
655
- (a === 169 && b === 254) ||
656
- (a === 172 && b >= 16 && b <= 31) ||
657
- (a === 192 && b === 168));
658
- }
659
- /**
660
- * Extracts the embedded IPv4 address from an IPv4-mapped IPv6 literal
661
- * (`::ffff:a.b.c.d` or its canonical hex form `::ffff:xxxx:yyyy`), or
662
- * returns null if `hostname` isn't one.
663
- */
664
- function extractIpv4MappedAddress(normalizedHostname) {
665
- const match = normalizedHostname.match(/^::ffff:(?:(\d{1,3}(?:\.\d{1,3}){3})|([0-9a-f]{1,4}):([0-9a-f]{1,4}))$/);
666
- if (!match)
667
- return null;
668
- if (match[1])
669
- return match[1];
670
- const high = Number.parseInt(match[2], 16);
671
- const low = Number.parseInt(match[3], 16);
672
- return `${(high >> 8) & 0xff}.${high & 0xff}.${(low >> 8) & 0xff}.${low & 0xff}`;
673
- }
674
- function isForbiddenIpv6(hostname) {
675
- const normalized = hostname.toLowerCase();
676
- const mappedIpv4 = extractIpv4MappedAddress(normalized);
677
- if (mappedIpv4)
678
- return isForbiddenIpv4(mappedIpv4);
679
- return (normalized === "::" ||
680
- normalized === "::1" ||
681
- normalized.startsWith("fc") ||
682
- normalized.startsWith("fd") ||
683
- normalized.startsWith("fe8") ||
684
- normalized.startsWith("fe9") ||
685
- normalized.startsWith("fea") ||
686
- normalized.startsWith("feb"));
687
- }
688
- function stripDangerousBlockTag(value, tagName) {
689
- const pattern = new RegExp(`<${tagName}\\b[^>]*>[\\s\\S]*?<\\/${tagName}\\s*>`, "gi");
690
- return value.replace(pattern, "");
986
+ return undefined;
691
987
  }
692
988
  function safeCodePointToString(value) {
693
989
  if (!Number.isFinite(value) || value < 0 || value > 0x10ffff)
@@ -699,3 +995,5 @@ function safeCodePointToString(value) {
699
995
  return undefined;
700
996
  }
701
997
  }
998
+ // Re-exported for existing importers (the SSRF suite pins these entry points).
999
+ export { assertResolvedHostAllowed } from "./host-guard.js";