akm-cli 0.9.0-rc.9 → 0.9.1-beta.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 (477) hide show
  1. package/CHANGELOG.md +1770 -44
  2. package/README.md +38 -47
  3. package/SECURITY.md +14 -1
  4. package/STABILITY.md +499 -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 +74 -89
  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 +148 -88
  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/llm-usage.js +2 -1
  57. package/dist/commands/health/md-report.js +5 -9
  58. package/dist/commands/health/metrics.js +62 -20
  59. package/dist/commands/health/renderers.js +47 -0
  60. package/dist/commands/health/report-view-model.js +4 -5
  61. package/dist/commands/health/stash-exposure.js +1 -1
  62. package/dist/commands/health/surfaces.js +4 -122
  63. package/dist/commands/health/task-runs.js +3 -67
  64. package/dist/commands/health/types-improve.js +7 -0
  65. package/dist/commands/health.js +160 -35
  66. package/dist/commands/improve/anti-collapse.js +2 -2
  67. package/dist/commands/improve/autonomy-gate.js +68 -0
  68. package/dist/commands/improve/collapse-detector.js +41 -40
  69. package/dist/commands/improve/consolidate/eligibility.js +1 -23
  70. package/dist/commands/improve/consolidate/merge.js +4 -0
  71. package/dist/commands/improve/consolidate.js +140 -1000
  72. package/dist/commands/improve/distill/promote-memory.js +12 -12
  73. package/dist/commands/improve/distill/quality-gate.js +11 -6
  74. package/dist/commands/improve/distill.js +58 -69
  75. package/dist/commands/improve/eligibility.js +101 -55
  76. package/dist/commands/improve/extract-cli.js +14 -133
  77. package/dist/commands/improve/improve-cli.js +98 -114
  78. package/dist/commands/improve/improve-result-file.js +1 -28
  79. package/dist/commands/improve/improve-strategies.js +8 -5
  80. package/dist/commands/improve/improve.js +254 -101
  81. package/dist/commands/improve/locks.js +7 -0
  82. package/dist/commands/improve/loop-stages.js +182 -20
  83. package/dist/commands/improve/memory/derived-ref.js +45 -43
  84. package/dist/commands/improve/memory/memory-belief.js +1 -1
  85. package/dist/commands/improve/memory/memory-contradiction-detect.js +4 -12
  86. package/dist/commands/improve/memory/memory-improve.js +14 -5
  87. package/dist/commands/improve/outcome-loop.js +22 -65
  88. package/dist/commands/improve/preparation.js +114 -123
  89. package/dist/commands/improve/proactive-maintenance.js +2 -5
  90. package/dist/commands/improve/reflect.js +56 -160
  91. package/dist/commands/improve/run-context.js +5 -0
  92. package/dist/commands/improve/salience.js +11 -122
  93. package/dist/commands/improve/session-asset.js +4 -0
  94. package/dist/commands/improve/source-identity.js +10 -38
  95. package/dist/commands/lint/base-linter.js +51 -131
  96. package/dist/commands/lint/env-key-rules.js +31 -47
  97. package/dist/commands/lint/index.js +444 -88
  98. package/dist/commands/lint/types.js +22 -1
  99. package/dist/commands/{events.js → log.js} +33 -38
  100. package/dist/commands/migrate-cli.js +92 -12
  101. package/dist/commands/migration-tool.js +46 -0
  102. package/dist/commands/observability-cli.js +70 -209
  103. package/dist/commands/proposal/drain.js +101 -29
  104. package/dist/commands/proposal/proposal-cli.js +76 -48
  105. package/dist/commands/proposal/proposal.js +54 -18
  106. package/dist/commands/proposal/propose-cli.js +88 -0
  107. package/dist/commands/proposal/propose.js +23 -15
  108. package/dist/commands/proposal/repository.js +718 -279
  109. package/dist/commands/proposal/validators/proposal-quality-validators.js +2 -8
  110. package/dist/commands/proposal/validators/proposal-validators.js +55 -7
  111. package/dist/commands/proposal/validators/proposals.js +4 -7
  112. package/dist/commands/read/curate.js +34 -53
  113. package/dist/commands/read/knowledge.js +150 -95
  114. package/dist/commands/read/registry-search.js +2 -2
  115. package/dist/commands/read/remember-cli.js +42 -15
  116. package/dist/commands/read/search-cli.js +180 -78
  117. package/dist/commands/read/search.js +58 -43
  118. package/dist/commands/read/show.js +197 -141
  119. package/dist/commands/registry-cli.js +12 -51
  120. package/dist/commands/remember.js +14 -57
  121. package/dist/commands/sources/add-cli.js +100 -31
  122. package/dist/commands/sources/bundle-cli.js +166 -0
  123. package/dist/commands/sources/bundle-config-ops.js +7 -2
  124. package/dist/commands/sources/info.js +30 -7
  125. package/dist/commands/sources/init.js +12 -12
  126. package/dist/commands/sources/installed-stashes.js +387 -98
  127. package/dist/commands/sources/schema-repair.js +3 -2
  128. package/dist/commands/sources/self-update.js +131 -38
  129. package/dist/commands/sources/source-add.js +72 -17
  130. package/dist/commands/sources/source-clone.js +129 -45
  131. package/dist/commands/sources/source-manage.js +43 -23
  132. package/dist/commands/sources/sources-cli.js +57 -208
  133. package/dist/commands/sources/stash-cli.js +46 -53
  134. package/dist/commands/tasks/tasks-cli.js +91 -97
  135. package/dist/commands/tasks/tasks.js +284 -423
  136. package/dist/commands/workflow-cli.js +181 -450
  137. package/dist/core/abort-deadline.js +28 -0
  138. package/dist/core/adapter/adapters/agent-skills-adapter.js +67 -5
  139. package/dist/core/adapter/adapters/akm-adapter.js +60 -38
  140. package/dist/core/adapter/adapters/akm-lint.js +109 -42
  141. package/dist/core/adapter/adapters/akm-metadata.js +15 -44
  142. package/dist/core/adapter/adapters/akm-task-adapter.js +54 -31
  143. package/dist/core/adapter/adapters/akm-workflow-adapter.js +55 -71
  144. package/dist/core/adapter/adapters/dotenv-adapter.js +1 -1
  145. package/dist/core/adapter/adapters/generic-files-adapter.js +2 -0
  146. package/dist/core/adapter/adapters/index.js +6 -6
  147. package/dist/core/adapter/adapters/llm-wiki-adapter.js +14 -8
  148. package/dist/core/adapter/adapters/okf-adapter.js +187 -19
  149. package/dist/core/adapter/adapters/shared.js +3 -19
  150. package/dist/core/adapter/adapters/tool-dir-shared.js +13 -6
  151. package/dist/core/adapter/adapters/website-snapshot-adapter.js +1 -0
  152. package/dist/core/adapter/detect-adapter.js +17 -0
  153. package/dist/core/adapter/recognize-match.js +6 -4
  154. package/dist/core/adapter/validate-context.js +214 -0
  155. package/dist/core/asset/akm-markdown.js +63 -0
  156. package/dist/core/asset/asset-placement.js +20 -6
  157. package/dist/core/asset/asset-ref.js +11 -9
  158. package/dist/core/asset/frontmatter-lint.js +30 -0
  159. package/dist/core/asset/frontmatter.js +29 -9
  160. package/dist/core/asset/markdown.js +40 -51
  161. package/dist/core/asset/resolve-ref.js +89 -18
  162. package/dist/core/asset/stash-meta.js +1 -1
  163. package/dist/core/bundle-id.js +51 -0
  164. package/dist/core/common.js +218 -44
  165. package/dist/core/concurrent.js +32 -0
  166. package/dist/core/config/config-io.js +12 -1
  167. package/dist/core/config/config-schema.js +35 -8
  168. package/dist/core/config/config-sources.js +55 -11
  169. package/dist/core/config/config-walker.js +25 -9
  170. package/dist/core/config/config.js +9 -48
  171. package/dist/core/config/experimental.js +21 -0
  172. package/dist/core/config/schema/embedding.js +5 -1
  173. package/dist/core/config/schema/experimental.js +30 -0
  174. package/dist/core/config/schema/improve-processes.js +0 -6
  175. package/dist/core/config/schema/improve.js +21 -3
  176. package/dist/core/config/schema/index-config.js +8 -15
  177. package/dist/core/config/schema/output.js +4 -1
  178. package/dist/core/config/schema/setup.js +9 -18
  179. package/dist/core/config/schema/sources-bundles.js +49 -33
  180. package/dist/core/config/schema/workflow.js +14 -3
  181. package/dist/core/env-secret-ref.js +76 -46
  182. package/dist/core/errors.js +43 -12
  183. package/dist/core/events.js +76 -152
  184. package/dist/core/file-change.js +6 -5
  185. package/dist/core/file-lock.js +7 -1
  186. package/dist/core/fs-txn.js +83 -7
  187. package/dist/core/git-message.js +2 -2
  188. package/dist/core/improve-result.js +6 -100
  189. package/dist/core/json-schema.js +327 -9
  190. package/dist/core/lesson-lint.js +1 -17
  191. package/dist/core/logs-db.js +2 -1
  192. package/dist/core/loopback.js +89 -0
  193. package/dist/core/migration-operation.js +33 -2
  194. package/dist/core/mutation-target.js +78 -0
  195. package/dist/core/parse.js +4 -1
  196. package/dist/core/path-access.js +107 -0
  197. package/dist/core/paths.js +17 -20
  198. package/dist/core/recognition-util.js +12 -14
  199. package/dist/core/redaction.js +98 -17
  200. package/dist/core/spawn-env.js +234 -0
  201. package/dist/core/standards/resolve-standards-context.js +2 -14
  202. package/dist/core/standards/resolve-stash-standards.js +2 -2
  203. package/dist/core/standards/resolve-type-conventions.js +2 -2
  204. package/dist/core/state/migrations.js +41 -18
  205. package/dist/core/state-db-scope.js +134 -0
  206. package/dist/core/state-db.js +5 -14
  207. package/dist/core/structured.js +1 -1
  208. package/dist/core/subprocess.js +182 -36
  209. package/dist/core/text-truncation.js +9 -5
  210. package/dist/core/type-presentation.js +3 -3
  211. package/dist/core/warn.js +0 -3
  212. package/dist/core/write-provenance.js +85 -0
  213. package/dist/core/write-source.js +778 -95
  214. package/dist/indexer/bundle-identity-guard.js +3 -2
  215. package/dist/indexer/db/graph-db.js +17 -30
  216. package/dist/indexer/ensure-index.js +11 -3
  217. package/dist/indexer/graph/graph-boost.js +9 -34
  218. package/dist/indexer/graph/graph-extraction.js +8 -5
  219. package/dist/indexer/index-writer-lock.js +53 -17
  220. package/dist/indexer/index-written-assets.js +33 -24
  221. package/dist/indexer/indexer.js +551 -253
  222. package/dist/indexer/installations.js +14 -96
  223. package/dist/indexer/passes/dir-staleness.js +16 -9
  224. package/dist/indexer/passes/memory-inference.js +15 -9
  225. package/dist/indexer/passes/metadata.js +113 -47
  226. package/dist/indexer/scan/doc-to-entry.js +38 -1
  227. package/dist/indexer/scan/drain-dir.js +13 -23
  228. package/dist/indexer/search/db-search.js +107 -69
  229. package/dist/indexer/search/fts-query.js +47 -24
  230. package/dist/indexer/search/ranking-contributors.js +42 -20
  231. package/dist/indexer/search/ranking.js +18 -99
  232. package/dist/indexer/search/search-fields.js +7 -2
  233. package/dist/indexer/search/search-source.js +82 -93
  234. package/dist/indexer/usage/usage-events.js +0 -89
  235. package/dist/indexer/walk/file-context.js +2 -1
  236. package/dist/indexer/walk/matchers.js +30 -43
  237. package/dist/indexer/walk/path-resolver.js +7 -2
  238. package/dist/indexer/walk/walker.js +38 -12
  239. package/dist/integrations/agent/builders.js +0 -6
  240. package/dist/integrations/agent/config.js +2 -2
  241. package/dist/integrations/agent/detect.js +49 -19
  242. package/dist/integrations/agent/engine-fallback.js +76 -0
  243. package/dist/integrations/agent/engine-resolution.js +24 -11
  244. package/dist/integrations/agent/model-aliases.js +1 -1
  245. package/dist/integrations/agent/profiles.js +23 -1
  246. package/dist/integrations/agent/prompts.js +12 -8
  247. package/dist/integrations/agent/runner-dispatch.js +4 -2
  248. package/dist/integrations/agent/runner.js +0 -1
  249. package/dist/integrations/agent/spawn.js +20 -93
  250. package/dist/integrations/github.js +1 -1
  251. package/dist/integrations/harnesses/aider/agent-builder.js +6 -4
  252. package/dist/integrations/harnesses/amazonq/agent-builder.js +7 -4
  253. package/dist/integrations/harnesses/claude/session-log.js +0 -10
  254. package/dist/integrations/harnesses/codex/agent-builder.js +5 -2
  255. package/dist/integrations/harnesses/copilot/agent-builder.js +5 -3
  256. package/dist/integrations/harnesses/gemini/agent-builder.js +5 -3
  257. package/dist/integrations/harnesses/index.js +3 -7
  258. package/dist/integrations/harnesses/opencode/agent-builder.js +21 -2
  259. package/dist/integrations/harnesses/opencode/session-log.js +0 -15
  260. package/dist/integrations/harnesses/opencode-sdk/sdk-runner.js +13 -4
  261. package/dist/integrations/harnesses/openhands/agent-builder.js +9 -6
  262. package/dist/integrations/harnesses/pi/agent-builder.js +6 -4
  263. package/dist/integrations/lockfile.js +132 -8
  264. package/dist/integrations/session-logs/index.js +3 -28
  265. package/dist/llm/client.js +136 -100
  266. package/dist/llm/embedders/remote.js +13 -5
  267. package/dist/llm/feature-gate.js +4 -12
  268. package/dist/llm/graph-extract.js +5 -11
  269. package/dist/llm/memory-infer.js +144 -1
  270. package/dist/llm/metadata-enhance.js +5 -7
  271. package/dist/llm/structured-call.js +1 -1
  272. package/dist/llm/usage-persist.js +26 -5
  273. package/dist/llm/usage-telemetry.js +25 -2
  274. package/dist/output/cli-hints.js +1 -2
  275. package/dist/output/context.js +22 -7
  276. package/dist/output/format-exempt.js +80 -0
  277. package/dist/output/generic-render.js +259 -0
  278. package/dist/output/render-registry.js +57 -0
  279. package/dist/output/renderers.js +14 -36
  280. package/dist/output/shapes/curate.js +10 -1
  281. package/dist/output/shapes/events.js +12 -7
  282. package/dist/output/shapes/helpers.js +56 -83
  283. package/dist/output/shapes/migrate.js +8 -0
  284. package/dist/output/shapes/passthrough.js +7 -41
  285. package/dist/output/shapes/proposal/producer.js +15 -7
  286. package/dist/output/shapes.js +2 -9
  287. package/dist/output/text/{init.js → bundle-create.js} +3 -1
  288. package/dist/output/text/bundle-show.js +7 -0
  289. package/dist/output/text/command-format.js +164 -96
  290. package/dist/output/text/env.js +1 -3
  291. package/dist/output/text/events.js +8 -7
  292. package/dist/output/text/health-format.js +103 -0
  293. package/dist/output/text/health.js +7 -0
  294. package/dist/output/text/helpers.js +10 -8
  295. package/dist/output/text/lint-format.js +56 -0
  296. package/dist/{migrate-storage-node.mjs → output/text/lint.js} +2 -5
  297. package/dist/output/text/migrate.js +88 -0
  298. package/dist/output/text/proposal/producer.js +4 -2
  299. package/dist/output/text/proposal-format.js +44 -72
  300. package/dist/output/text/registry-commands.js +1 -2
  301. package/dist/output/text/show-directives.js +15 -7
  302. package/dist/output/text/status-list.js +32 -0
  303. package/dist/output/text/{save.js → sync.js} +2 -2
  304. package/dist/output/text/workflow-format.js +24 -203
  305. package/dist/output/text/workflow.js +1 -7
  306. package/dist/output/text.js +16 -17
  307. package/dist/registry/factory.js +4 -6
  308. package/dist/registry/origin-resolve.js +16 -27
  309. package/dist/registry/providers/skills-sh.js +3 -3
  310. package/dist/registry/providers/static-index.js +13 -23
  311. package/dist/registry/resolve.js +42 -7
  312. package/dist/registry/semver.js +34 -84
  313. package/dist/runtime.js +2 -23
  314. package/dist/scripts/akm-migrate-node.js +60972 -0
  315. package/dist/scripts/akm-migrate.js +60310 -0
  316. package/dist/setup/detect.js +42 -15
  317. package/dist/setup/registry-stash-loader.js +2 -2
  318. package/dist/setup/setup.js +236 -136
  319. package/dist/setup/steps/connection.js +7 -9
  320. package/dist/setup/steps/platforms.js +9 -9
  321. package/dist/setup/steps/semantic.js +15 -3
  322. package/dist/setup/steps/sources.js +12 -13
  323. package/dist/setup/steps/stashdir.js +2 -3
  324. package/dist/setup/steps/tasks.js +237 -120
  325. package/dist/sources/freshness.js +1 -1
  326. package/dist/sources/provider-factory.js +11 -17
  327. package/dist/sources/providers/filesystem.js +2 -3
  328. package/dist/sources/providers/git-install.js +278 -34
  329. package/dist/sources/providers/git-provider.js +25 -23
  330. package/dist/sources/providers/git-stash.js +414 -106
  331. package/dist/sources/providers/git.js +2 -2
  332. package/dist/sources/providers/npm.js +16 -19
  333. package/dist/sources/providers/provider-utils.js +7 -4
  334. package/dist/sources/providers/sync-from-ref.js +3 -9
  335. package/dist/sources/providers/website.js +6 -1
  336. package/dist/sources/resolve.js +6 -5
  337. package/dist/sources/snapshot-fetchers/bluesky.js +146 -0
  338. package/dist/sources/snapshot-fetchers/content-extract.js +566 -0
  339. package/dist/sources/snapshot-fetchers/fetcher-util.js +41 -0
  340. package/dist/sources/snapshot-fetchers/github.js +100 -0
  341. package/dist/sources/snapshot-fetchers/host-guard.js +291 -0
  342. package/dist/sources/snapshot-fetchers/registry.js +17 -1
  343. package/dist/sources/snapshot-fetchers/robots.js +348 -0
  344. package/dist/sources/snapshot-fetchers/rss.js +282 -0
  345. package/dist/sources/snapshot-fetchers/secret-seam.js +42 -0
  346. package/dist/sources/snapshot-fetchers/website-ingest.js +686 -282
  347. package/dist/sources/snapshot-fetchers/x.js +910 -0
  348. package/dist/storage/database.js +13 -1
  349. package/dist/storage/engines/sqlite-migrations.js +23 -111
  350. package/dist/storage/managed-db.js +21 -2
  351. package/dist/storage/repositories/canaries-repository.js +1 -1
  352. package/dist/storage/repositories/events-repository.js +27 -11
  353. package/dist/storage/repositories/improve-runs-repository.js +6 -12
  354. package/dist/storage/repositories/index-connection.js +44 -8
  355. package/dist/storage/repositories/index-entries-repository.js +157 -241
  356. package/dist/storage/repositories/index-entry-mapper.js +15 -11
  357. package/dist/storage/repositories/index-fts-repository.js +5 -2
  358. package/dist/storage/repositories/index-llm-cache-repository.js +0 -1
  359. package/dist/storage/repositories/index-meta-repository.js +2 -3
  360. package/dist/storage/repositories/index-schema.js +10 -25
  361. package/dist/storage/repositories/index-utility-repository.js +15 -28
  362. package/dist/storage/repositories/index-vec-repository.js +6 -1
  363. package/dist/storage/repositories/outcome-repository.js +119 -0
  364. package/dist/storage/repositories/proposals-repository.js +296 -59
  365. package/dist/storage/repositories/registry-cache.js +19 -0
  366. package/dist/storage/repositories/salience-repository.js +172 -0
  367. package/dist/storage/repositories/task-history-repository.js +15 -13
  368. package/dist/storage/repositories/workflow-runs-repository.js +118 -53
  369. package/dist/tasks/backends/cron.js +105 -15
  370. package/dist/tasks/backends/index.js +1 -1
  371. package/dist/tasks/backends/launchd.js +85 -38
  372. package/dist/tasks/backends/schtasks.js +135 -15
  373. package/dist/tasks/embedded.js +56 -40
  374. package/dist/tasks/log-redaction.js +156 -0
  375. package/dist/tasks/parser.js +87 -160
  376. package/dist/tasks/resolve-akm-bin.js +137 -59
  377. package/dist/tasks/runner.js +209 -44
  378. package/dist/tasks/scheduler-invocation.js +220 -10
  379. package/dist/tasks/schema.js +89 -1
  380. package/dist/tasks/task-id.js +1 -3
  381. package/dist/tasks/validator.js +20 -6
  382. package/dist/workflows/authoring/authoring.js +94 -143
  383. package/dist/workflows/authoring/scope-key.js +1 -1
  384. package/dist/workflows/concurrency-policy.js +95 -1
  385. package/dist/workflows/exec/dispatch-redaction.js +114 -0
  386. package/dist/workflows/exec/exec-unit.js +542 -0
  387. package/dist/workflows/exec/frozen-judge.js +130 -32
  388. package/dist/workflows/exec/native-executor.js +513 -274
  389. package/dist/workflows/exec/param-secrets.js +12 -11
  390. package/dist/workflows/exec/run-workflow.js +496 -237
  391. package/dist/workflows/exec/step-work.js +616 -412
  392. package/dist/workflows/exec/unit-dispatch.js +34 -0
  393. package/dist/workflows/exec/unit-writer.js +53 -13
  394. package/dist/workflows/exec/worktree.js +454 -41
  395. package/dist/workflows/ir/compile.js +166 -271
  396. package/dist/workflows/ir/freeze.js +119 -42
  397. package/dist/workflows/ir/params.js +135 -11
  398. package/dist/workflows/ir/plan-hash.js +1 -1
  399. package/dist/workflows/ir/schema.js +129 -45
  400. package/dist/workflows/parser.js +1070 -307
  401. package/dist/workflows/program/expressions.js +20 -208
  402. package/dist/workflows/program/schema.js +31 -10
  403. package/dist/workflows/renderer.js +123 -68
  404. package/dist/workflows/resource-limits.js +184 -0
  405. package/dist/workflows/runtime/checkin.js +3 -3
  406. package/dist/workflows/runtime/plan-classifier.js +16 -75
  407. package/dist/workflows/runtime/runs.js +331 -132
  408. package/dist/workflows/runtime/unit-checkin.js +1 -1
  409. package/dist/workflows/runtime/unit-phases.js +2 -2
  410. package/dist/workflows/runtime/workflow-asset-loader.js +232 -83
  411. package/dist/workflows/schema.js +1 -11
  412. package/dist/workflows/validate-summary.js +47 -38
  413. package/dist/workflows/validator.js +21 -62
  414. package/docs/README.md +110 -0
  415. package/docs/migration/README.md +8 -0
  416. package/docs/migration/release-notes/0.7.0.md +11 -11
  417. package/docs/migration/release-notes/0.9.0.md +209 -27
  418. package/docs/migration/v0.7-to-v0.8.md +48 -48
  419. package/docs/migration/v0.8-to-v0.9.md +567 -209
  420. package/docs/migration/v0.9.0-troubleshooting.md +561 -0
  421. package/docs/reference/README.md +19 -0
  422. package/docs/reference/bundle-types.md +19 -0
  423. package/docs/reference/cli.md +2342 -0
  424. package/docs/reference/configuration.md +371 -0
  425. package/docs/reference/data-and-telemetry.md +129 -46
  426. package/docs/reference/supported-formats.md +50 -0
  427. package/docs/reference/workflow-schema.md +1014 -0
  428. package/docs/reference/workflows.md +51 -0
  429. package/package.json +31 -13
  430. package/schemas/akm-asset-envelope.json +93 -0
  431. package/schemas/akm-config.json +89 -128
  432. package/schemas/akm-task.json +27 -5
  433. package/schemas/akm-workflow.json +157 -81
  434. package/dist/assets/tasks/core/backup.yml +0 -5
  435. package/dist/assets/tasks/graph-refresh-weekly.yml +0 -10
  436. package/dist/cli/config-migrate.js +0 -1878
  437. package/dist/cli/config-validate.js +0 -41
  438. package/dist/commands/backup-cli.js +0 -56
  439. package/dist/commands/bundle/bundle-cli.js +0 -68
  440. package/dist/commands/bundle/bundle.js +0 -219
  441. package/dist/commands/graph/graph-cli.js +0 -124
  442. package/dist/commands/graph/graph.js +0 -489
  443. package/dist/commands/improve/extract-watch.js +0 -140
  444. package/dist/commands/mv-cli.js +0 -1221
  445. package/dist/commands/sources/history.js +0 -201
  446. package/dist/commands/tasks/default-tasks.js +0 -186
  447. package/dist/core/migration-backup.js +0 -1231
  448. package/dist/indexer/usage/unmigrated-vaults-guard.js +0 -95
  449. package/dist/llm/memory-infer-impl.js +0 -138
  450. package/dist/migrate/legacy/config-source-migration.js +0 -257
  451. package/dist/migrate/legacy/content-migration.js +0 -350
  452. package/dist/migrate/legacy/legacy-layout.js +0 -779
  453. package/dist/migrate/legacy/legacy-paths.js +0 -25
  454. package/dist/migrate/legacy/legacy-stash-json.js +0 -80
  455. package/dist/migrate/legacy/proposal-fs-import.js +0 -168
  456. package/dist/migrate/legacy/task-target-ref-migration.js +0 -278
  457. package/dist/migrate/legacy/three-db-cutover.js +0 -845
  458. package/dist/migrate/legacy/workflow-migrations-bodies.js +0 -52
  459. package/dist/migrate/legacy/workflow-migrations-frozen.js +0 -21
  460. package/dist/migrate/legacy-ref-grammar.js +0 -214
  461. package/dist/output/shapes/distill.js +0 -14
  462. package/dist/output/shapes/history.js +0 -11
  463. package/dist/output/text/distill.js +0 -6
  464. package/dist/output/text/enable-disable.js +0 -8
  465. package/dist/output/text/history.js +0 -6
  466. package/dist/registry/build-index.js +0 -382
  467. package/dist/schemas/akm-config.json +0 -4704
  468. package/dist/schemas/akm-task.json +0 -87
  469. package/dist/schemas/akm-workflow.json +0 -372
  470. package/dist/scripts/migrate-storage.js +0 -3816
  471. package/dist/workflows/authoring/workflow-program-template.yaml +0 -31
  472. package/dist/workflows/cli.js +0 -53
  473. package/dist/workflows/exec/brief.js +0 -481
  474. package/dist/workflows/exec/report.js +0 -1460
  475. package/dist/workflows/exec/watch.js +0 -116
  476. package/dist/workflows/program/parser.js +0 -813
  477. package/dist/workflows/program/project.js +0 -104
@@ -1,18 +1,20 @@
1
1
  // This Source Code Form is subject to the terms of the Mozilla Public
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
- import { createHash } from "node:crypto";
5
- import { lookup as dnsLookup } from "node:dns/promises";
4
+ import { createHash, randomBytes } from "node:crypto";
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,24 +158,196 @@ 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
+ let snapshotWriteHookForTests;
205
+ /**
206
+ * TEST-ONLY. Interrupt a refresh partway through its page-write loop;
207
+ * `undefined` restores. Exists because "a process killed mid-refresh" cannot
208
+ * be staged from outside the module — the whole loop is a single synchronous
209
+ * burst between two awaits. Inert in production (one `undefined?.()` per page).
210
+ */
211
+ export function _setWebsiteSnapshotWriteHookForTests(hook) {
212
+ snapshotWriteHookForTests = hook;
213
+ }
214
+ function snapshotWriteHook(event) {
215
+ snapshotWriteHookForTests?.(event);
216
+ }
217
+ function snapshotSiblingPrefix(stashDir, kind) {
218
+ return `.${path.basename(stashDir)}.${kind}-`;
219
+ }
220
+ function snapshotSiblingPath(stashDir, kind) {
221
+ const unique = `${process.pid}-${randomBytes(6).toString("hex")}`;
222
+ return path.join(path.dirname(stashDir), `${snapshotSiblingPrefix(stashDir, kind)}${unique}`);
223
+ }
224
+ /**
225
+ * Age gate for the staging sweep. Nothing enforces one refresh at a time for a
226
+ * given website source, so a sibling directory may belong to a refresh that is
227
+ * still running in another process; deleting it would break a healthy run
228
+ * instead of cleaning up after a dead one. Only clearly-abandoned directories
229
+ * (untouched for an hour — far longer than the 10-minute crawl wall-clock cap)
230
+ * are swept. Leftovers are inert until then: they are dot-prefixed, so the
231
+ * indexer's walk skips them.
232
+ */
233
+ const SNAPSHOT_STAGING_SWEEP_AGE_MS = 60 * 60 * 1000;
234
+ /** Remove staging/retired directories abandoned by an earlier interrupted run. */
235
+ function sweepSnapshotStaging(stashDir) {
236
+ const parent = path.dirname(stashDir);
237
+ let entries;
238
+ try {
239
+ entries = fs.readdirSync(parent);
240
+ }
241
+ catch {
242
+ return;
243
+ }
244
+ const prefixes = [snapshotSiblingPrefix(stashDir, "staging"), snapshotSiblingPrefix(stashDir, "retired")];
245
+ const cutoff = Date.now() - SNAPSHOT_STAGING_SWEEP_AGE_MS;
246
+ for (const entry of entries) {
247
+ if (!prefixes.some((prefix) => entry.startsWith(prefix)))
248
+ continue;
249
+ const abandoned = path.join(parent, entry);
250
+ try {
251
+ if (fs.statSync(abandoned).mtimeMs > cutoff)
252
+ continue;
253
+ }
254
+ catch {
255
+ continue;
256
+ }
257
+ fs.rmSync(abandoned, { recursive: true, force: true });
258
+ }
259
+ }
260
+ function beginSnapshotStaging(stashDir) {
261
+ fs.mkdirSync(path.dirname(stashDir), { recursive: true });
262
+ sweepSnapshotStaging(stashDir);
263
+ const dir = snapshotSiblingPath(stashDir, "staging");
264
+ fs.mkdirSync(dir, { recursive: true });
265
+ return { dir, target: stashDir };
266
+ }
267
+ /**
268
+ * Swap the staged snapshot in. POSIX cannot atomically exchange two non-empty
269
+ * directories, so the previous snapshot is renamed ASIDE first and deleted
270
+ * afterwards: the window in which the target does not exist is one syscall
271
+ * wide instead of an entire write loop.
272
+ */
273
+ function publishSnapshotStaging(staging) {
274
+ let retired;
275
+ if (fs.existsSync(staging.target)) {
276
+ retired = snapshotSiblingPath(staging.target, "retired");
277
+ fs.renameSync(staging.target, retired);
278
+ }
279
+ fs.renameSync(staging.dir, staging.target);
280
+ if (retired)
281
+ fs.rmSync(retired, { recursive: true, force: true });
282
+ }
283
+ /** Drop an unpublished staging directory (no-op once it has been renamed). */
284
+ function discardSnapshotStaging(staging) {
285
+ fs.rmSync(staging.dir, { recursive: true, force: true });
286
+ }
287
+ /** Materialize a single fetcher snapshot as the source's whole stash. */
288
+ function writeSnapshotToStash(stashDir, snapshot) {
289
+ const preferredName = snapshot.preferredName ?? deriveImportPath(snapshot.url);
290
+ const relPath = avoidReservedBasename(preferredName);
291
+ // Validate against the FINAL location so the guarantee (and the error text)
292
+ // is independent of where the file is staged.
293
+ const knowledgeDir = path.join(stashDir, "knowledge");
294
+ if (!isWithin(path.resolve(knowledgeDir, `${relPath}.md`), knowledgeDir)) {
295
+ throw new UsageError(`Snapshot fetcher returned an unsafe preferred name: ${JSON.stringify(preferredName)}`);
296
+ }
297
+ const staging = beginSnapshotStaging(stashDir);
298
+ try {
299
+ const filePath = path.resolve(path.join(staging.dir, "knowledge"), `${relPath}.md`);
300
+ fs.mkdirSync(path.dirname(filePath), { recursive: true });
301
+ const slug = relPath.split("/").pop() ?? "index";
302
+ fs.writeFileSync(filePath, buildMarkdownSnapshot({ url: snapshot.url, title: snapshot.title, markdown: snapshot.markdown }, slug, snapshot.tags), "utf8");
303
+ snapshotWriteHook({ point: "page-written", index: 1, total: 1, relPath: `knowledge/${relPath}.md` });
304
+ publishSnapshotStaging(staging);
305
+ }
306
+ finally {
307
+ discardSnapshotStaging(staging);
308
+ }
309
+ }
120
310
  async function scrapeWebsiteToStash(startUrl, stashDir, options) {
311
+ // Offer the URL to the specialized fetchers before falling back to a crawl.
312
+ // Without this, `akm bundle add <feed|profile URL>` reaches only the generic
313
+ // crawler and the RSS/Bluesky/X/YouTube fetchers are unreachable outside the
314
+ // `akm import` path. A fetcher returning null falls through to the crawl.
315
+ const fetched = await fetchSnapshotViaRegistry(startUrl, options.fetcherStashDir ?? null, options.allowPrivateHosts, options.resolveSecret);
316
+ if (fetched) {
317
+ writeSnapshotToStash(stashDir, fetched);
318
+ return;
319
+ }
121
320
  const pages = await crawlWebsite(startUrl, options);
122
321
  if (pages.length === 0) {
123
322
  throw new Error(`No content could be scraped from ${startUrl}`);
124
323
  }
125
- fs.rmSync(stashDir, { recursive: true, force: true });
126
- const knowledgeDir = path.join(stashDir, "knowledge");
127
- fs.mkdirSync(knowledgeDir, { recursive: true });
128
- const usedPaths = new Set();
129
- for (const page of pages) {
130
- const relPath = avoidReservedBasename(urlToRelativePath(page.url));
131
- const uniquePath = uniqueSlug(relPath, usedPaths);
132
- const filePath = path.join(knowledgeDir, `${uniquePath}.md`);
133
- const dir = path.dirname(filePath);
134
- if (dir !== knowledgeDir)
135
- fs.mkdirSync(dir, { recursive: true });
136
- const slug = uniquePath.split("/").pop() ?? "index";
137
- fs.writeFileSync(filePath, buildMarkdownSnapshot(page, slug), "utf8");
324
+ const staging = beginSnapshotStaging(stashDir);
325
+ try {
326
+ const knowledgeDir = path.join(staging.dir, "knowledge");
327
+ fs.mkdirSync(knowledgeDir, { recursive: true });
328
+ const usedPaths = new Set();
329
+ let written = 0;
330
+ for (const page of pages) {
331
+ const relPath = avoidReservedBasename(urlToRelativePath(page.url));
332
+ const uniquePath = uniqueSlug(relPath, usedPaths);
333
+ const filePath = path.join(knowledgeDir, `${uniquePath}.md`);
334
+ const dir = path.dirname(filePath);
335
+ if (dir !== knowledgeDir)
336
+ fs.mkdirSync(dir, { recursive: true });
337
+ const slug = uniquePath.split("/").pop() ?? "index";
338
+ fs.writeFileSync(filePath, buildMarkdownSnapshot(page, slug), "utf8");
339
+ written++;
340
+ snapshotWriteHook({
341
+ point: "page-written",
342
+ index: written,
343
+ total: pages.length,
344
+ relPath: `knowledge/${uniquePath}.md`,
345
+ });
346
+ }
347
+ publishSnapshotStaging(staging);
348
+ }
349
+ finally {
350
+ discardSnapshotStaging(staging);
138
351
  }
139
352
  }
140
353
  export async function fetchWebsiteMarkdownSnapshot(rawUrl, options) {
@@ -145,24 +358,37 @@ export async function fetchWebsiteMarkdownSnapshot(rawUrl, options) {
145
358
  stashDir: stashDir ?? "",
146
359
  timeoutMs: options?.timeoutMs ?? 15_000,
147
360
  signal: options?.signal,
361
+ ...(options?.resolveSecret ? { resolveSecret: options.resolveSecret } : {}),
362
+ ...(options?.allowPrivateHosts ? { allowPrivateHosts: true } : {}),
148
363
  };
149
- for (const fetcher of await loadWikiSnapshotFetchers(stashDir)) {
364
+ const snapshot = await dispatchSnapshotFetchers(parsedUrl, context, stashDir);
365
+ if (snapshot)
366
+ return websiteMarkdownSnapshotFromResult(snapshot);
367
+ const fetchedResponse = await fetchWebsiteResponse(normalizedUrl, 0, {
368
+ allowPrivateHosts: options?.allowPrivateHosts,
369
+ signal: options?.signal,
370
+ });
371
+ const finalUrl = normalizeCrawlUrl(fetchedResponse.finalUrl) ?? normalizedUrl;
372
+ if (finalUrl !== normalizedUrl) {
373
+ let redirectedSnapshot;
150
374
  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);
375
+ redirectedSnapshot = await dispatchSnapshotFetchers(new URL(finalUrl), context, stashDir);
157
376
  }
158
377
  catch (error) {
159
- warn("[akm] wiki-fetcher %s threw on %s: %s", fetcher.name, normalizedUrl, error instanceof Error ? error.message : String(error));
378
+ await fetchedResponse.response.body?.cancel().catch(() => undefined);
379
+ throw error;
380
+ }
381
+ if (redirectedSnapshot) {
382
+ await fetchedResponse.response.body?.cancel().catch(() => undefined);
383
+ return websiteMarkdownSnapshotFromResult(redirectedSnapshot);
160
384
  }
161
385
  }
162
- const fetched = await fetchWebsitePage(normalizedUrl, { allowPrivateHosts: options?.allowPrivateHosts });
163
- if (!fetched) {
386
+ const fetched = await websitePageFromResponse(fetchedResponse, normalizedUrl, {
387
+ allowPrivateHosts: options?.allowPrivateHosts,
388
+ signal: options?.signal,
389
+ });
390
+ if (!fetched)
164
391
  throw new UsageError(`No content could be fetched from ${normalizedUrl}`);
165
- }
166
392
  return websiteMarkdownSnapshotFromResult({
167
393
  url: fetched.page.url,
168
394
  title: fetched.page.title,
@@ -184,13 +410,168 @@ function websiteMarkdownSnapshotFromResult(snapshot) {
184
410
  }, slug || "website", snapshot.tags),
185
411
  };
186
412
  }
413
+ function sleep(ms) {
414
+ return new Promise((resolve) => setTimeout(resolve, ms));
415
+ }
416
+ /**
417
+ * Evaluates a URL against robots.txt, checking both `normalizedUrl` — the
418
+ * form akm treats as canonical for dedup/storage, with any bare trailing
419
+ * slash already stripped by `normalizeCrawlUrl`/`normalizeSiteUrl` — and,
420
+ * when it differs, `rawUrl`: the URL exactly as discovered (a link's literal
421
+ * `href`), as-supplied (the user's typed start URL), or redirected-to (a
422
+ * `Location` header), before any such stripping. Delegates the actual
423
+ * allow/disallow decision to {@link decideRobotsAllowance}'s asymmetric
424
+ * matrix — see its doc comment — rather than a plain AND of both forms: a
425
+ * `Disallow: /dir/`-shaped rule needs the un-stripped `rawUrl` to ever match
426
+ * (closing that gap), while an `Allow: /docs/`-shaped rule needs it too, in
427
+ * the *other* direction (a normalized-disallowed-but-raw-allowed URL must
428
+ * still be treated as allowed here, not just at the point where akm chooses
429
+ * which literal URL to fetch — a URL reaching this function has already been
430
+ * fetched, under whichever form `crawlWebsite`/`resolveCrawlRobotsDecision`
431
+ * selected, so only the `allowed` verdict matters here, never `fetchUrl`).
432
+ *
433
+ * `normalizeCrawlUrl`/`normalizeSiteUrl` strip a bare trailing slash before
434
+ * any robots check ever runs, so a `Disallow: /dir/`-shaped rule — which
435
+ * requires a literal trailing `/` in the target, see
436
+ * `matchesCompiledPattern`'s prefix check — can never match the stripped
437
+ * alias. Checking the un-stripped `rawUrl` closes that gap without changing
438
+ * what akm treats as the canonical URL for storage/dedup, and (crucially)
439
+ * without over-blocking a URL that never had a trailing slash to begin with
440
+ * — e.g. a `/secret` link that happens to redirect to `/secret/`: the
441
+ * pre-redirect request itself is unaffected by a `Disallow: /secret/` rule,
442
+ * only the redirect target is.
443
+ */
444
+ async function isCrawlUrlAllowedByRobots(robots, normalizedUrl, rawUrl) {
445
+ return (await resolveCrawlRobotsDecision(robots, normalizedUrl, rawUrl)).allowed;
446
+ }
447
+ /**
448
+ * Decides whether `normalizedUrl` (akm's canonical form — dedup/cache key,
449
+ * with any bare trailing slash already stripped by `normalizeCrawlUrl`/
450
+ * `normalizeSiteUrl`) may be crawled, and which literal URL to actually
451
+ * request.
452
+ *
453
+ * A `Disallow: /dir/`-shaped rule requires a literal trailing `/` in the
454
+ * target (see `matchesCompiledPattern`'s prefix check), so it can never match
455
+ * the slash-stripped normalized alias — checking the un-stripped `rawUrl` (a
456
+ * link's literal `href`, the user's as-typed start URL, or a redirect
457
+ * `Location`) closes that gap. Symmetrically, an `Allow: /docs/`-shaped rule
458
+ * requires that same trailing `/` to match, so a start URL or link typed as
459
+ * `.../docs/` under `Disallow: / \n Allow: /docs/` is allowed in its raw form
460
+ * but disallowed once normalized — over-blocking a site the owner explicitly
461
+ * opened to crawlers.
462
+ *
463
+ * Resolution matrix (raw form only consulted when it differs from normalized):
464
+ * - normalized allowed, raw allowed (or no distinct raw) => allowed, fetch normalized
465
+ * - normalized allowed, raw disallowed => BLOCKED (raw wins: closes the Disallow: /dir/ gap)
466
+ * - normalized disallowed, raw allowed => allowed, fetch the RAW url (closes the Allow: /docs/ gap)
467
+ * - normalized disallowed, raw disallowed => BLOCKED
468
+ *
469
+ * Only the third row switches the fetch target; every other row fetches the
470
+ * normalized form akm already treats as canonical. Do not collapse this to a
471
+ * plain OR of the two checks — that would also flip the second row to
472
+ * "allowed" and reopen the `Disallow: /dir/` bypass this matrix exists to
473
+ * close.
474
+ */
475
+ function decideRobotsAllowance(rules, normalizedUrl, rawUrl) {
476
+ const normalizedAllowed = isPathAllowedByRobots(rules, normalizedUrl);
477
+ if (!rawUrl || rawUrl === normalizedUrl) {
478
+ return { allowed: normalizedAllowed, fetchUrl: normalizedUrl };
479
+ }
480
+ const rawAllowed = isPathAllowedByRobots(rules, rawUrl);
481
+ if (!normalizedAllowed && rawAllowed) {
482
+ return { allowed: true, fetchUrl: rawUrl };
483
+ }
484
+ return { allowed: normalizedAllowed && rawAllowed, fetchUrl: normalizedUrl };
485
+ }
486
+ /**
487
+ * Async wrapper of {@link decideRobotsAllowance} for call sites holding a
488
+ * `RobotsPolicy` (which resolves/caches rules per origin) rather than an
489
+ * already-fetched `RobotsRuleSet`.
490
+ */
491
+ async function resolveCrawlRobotsDecision(robots, normalizedUrl, rawUrl) {
492
+ const rules = await robots.rulesFor(normalizedUrl);
493
+ return decideRobotsAllowance(rules, normalizedUrl, rawUrl);
494
+ }
495
+ /**
496
+ * C-02/C-03: fail fast, before any page fetch, when the start URL itself is
497
+ * off-limits. A 5xx robots.txt (RobotsPolicy caches `DISALLOW_ALL_RULES` for
498
+ * that case) gets a distinct message calling out the server error, per spec
499
+ * §4.6.
500
+ *
501
+ * Checks both `start`'s (normalized) URL and `rawStartUrl` — the URL exactly
502
+ * as the user supplied it in config, before `validateWebsiteUrl` ->
503
+ * `normalizeSiteUrl` stripped any trailing slash — via
504
+ * `decideRobotsAllowance`. Without this, a start URL typed as `.../secret/`
505
+ * under `Disallow: /secret/` would never match that rule and would be
506
+ * crawled instead of rejected with the spec §4.6 C-02 UsageError; conversely,
507
+ * a start URL typed as `.../docs/` under `Disallow: / \n Allow: /docs/`
508
+ * would be normalized to `.../docs`, fail to match `Allow: /docs/`, and be
509
+ * rejected even though the site owner explicitly opened `/docs/` to
510
+ * crawlers. `crawlWebsite`'s queue gate applies the same decision (and, in
511
+ * the Allow case, actually fetches the raw URL this function only validates
512
+ * against) — see its call to `resolveCrawlRobotsDecision`.
513
+ */
514
+ async function assertStartUrlAllowedByRobots(robots, start, rawStartUrl) {
515
+ const startUrl = start.toString();
516
+ const robotsUrl = new URL("/robots.txt", start.origin).toString();
517
+ const rules = await robots.rulesFor(startUrl);
518
+ if (rules.disallowAll) {
519
+ throw new UsageError(`Refusing to crawl ${startUrl}: ${robotsUrl} returned a server error, which robots.txt conventions ` +
520
+ `treat as a full disallow until it recovers. Set respectRobots: false on this website source to bypass ` +
521
+ `robots.txt.`);
522
+ }
523
+ let rawStartUrlNormalized;
524
+ if (rawStartUrl) {
525
+ try {
526
+ rawStartUrlNormalized = new URL(rawStartUrl).toString();
527
+ }
528
+ catch {
529
+ rawStartUrlNormalized = undefined;
530
+ }
531
+ }
532
+ const { allowed } = decideRobotsAllowance(rules, startUrl, rawStartUrlNormalized);
533
+ if (!allowed) {
534
+ throw new UsageError(`Refusing to crawl ${startUrl}: disallowed by ${robotsUrl}. Set respectRobots: false on this website ` +
535
+ `source to bypass robots.txt.`);
536
+ }
537
+ }
187
538
  async function crawlWebsite(startUrl, options) {
188
539
  const start = new URL(normalizeSiteUrl(startUrl));
189
540
  const allowedOrigin = start.origin;
190
- const queue = [{ url: start.toString(), depth: 0 }];
541
+ const queue = [
542
+ { url: start.toString(), rawUrl: options.rawStartUrl ?? start.toString(), depth: 0, deferrals: 0 },
543
+ ];
191
544
  const visited = new Set();
192
545
  const pages = [];
193
- const deadline = Date.now() + WEBSITE_CRAWL_WALL_CLOCK_MS;
546
+ // Precedence: the test-only seam, then the user's `crawlTimeoutMs`, then the
547
+ // default. `crawlTimeoutMs: 0` / `false` disables the cap outright, for a
548
+ // deliberately long-running crawl the user is willing to babysit.
549
+ const configuredCapMs = options.crawlTimeoutMs === null ? null : (options.crawlTimeoutMs ?? WEBSITE_CRAWL_WALL_CLOCK_MS);
550
+ const wallClockCapMs = options.wallClockCapMs ?? configuredCapMs;
551
+ const capDisabled = wallClockCapMs === null;
552
+ const deadline = capDisabled ? Number.POSITIVE_INFINITY : Date.now() + wallClockCapMs;
553
+ // Between-iteration deadline checks cannot interrupt work already in
554
+ // flight: a single request's `Retry-After` sleep, or a slow body read, can
555
+ // run far past the cap on its own. This signal makes the cap a HARD limit —
556
+ // it aborts the in-flight fetch and the retry sleep alike.
557
+ const abortController = new AbortController();
558
+ const capTimer = capDisabled
559
+ ? undefined
560
+ : setTimeout(() => abortController.abort(new Error(`Website crawl exceeded its ${wallClockCapMs / 1000}s limit`)), wallClockCapMs);
561
+ const crawlSignal = abortController.signal;
562
+ const robots = options.respectRobots === false
563
+ ? createAllowAllRobotsPolicy()
564
+ : createRobotsPolicy((robotsUrl) => loadRobotsTxt(robotsUrl, { allowPrivateHosts: options.allowPrivateHosts, signal: crawlSignal }));
565
+ await assertStartUrlAllowedByRobots(robots, start, options.rawStartUrl);
566
+ // Counts actual `fetchWebsitePage` invocations (regardless of outcome) so
567
+ // Crawl-delay pacing skips the first fetch and never charges a delay slot
568
+ // to a URL that robots.txt skipped without ever being fetched (C-11).
569
+ let fetchAttempts = 0;
570
+ // URLs pushed back because their Crawl-delay would not fit in the remaining
571
+ // budget, and URLs that ran out of retries entirely. Reported at the end so
572
+ // a rate-limited origin is visible rather than silently missing.
573
+ const deferred = new Set();
574
+ const unfetched = new Set();
194
575
  while (queue.length > 0 && pages.length < options.maxPages) {
195
576
  if (Date.now() > deadline)
196
577
  break;
@@ -200,8 +581,49 @@ async function crawlWebsite(startUrl, options) {
200
581
  const normalized = normalizeCrawlUrl(next.url);
201
582
  if (!normalized || visited.has(normalized))
202
583
  continue;
584
+ const decision = await resolveCrawlRobotsDecision(robots, normalized, next.rawUrl);
585
+ if (!decision.allowed) {
586
+ // Deliberately NOT marked visited. `/docs/` and `/docs` share a
587
+ // normalized key but get different robots verdicts (a `Disallow: /docs/`
588
+ // rule matches only the trailing-slash form). Marking the key visited
589
+ // here would let whichever alias happened to be discovered first — and
590
+ // was then rejected — permanently suppress the allowed alias, making
591
+ // crawl coverage depend on link order. Robots rules are cached per
592
+ // origin, so re-evaluating a repeated disallowed alias is cheap.
593
+ warnVerbose("[akm] website crawl: skipping %s (disallowed by robots.txt)", normalized);
594
+ continue;
595
+ }
596
+ if (fetchAttempts > 0) {
597
+ const delayMs = await robots.crawlDelayMs(normalized);
598
+ if (delayMs > 0) {
599
+ if (Date.now() + delayMs >= deadline) {
600
+ // Sleeping this one out would blow the wall-clock cap. Defer it to
601
+ // the back of the queue instead of ending the crawl here: other
602
+ // origins may have no Crawl-delay and can still be fetched with the
603
+ // time that remains. Anything still deferred when the deadline
604
+ // arrives is reported as unfetched below rather than silently
605
+ // dropped. `deferred` is bounded so a queue of delayed URLs cannot
606
+ // spin forever re-appending to itself.
607
+ if (next.deferrals < MAX_CRAWL_DEFERRALS) {
608
+ queue.push({ ...next, deferrals: next.deferrals + 1 });
609
+ deferred.add(normalized);
610
+ }
611
+ else {
612
+ unfetched.add(normalized);
613
+ }
614
+ continue;
615
+ }
616
+ await sleep(delayMs);
617
+ }
618
+ }
203
619
  visited.add(normalized);
204
- const fetched = await fetchWebsitePage(normalized, { allowPrivateHosts: options.allowPrivateHosts });
620
+ deferred.delete(normalized);
621
+ fetchAttempts++;
622
+ const fetched = await fetchWebsitePage(decision.fetchUrl, {
623
+ allowPrivateHosts: options.allowPrivateHosts,
624
+ robots,
625
+ signal: crawlSignal,
626
+ });
205
627
  if (!fetched)
206
628
  continue;
207
629
  pages.push(fetched.page);
@@ -212,20 +634,59 @@ async function crawlWebsite(startUrl, options) {
212
634
  break;
213
635
  if (link.origin !== allowedOrigin)
214
636
  continue;
215
- const candidate = normalizeCrawlUrl(link.toString());
637
+ const rawLinkUrl = link.toString();
638
+ const candidate = normalizeCrawlUrl(rawLinkUrl);
216
639
  if (!candidate || visited.has(candidate) || isAssetLikePath(link.pathname))
217
640
  continue;
218
- queue.push({ url: candidate, depth: next.depth + 1 });
641
+ queue.push({ url: candidate, rawUrl: rawLinkUrl, depth: next.depth + 1, deferrals: 0 });
219
642
  }
220
643
  }
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);
644
+ if (!capDisabled && Date.now() > deadline) {
645
+ warn("[akm] website crawl stopped at the %ds wall-clock cap with %d/%d pages collected from %s. " +
646
+ "Raise crawlTimeoutMs on this website source, or set it to 0 to disable the cap.", wallClockCapMs / 1000, pages.length, options.maxPages, startUrl);
647
+ }
648
+ if (capTimer)
649
+ clearTimeout(capTimer);
650
+ // A URL still deferred when the loop ends never got fetched — report it
651
+ // rather than letting a rate-limited origin go missing without a trace.
652
+ for (const url of deferred)
653
+ unfetched.add(url);
654
+ if (unfetched.size > 0) {
655
+ warn("[akm] website crawl: %d URL(s) were not fetched — their origin's Crawl-delay did not fit in the " +
656
+ "remaining time budget. First: %s", unfetched.size, [...unfetched].slice(0, 3).join(", "));
223
657
  }
224
658
  return pages;
225
659
  }
660
+ /**
661
+ * Sentinel thrown by `fetchWebsiteResponse` when a redirect hop's target is
662
+ * disallowed by robots.txt (see the doc comment above the check in
663
+ * `fetchWebsiteResponse`). Never escapes `fetchWebsitePage`, which maps it to
664
+ * `null` — the same "page skipped, no error" outcome as any other
665
+ * robots-disallowed URL. Not exported; purely an internal control-flow
666
+ * signal between the two functions.
667
+ */
668
+ class RobotsDisallowedRedirectError extends Error {
669
+ constructor(url) {
670
+ super(`robots.txt disallows redirect target ${url}`);
671
+ this.name = "RobotsDisallowedRedirectError";
672
+ }
673
+ }
226
674
  async function fetchWebsitePage(pageUrl, options) {
227
- const response = await fetchWebsiteResponse(pageUrl, 0, options);
675
+ let fetchedResponse;
676
+ try {
677
+ fetchedResponse = await fetchWebsiteResponse(pageUrl, 0, options);
678
+ }
679
+ catch (err) {
680
+ if (err instanceof RobotsDisallowedRedirectError)
681
+ return null;
682
+ throw err;
683
+ }
684
+ return websitePageFromResponse(fetchedResponse, pageUrl, options);
685
+ }
686
+ async function websitePageFromResponse(fetched, pageUrl, options) {
687
+ const { response } = fetched;
228
688
  if (!response.ok) {
689
+ await response.body?.cancel().catch(() => undefined);
229
690
  if (response.status === 404)
230
691
  return null;
231
692
  throw new Error(`Failed to fetch website content (${response.status}) from ${pageUrl}`);
@@ -235,6 +696,7 @@ async function fetchWebsitePage(pageUrl, options) {
235
696
  try {
236
697
  body = await readBodyWithByteCap(response, WEBSITE_PAGE_BYTE_CAP, {
237
698
  bodyTimeoutMs: WEBSITE_PAGE_BODY_TIMEOUT_MS,
699
+ signal: options?.signal,
238
700
  });
239
701
  }
240
702
  catch (err) {
@@ -242,24 +704,40 @@ async function fetchWebsitePage(pageUrl, options) {
242
704
  return null;
243
705
  throw err;
244
706
  }
245
- const finalUrl = normalizeCrawlUrl(response.url || pageUrl) ?? pageUrl;
707
+ const rawFinalUrl = fetched.finalUrl;
708
+ const finalUrl = normalizeCrawlUrl(rawFinalUrl) ?? pageUrl;
246
709
  assertWebsiteRequestUrl(finalUrl, Error, options);
247
- if (contentType.includes("text/html") || contentType.includes("application/xhtml+xml") || looksLikeMarkup(body)) {
710
+ // Re-check robots.txt against the FINAL (post-redirect) URL, not just the
711
+ // pre-redirect URL crawlWebsite already gated. normalizeCrawlUrl strips
712
+ // trailing slashes before the initial gate, so a rule shaped like
713
+ // `Disallow: /secret/` correctly lets `/secret` through that gate; if the
714
+ // server then redirects to `/secret/` (a common trailing-slash
715
+ // canonicalization), the disallowed page would otherwise be fetched and
716
+ // stored without ever being weighed against robots.txt. See spec §4.6 C-04.
717
+ // Checks `rawFinalUrl` (the un-normalized `response.url`, slash intact) as
718
+ // well as `finalUrl`, per `isCrawlUrlAllowedByRobots` — `normalizeCrawlUrl`
719
+ // would otherwise strip the very trailing slash a `Disallow: /secret/`
720
+ // rule needs to match.
721
+ if (options?.robots && !(await isCrawlUrlAllowedByRobots(options.robots, finalUrl, rawFinalUrl))) {
722
+ warnVerbose("[akm] website crawl: skipping %s (disallowed by robots.txt after redirect)", finalUrl);
723
+ return null;
724
+ }
725
+ if (contentType.includes("text/html") ||
726
+ contentType.includes("application/xhtml+xml") ||
727
+ (!contentType && looksLikeMarkup(body))) {
248
728
  const title = extractHtmlTitle(body) || new URL(finalUrl).hostname;
729
+ // One parse yields both the content Markdown and the whole-document links.
730
+ const { markdown, links } = htmlToMarkdownAndLinks(body, finalUrl);
249
731
  return {
250
- page: {
251
- url: finalUrl,
252
- title,
253
- markdown: htmlToMarkdown(body, finalUrl),
254
- },
255
- links: extractSameDocumentLinks(body, finalUrl),
732
+ page: { url: finalUrl, title, markdown },
733
+ links,
256
734
  };
257
735
  }
258
736
  return {
259
737
  page: {
260
738
  url: finalUrl,
261
739
  title: extractTextTitle(body) || new URL(finalUrl).hostname,
262
- markdown: body.trim(),
740
+ markdown: plainTextToMarkdown(body),
263
741
  },
264
742
  links: [],
265
743
  };
@@ -277,25 +755,145 @@ async function fetchWebsiteResponse(pageUrl, redirectCount = 0, options) {
277
755
  "User-Agent": "akm-cli website provider",
278
756
  },
279
757
  redirect: "manual",
758
+ ...(options?.signal ? { signal: options.signal } : {}),
280
759
  }, { timeout: 15_000, retries: 1 });
281
760
  if (response.status >= 300 && response.status < 400) {
282
761
  if (redirectCount >= WEBSITE_MAX_REDIRECTS) {
762
+ await response.body?.cancel().catch(() => undefined);
283
763
  throw new Error(`Too many redirects while fetching ${pageUrl}`);
284
764
  }
285
765
  const location = response.headers.get("location");
286
766
  if (!location) {
767
+ await response.body?.cancel().catch(() => undefined);
287
768
  throw new Error(`Redirect response from ${pageUrl} did not include a Location header`);
288
769
  }
770
+ await response.body?.cancel().catch(() => undefined);
289
771
  const nextUrl = new URL(location, pageUrl).toString();
290
- assertWebsiteRequestUrl(nextUrl, Error, options);
772
+ try {
773
+ assertWebsiteRequestUrl(nextUrl, Error, options);
774
+ }
775
+ catch (error) {
776
+ if (options?.robots) {
777
+ warnVerbose("[akm] website crawl: skipping unsafe redirect to %s", nextUrl);
778
+ throw new RobotsDisallowedRedirectError(nextUrl);
779
+ }
780
+ throw error;
781
+ }
782
+ // Robots-check every intermediate redirect hop, not just the pre-redirect
783
+ // queue URL (`crawlWebsite`'s gate) and the FINAL URL (the post-redirect
784
+ // recheck below in `fetchWebsitePage`). Without this, a chain like
785
+ // `/go` -> 302 `/secret/` -> 302 `/public` issues a live GET to
786
+ // `/secret/` even when robots.txt disallows it, because that hop is
787
+ // never the queue URL and never the final URL. Only gated when a
788
+ // `RobotsPolicy` was actually threaded in (`crawlWebsite`); single-URL
789
+ // `fetchWebsiteMarkdownSnapshot` fetches stay deliberately ungated per
790
+ // spec §1.
791
+ if (options?.robots) {
792
+ const normalizedNext = normalizeCrawlUrl(nextUrl);
793
+ if (!normalizedNext) {
794
+ // `normalizeCrawlUrl` returns null for anything that isn't http(s) —
795
+ // a redirect `Location` can legally point at `mailto:`, `tel:`, a
796
+ // bare relative path that resolves to an opaque scheme, etc.
797
+ // `RobotsPolicy.rulesFor` computes `new URL(url).origin` and then
798
+ // resolves `/robots.txt` against it; for a non-http(s) URL that
799
+ // origin is the literal string "null", and re-resolving against it
800
+ // throws an unhandled TypeError that aborts the whole crawl. Refuse
801
+ // the hop outright instead of ever handing such a URL to the policy —
802
+ // `fetchWebsitePage` maps this to a graceful skip. Regression
803
+ // introduced by a67412c, which fell back to the raw `nextUrl` here.
804
+ warnVerbose("[akm] website crawl: skipping redirect to %s (not an http(s) URL)", nextUrl);
805
+ throw new RobotsDisallowedRedirectError(nextUrl);
806
+ }
807
+ if (!(await isCrawlUrlAllowedByRobots(options.robots, normalizedNext, nextUrl))) {
808
+ warnVerbose("[akm] website crawl: skipping redirect to %s (disallowed by robots.txt)", nextUrl);
809
+ throw new RobotsDisallowedRedirectError(nextUrl);
810
+ }
811
+ }
291
812
  return fetchWebsiteResponse(nextUrl, redirectCount + 1, options);
292
813
  }
293
- return response;
814
+ return { response, finalUrl: pageUrl };
815
+ }
816
+ /**
817
+ * Fetches and classifies `<origin>/robots.txt`. Reuses `fetchWebsiteResponse`
818
+ * (spec §6.2: no second fetch path) so robots.txt gets the exact same SSRF
819
+ * guards, retry, and redirect handling as a page fetch.
820
+ *
821
+ * Steps 1–2 (the guard on the INITIAL URL) run OUTSIDE the try/catch on
822
+ * purpose: a guard rejection there must propagate as-is, never be downgraded
823
+ * to "unavailable" (spec §4.5 F-12, §6.2). Guard rejections on a LATER
824
+ * redirect hop happen inside `fetchWebsiteResponse`, which the try/catch
825
+ * below does cover — the guard has already refused to fetch that host, so
826
+ * only the error *reporting* is downgraded (F-11).
827
+ */
828
+ export async function loadRobotsTxt(robotsUrl, options) {
829
+ assertWebsiteRequestUrl(robotsUrl, UsageError, options);
830
+ await assertResolvedHostAllowed(new URL(robotsUrl).hostname, options);
831
+ try {
832
+ const { response } = await fetchWebsiteResponse(robotsUrl, 0, options);
833
+ if (response.status >= 200 && response.status < 300) {
834
+ try {
835
+ const text = await readBodyWithByteCap(response, ROBOTS_BYTE_CAP, {
836
+ bodyTimeoutMs: ROBOTS_BODY_TIMEOUT_MS,
837
+ signal: options?.signal,
838
+ });
839
+ return { kind: "body", text };
840
+ }
841
+ catch (err) {
842
+ if (err instanceof ResponseTooLargeError) {
843
+ warn("[akm] robots.txt at %s exceeded the %d-byte cap; treating it as unavailable.", robotsUrl, ROBOTS_BYTE_CAP);
844
+ return { kind: "unavailable" };
845
+ }
846
+ throw err;
847
+ }
848
+ }
849
+ if (response.status >= 500 && response.status < 600) {
850
+ await response.body?.cancel().catch(() => undefined);
851
+ // RFC 9309 §2.3.1.4: an unreachable robots.txt is a full disallow, not
852
+ // an allow-all. `fetchWithRetry` already retried this once, so a
853
+ // transient blip does not trip it.
854
+ warn("[akm] robots.txt at %s returned %d; treating the crawl as fully disallowed until it recovers. " +
855
+ "Set respectRobots: false on this website source to bypass robots.txt.", robotsUrl, response.status);
856
+ return { kind: "unreachable" };
857
+ }
858
+ // 4xx (404 is the common, silent case), and any other non-2xx/5xx status.
859
+ await response.body?.cancel().catch(() => undefined);
860
+ return { kind: "unavailable" };
861
+ }
862
+ catch (err) {
863
+ if (options?.signal?.aborted)
864
+ throw err;
865
+ warnVerbose("[akm] failed to fetch robots.txt at %s: %s", robotsUrl, err instanceof Error ? err.message : String(err));
866
+ return { kind: "unavailable" };
867
+ }
868
+ }
869
+ /**
870
+ * Coerces `SourceConfigEntry.options.respectRobots` to a boolean. The bundle
871
+ * descriptor is boolean-validated at config load (schema), but the legacy
872
+ * `sources[].options` bag is `z.record(z.unknown())` and accepts anything, so
873
+ * the runtime read still validates. A misspelled non-boolean opt-out fails
874
+ * loudly (`ConfigError`) rather than silently defaulting either way — the
875
+ * user would otherwise think robots.txt handling is something other than
876
+ * what akm is actually doing (spec §4.7).
877
+ */
878
+ export function coerceRespectRobots(value) {
879
+ if (value === undefined || value === null)
880
+ return true;
881
+ if (typeof value === "boolean")
882
+ return value;
883
+ if (typeof value === "string") {
884
+ const normalized = value.trim().toLowerCase();
885
+ if (normalized === "true")
886
+ return true;
887
+ if (normalized === "false")
888
+ return false;
889
+ }
890
+ throw new ConfigError(`Invalid value for respectRobots: expected a boolean (or "true"/"false"), got ${JSON.stringify(value)}.`);
294
891
  }
295
892
  function buildMarkdownSnapshot(page, slug, tags) {
296
893
  const title = sanitizeString(page.title, 200) || slug;
297
- const description = sanitizeString(`Snapshot of ${page.url}`, 500);
894
+ const heading = title.replace(/([\\[\]`*_])/g, "\\$1").replace(/<(?=[a-zA-Z/!?])/g, "&lt;");
298
895
  const host = sanitizeString(new URL(page.url).hostname, 120);
896
+ const description = sanitizeString(`Website snapshot from ${host}`, 500);
299
897
  const content = page.markdown.trim() || `Source: ${page.url}`;
300
898
  const normalizedTags = Array.from(new Set(["website", host, ...(tags ?? [])]));
301
899
  return [
@@ -304,11 +902,14 @@ function buildMarkdownSnapshot(page, slug, tags) {
304
902
  `description: ${JSON.stringify(description)}`,
305
903
  `sourceUrl: ${JSON.stringify(page.url)}`,
306
904
  `title: ${JSON.stringify(title)}`,
905
+ `updated: ${todayIso()}`,
906
+ "lint_skip:",
907
+ " - stale-path",
307
908
  "tags:",
308
909
  ...normalizedTags.map((tag) => ` - ${JSON.stringify(tag)}`),
309
910
  "---",
310
911
  "",
311
- `# ${title}`,
912
+ `# ${heading}`,
312
913
  "",
313
914
  `Source: ${page.url}`,
314
915
  "",
@@ -436,92 +1037,16 @@ function coercePositiveInt(value, fallback) {
436
1037
  function looksLikeMarkup(body) {
437
1038
  return /<html[\s>]|<body[\s>]|<\/[a-z][\w:-]*>/i.test(body);
438
1039
  }
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;
1040
+ function plainTextToMarkdown(body) {
1041
+ const inlineSafe = body
1042
+ .replace(/\r\n?/g, "\n")
1043
+ .replace(/([\\[\]`])/g, "\\$1")
1044
+ .replace(/<(?=[a-zA-Z/!?])/g, "&lt;");
1045
+ return escapeMarkdownStructure(inlineSafe).trim();
458
1046
  }
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;
1047
+ /** True for URL paths that are plainly binary assets, never crawlable pages. */
1048
+ function isAssetLikePath(pathname) {
1049
+ return /\.(css|js|json|png|jpe?g|gif|svg|ico|webp|pdf|zip|tar|gz|mp4|mp3|woff2?)$/i.test(pathname);
525
1050
  }
526
1051
  function stripTags(value) {
527
1052
  return value.replace(/<[^>]+>/g, " ");
@@ -546,148 +1071,25 @@ function decodeHtmlEntities(value) {
546
1071
  return namedEntities[normalized] ?? match;
547
1072
  });
548
1073
  }
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;
1074
+ function extractHtmlTitle(html) {
1075
+ const title = html.match(/<title[^>]*>([\s\S]*?)<\/title>/i)?.[1];
1076
+ if (title)
1077
+ return decodeHtmlEntities(stripTags(title)).trim();
1078
+ const h1 = html.match(/<h1[^>]*>([\s\S]*?)<\/h1>/i)?.[1];
1079
+ if (h1)
1080
+ return decodeHtmlEntities(stripTags(h1)).trim();
1081
+ return undefined;
620
1082
  }
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;
1083
+ function extractTextTitle(text) {
1084
+ for (const line of text.split(/\r?\n/)) {
1085
+ const trimmed = line.trim();
1086
+ if (!trimmed)
1087
+ continue;
1088
+ if (trimmed.startsWith("#"))
1089
+ return trimmed.replace(/^#+\s*/, "");
1090
+ return trimmed.slice(0, 120);
626
1091
  }
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, "");
1092
+ return undefined;
691
1093
  }
692
1094
  function safeCodePointToString(value) {
693
1095
  if (!Number.isFinite(value) || value < 0 || value > 0x10ffff)
@@ -699,3 +1101,5 @@ function safeCodePointToString(value) {
699
1101
  return undefined;
700
1102
  }
701
1103
  }
1104
+ // Re-exported for existing importers (the SSRF suite pins these entry points).
1105
+ export { assertResolvedHostAllowed } from "./host-guard.js";