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.
- package/CHANGELOG.md +1063 -44
- package/README.md +51 -25
- package/SECURITY.md +14 -1
- package/STABILITY.md +497 -0
- package/dist/akm +148 -35
- package/dist/{akm-migrate-storage → akm-migrate} +6 -9
- package/dist/assets/hints/cli-hints-full.md +223 -95
- package/dist/assets/hints/cli-hints-short.md +85 -22
- package/dist/assets/improve-strategies/default.json +1 -1
- package/dist/assets/improve-strategies/reflect-distill.json +1 -1
- package/dist/assets/prompts/memory-infer-user.md +2 -3
- package/dist/assets/stash-skeleton/README.md +6 -5
- package/dist/assets/stash-skeleton/facts/conventions/assets/agent.md +2 -0
- package/dist/assets/stash-skeleton/facts/conventions/assets/command.md +2 -0
- package/dist/assets/stash-skeleton/facts/conventions/assets/fact.md +2 -0
- package/dist/assets/stash-skeleton/facts/conventions/assets/knowledge.md +2 -0
- package/dist/assets/stash-skeleton/facts/conventions/assets/lesson.md +2 -0
- package/dist/assets/stash-skeleton/facts/conventions/assets/memory.md +2 -0
- package/dist/assets/stash-skeleton/facts/conventions/assets/script.md +2 -0
- package/dist/assets/stash-skeleton/facts/conventions/assets/skill.md +2 -0
- package/dist/assets/stash-skeleton/facts/conventions/assets/workflow.md +2 -0
- package/dist/assets/stash-skeleton/facts/conventions/backlinks.md +2 -0
- package/dist/assets/stash-skeleton/facts/conventions/domains.md +2 -0
- package/dist/assets/stash-skeleton/facts/conventions/organization.md +20 -9
- package/dist/assets/tasks/core/extract.yml +1 -1
- package/dist/assets/tasks/core/version-check.yml +1 -1
- package/dist/assets/tasks/improve/akm-graph-refresh-weekly.yml +5 -0
- package/dist/assets/tasks/improve/akm-improve-catchup.yml +8 -0
- package/dist/assets/tasks/improve/akm-improve-consolidate.yml +5 -0
- package/dist/assets/tasks/improve/akm-improve-frequent.yml +5 -0
- package/dist/assets/tasks/improve/akm-improve-nightly.yml +5 -0
- package/dist/assets/templates/html/health.html +1 -3
- package/dist/assets/workflows/workflow-template.md +32 -15
- package/dist/cli/invocation.js +40 -15
- package/dist/cli/parse-args.js +0 -22
- package/dist/cli/retired-commands.js +121 -0
- package/dist/cli/shared.js +154 -22
- package/dist/cli/unknown-flags.js +236 -0
- package/dist/cli-node.mjs +2 -1
- package/dist/cli.js +696 -258
- package/dist/commands/agent/agent-dispatch.js +14 -3
- package/dist/commands/agent/contribute-cli.js +73 -88
- package/dist/commands/completions.js +79 -22
- package/dist/commands/config-cli.js +17 -150
- package/dist/commands/env/env-cli.js +59 -143
- package/dist/commands/env/env.js +12 -163
- package/dist/commands/env/marker-path.js +6 -0
- package/dist/commands/env/secret-cli.js +36 -66
- package/dist/commands/env/secret.js +24 -57
- package/dist/commands/feedback-cli.js +141 -87
- package/dist/commands/health/accept-rate.js +58 -0
- package/dist/commands/health/advisories.js +3 -4
- package/dist/commands/health/checks.js +85 -23
- package/dist/commands/health/html-report.js +7 -10
- package/dist/commands/health/improve-metrics.js +25 -83
- package/dist/commands/health/md-report.js +5 -9
- package/dist/commands/health/metrics.js +62 -20
- package/dist/commands/health/renderers.js +47 -0
- package/dist/commands/health/report-view-model.js +4 -5
- package/dist/commands/health/stash-exposure.js +1 -1
- package/dist/commands/health/surfaces.js +3 -48
- package/dist/commands/health/task-runs.js +3 -67
- package/dist/commands/health/types-improve.js +7 -0
- package/dist/commands/health.js +99 -28
- package/dist/commands/improve/anti-collapse.js +2 -2
- package/dist/commands/improve/autonomy-gate.js +68 -0
- package/dist/commands/improve/collapse-detector.js +41 -40
- package/dist/commands/improve/consolidate/eligibility.js +1 -23
- package/dist/commands/improve/consolidate/merge.js +4 -0
- package/dist/commands/improve/consolidate.js +140 -1000
- package/dist/commands/improve/distill/promote-memory.js +12 -12
- package/dist/commands/improve/distill/quality-gate.js +6 -6
- package/dist/commands/improve/distill.js +58 -69
- package/dist/commands/improve/eligibility.js +95 -55
- package/dist/commands/improve/extract-cli.js +14 -133
- package/dist/commands/improve/improve-cli.js +98 -114
- package/dist/commands/improve/improve-result-file.js +1 -28
- package/dist/commands/improve/improve-strategies.js +8 -5
- package/dist/commands/improve/improve.js +128 -91
- package/dist/commands/improve/loop-stages.js +182 -20
- package/dist/commands/improve/memory/derived-ref.js +45 -43
- package/dist/commands/improve/memory/memory-belief.js +1 -1
- package/dist/commands/improve/memory/memory-contradiction-detect.js +4 -12
- package/dist/commands/improve/memory/memory-improve.js +6 -5
- package/dist/commands/improve/outcome-loop.js +22 -65
- package/dist/commands/improve/preparation.js +114 -123
- package/dist/commands/improve/proactive-maintenance.js +2 -5
- package/dist/commands/improve/reflect.js +56 -160
- package/dist/commands/improve/salience.js +11 -122
- package/dist/commands/improve/source-identity.js +10 -38
- package/dist/commands/lint/base-linter.js +20 -124
- package/dist/commands/lint/env-key-rules.js +31 -47
- package/dist/commands/lint/index.js +249 -43
- package/dist/commands/{events.js → log.js} +33 -38
- package/dist/commands/migrate-cli.js +92 -12
- package/dist/commands/migration-tool.js +46 -0
- package/dist/commands/observability-cli.js +70 -209
- package/dist/commands/proposal/drain.js +101 -29
- package/dist/commands/proposal/proposal-cli.js +76 -48
- package/dist/commands/proposal/proposal.js +54 -18
- package/dist/commands/proposal/propose-cli.js +88 -0
- package/dist/commands/proposal/propose.js +23 -15
- package/dist/commands/proposal/repository.js +701 -278
- package/dist/commands/proposal/validators/proposal-quality-validators.js +2 -8
- package/dist/commands/proposal/validators/proposal-validators.js +55 -7
- package/dist/commands/proposal/validators/proposals.js +4 -7
- package/dist/commands/read/curate.js +34 -53
- package/dist/commands/read/knowledge.js +150 -95
- package/dist/commands/read/registry-search.js +2 -2
- package/dist/commands/read/remember-cli.js +42 -15
- package/dist/commands/read/search-cli.js +180 -78
- package/dist/commands/read/search.js +58 -43
- package/dist/commands/read/show.js +197 -141
- package/dist/commands/registry-cli.js +12 -51
- package/dist/commands/remember.js +14 -57
- package/dist/commands/sources/add-cli.js +100 -31
- package/dist/commands/sources/bundle-cli.js +166 -0
- package/dist/commands/sources/bundle-config-ops.js +7 -2
- package/dist/commands/sources/info.js +18 -5
- package/dist/commands/sources/init.js +12 -12
- package/dist/commands/sources/installed-stashes.js +382 -98
- package/dist/commands/sources/schema-repair.js +3 -2
- package/dist/commands/sources/self-update.js +131 -38
- package/dist/commands/sources/source-add.js +72 -17
- package/dist/commands/sources/source-clone.js +129 -45
- package/dist/commands/sources/source-manage.js +43 -23
- package/dist/commands/sources/sources-cli.js +57 -208
- package/dist/commands/sources/stash-cli.js +46 -53
- package/dist/commands/tasks/tasks-cli.js +91 -97
- package/dist/commands/tasks/tasks.js +276 -421
- package/dist/commands/workflow-cli.js +175 -450
- package/dist/core/adapter/adapters/akm-adapter.js +47 -28
- package/dist/core/adapter/adapters/akm-lint.js +42 -27
- package/dist/core/adapter/adapters/akm-metadata.js +15 -44
- package/dist/core/adapter/adapters/akm-task-adapter.js +15 -13
- package/dist/core/adapter/adapters/akm-workflow-adapter.js +55 -71
- package/dist/core/adapter/adapters/dotenv-adapter.js +1 -1
- package/dist/core/adapter/adapters/generic-files-adapter.js +2 -0
- package/dist/core/adapter/adapters/index.js +6 -6
- package/dist/core/adapter/adapters/llm-wiki-adapter.js +14 -8
- package/dist/core/adapter/adapters/okf-adapter.js +187 -19
- package/dist/core/adapter/adapters/shared.js +3 -19
- package/dist/core/adapter/adapters/tool-dir-shared.js +8 -3
- package/dist/core/adapter/adapters/website-snapshot-adapter.js +1 -0
- package/dist/core/adapter/detect-adapter.js +17 -0
- package/dist/core/adapter/recognize-match.js +6 -4
- package/dist/core/adapter/validate-context.js +214 -0
- package/dist/core/asset/akm-markdown.js +63 -0
- package/dist/core/asset/asset-placement.js +20 -6
- package/dist/core/asset/asset-ref.js +11 -9
- package/dist/core/asset/frontmatter-lint.js +30 -0
- package/dist/core/asset/frontmatter.js +25 -9
- package/dist/core/asset/markdown.js +40 -51
- package/dist/core/asset/resolve-ref.js +89 -18
- package/dist/core/asset/stash-meta.js +1 -1
- package/dist/core/bundle-id.js +51 -0
- package/dist/core/common.js +152 -38
- package/dist/core/config/config-io.js +12 -1
- package/dist/core/config/config-schema.js +35 -8
- package/dist/core/config/config-sources.js +55 -11
- package/dist/core/config/config-walker.js +25 -9
- package/dist/core/config/config.js +9 -48
- package/dist/core/config/experimental.js +21 -0
- package/dist/core/config/schema/embedding.js +5 -1
- package/dist/core/config/schema/experimental.js +30 -0
- package/dist/core/config/schema/improve-processes.js +0 -6
- package/dist/core/config/schema/improve.js +21 -3
- package/dist/core/config/schema/index-config.js +8 -15
- package/dist/core/config/schema/output.js +4 -1
- package/dist/core/config/schema/setup.js +9 -18
- package/dist/core/config/schema/sources-bundles.js +49 -33
- package/dist/core/config/schema/workflow.js +3 -3
- package/dist/core/env-secret-ref.js +76 -46
- package/dist/core/errors.js +18 -12
- package/dist/core/events.js +46 -128
- package/dist/core/file-change.js +6 -5
- package/dist/core/fs-txn.js +83 -7
- package/dist/core/git-message.js +2 -2
- package/dist/core/improve-result.js +1 -100
- package/dist/core/lesson-lint.js +1 -17
- package/dist/core/logs-db.js +2 -1
- package/dist/core/migration-operation.js +16 -0
- package/dist/core/mutation-target.js +78 -0
- package/dist/core/parse.js +4 -1
- package/dist/core/paths.js +17 -20
- package/dist/core/recognition-util.js +12 -14
- package/dist/core/redaction.js +34 -0
- package/dist/core/standards/resolve-standards-context.js +2 -14
- package/dist/core/standards/resolve-stash-standards.js +2 -2
- package/dist/core/standards/resolve-type-conventions.js +2 -2
- package/dist/core/state/migrations.js +41 -18
- package/dist/core/state-db.js +5 -14
- package/dist/core/structured.js +1 -1
- package/dist/core/subprocess.js +6 -4
- package/dist/core/text-truncation.js +9 -5
- package/dist/core/type-presentation.js +3 -3
- package/dist/core/warn.js +0 -3
- package/dist/core/write-source.js +771 -95
- package/dist/indexer/bundle-identity-guard.js +3 -2
- package/dist/indexer/db/graph-db.js +0 -24
- package/dist/indexer/ensure-index.js +1 -0
- package/dist/indexer/graph/graph-boost.js +9 -34
- package/dist/indexer/graph/graph-extraction.js +8 -5
- package/dist/indexer/index-writer-lock.js +53 -17
- package/dist/indexer/index-written-assets.js +16 -22
- package/dist/indexer/indexer.js +497 -239
- package/dist/indexer/installations.js +14 -96
- package/dist/indexer/passes/dir-staleness.js +16 -9
- package/dist/indexer/passes/memory-inference.js +11 -9
- package/dist/indexer/passes/metadata.js +113 -47
- package/dist/indexer/scan/doc-to-entry.js +38 -1
- package/dist/indexer/scan/drain-dir.js +13 -23
- package/dist/indexer/search/db-search.js +99 -54
- package/dist/indexer/search/fts-query.js +47 -24
- package/dist/indexer/search/ranking-contributors.js +42 -20
- package/dist/indexer/search/ranking.js +18 -99
- package/dist/indexer/search/search-fields.js +7 -2
- package/dist/indexer/search/search-source.js +82 -93
- package/dist/indexer/usage/usage-events.js +0 -89
- package/dist/indexer/walk/file-context.js +2 -1
- package/dist/indexer/walk/matchers.js +30 -43
- package/dist/indexer/walk/path-resolver.js +7 -2
- package/dist/indexer/walk/walker.js +38 -12
- package/dist/integrations/agent/builders.js +0 -6
- package/dist/integrations/agent/config.js +2 -2
- package/dist/integrations/agent/detect.js +49 -19
- package/dist/integrations/agent/engine-fallback.js +76 -0
- package/dist/integrations/agent/profiles.js +14 -0
- package/dist/integrations/agent/prompts.js +12 -8
- package/dist/integrations/agent/runner-dispatch.js +4 -2
- package/dist/integrations/agent/runner.js +0 -1
- package/dist/integrations/agent/spawn.js +5 -6
- package/dist/integrations/github.js +1 -1
- package/dist/integrations/harnesses/aider/agent-builder.js +6 -4
- package/dist/integrations/harnesses/amazonq/agent-builder.js +7 -4
- package/dist/integrations/harnesses/claude/session-log.js +0 -10
- package/dist/integrations/harnesses/codex/agent-builder.js +5 -2
- package/dist/integrations/harnesses/copilot/agent-builder.js +5 -3
- package/dist/integrations/harnesses/gemini/agent-builder.js +5 -3
- package/dist/integrations/harnesses/index.js +3 -7
- package/dist/integrations/harnesses/opencode/agent-builder.js +21 -2
- package/dist/integrations/harnesses/opencode/session-log.js +0 -15
- package/dist/integrations/harnesses/opencode-sdk/sdk-runner.js +13 -4
- package/dist/integrations/harnesses/openhands/agent-builder.js +9 -6
- package/dist/integrations/harnesses/pi/agent-builder.js +6 -4
- package/dist/integrations/lockfile.js +88 -7
- package/dist/integrations/session-logs/index.js +3 -28
- package/dist/llm/client.js +136 -100
- package/dist/llm/embedders/remote.js +13 -5
- package/dist/llm/feature-gate.js +4 -12
- package/dist/llm/graph-extract.js +5 -11
- package/dist/llm/memory-infer.js +144 -1
- package/dist/llm/metadata-enhance.js +5 -7
- package/dist/llm/structured-call.js +1 -1
- package/dist/llm/usage-persist.js +26 -5
- package/dist/llm/usage-telemetry.js +25 -2
- package/dist/output/cli-hints.js +1 -2
- package/dist/output/context.js +22 -7
- package/dist/output/format-exempt.js +80 -0
- package/dist/output/generic-render.js +259 -0
- package/dist/output/render-registry.js +57 -0
- package/dist/output/renderers.js +14 -36
- package/dist/output/shapes/curate.js +10 -1
- package/dist/output/shapes/events.js +12 -7
- package/dist/output/shapes/helpers.js +56 -83
- package/dist/output/shapes/migrate.js +8 -0
- package/dist/output/shapes/passthrough.js +7 -41
- package/dist/output/shapes/proposal/producer.js +15 -7
- package/dist/output/shapes.js +2 -9
- package/dist/output/text/{init.js → bundle-create.js} +3 -1
- package/dist/output/text/bundle-show.js +7 -0
- package/dist/output/text/command-format.js +164 -96
- package/dist/output/text/env.js +1 -3
- package/dist/output/text/events.js +8 -7
- package/dist/output/text/health-format.js +103 -0
- package/dist/output/text/health.js +7 -0
- package/dist/output/text/helpers.js +10 -8
- package/dist/output/text/lint-format.js +43 -0
- package/dist/output/text/{save.js → lint.js} +2 -2
- package/dist/output/text/migrate.js +88 -0
- package/dist/output/text/proposal/producer.js +4 -2
- package/dist/output/text/proposal-format.js +44 -72
- package/dist/output/text/registry-commands.js +1 -2
- package/dist/output/text/show-directives.js +15 -7
- package/dist/output/text/status-list.js +32 -0
- package/dist/output/text/sync.js +5 -0
- package/dist/output/text/workflow-format.js +24 -203
- package/dist/output/text/workflow.js +1 -7
- package/dist/output/text.js +16 -17
- package/dist/registry/factory.js +4 -6
- package/dist/registry/origin-resolve.js +16 -27
- package/dist/registry/providers/skills-sh.js +3 -3
- package/dist/registry/providers/static-index.js +13 -23
- package/dist/registry/resolve.js +42 -7
- package/dist/registry/semver.js +34 -84
- package/dist/runtime.js +2 -23
- package/dist/scripts/akm-migrate-node.js +60290 -0
- package/dist/scripts/akm-migrate.js +59628 -0
- package/dist/setup/detect.js +42 -15
- package/dist/setup/registry-stash-loader.js +2 -2
- package/dist/setup/setup.js +236 -136
- package/dist/setup/steps/connection.js +7 -9
- package/dist/setup/steps/platforms.js +9 -9
- package/dist/setup/steps/semantic.js +15 -3
- package/dist/setup/steps/sources.js +12 -13
- package/dist/setup/steps/stashdir.js +2 -3
- package/dist/setup/steps/tasks.js +237 -120
- package/dist/sources/freshness.js +1 -1
- package/dist/sources/provider-factory.js +11 -17
- package/dist/sources/providers/filesystem.js +2 -3
- package/dist/sources/providers/git-install.js +278 -34
- package/dist/sources/providers/git-provider.js +25 -23
- package/dist/sources/providers/git-stash.js +395 -106
- package/dist/sources/providers/git.js +2 -2
- package/dist/sources/providers/npm.js +16 -19
- package/dist/sources/providers/provider-utils.js +7 -4
- package/dist/sources/providers/sync-from-ref.js +3 -9
- package/dist/sources/providers/website.js +6 -1
- package/dist/sources/resolve.js +6 -5
- package/dist/sources/snapshot-fetchers/bluesky.js +146 -0
- package/dist/sources/snapshot-fetchers/content-extract.js +566 -0
- package/dist/sources/snapshot-fetchers/fetcher-util.js +41 -0
- package/dist/sources/snapshot-fetchers/github.js +100 -0
- package/dist/sources/snapshot-fetchers/host-guard.js +291 -0
- package/dist/sources/snapshot-fetchers/registry.js +17 -1
- package/dist/sources/snapshot-fetchers/robots.js +348 -0
- package/dist/sources/snapshot-fetchers/rss.js +282 -0
- package/dist/sources/snapshot-fetchers/secret-seam.js +42 -0
- package/dist/sources/snapshot-fetchers/website-ingest.js +566 -268
- package/dist/sources/snapshot-fetchers/x.js +910 -0
- package/dist/storage/database.js +7 -0
- package/dist/storage/engines/sqlite-migrations.js +23 -111
- package/dist/storage/managed-db.js +2 -2
- package/dist/storage/repositories/canaries-repository.js +1 -1
- package/dist/storage/repositories/events-repository.js +27 -11
- package/dist/storage/repositories/improve-runs-repository.js +6 -12
- package/dist/storage/repositories/index-connection.js +17 -6
- package/dist/storage/repositories/index-entries-repository.js +151 -240
- package/dist/storage/repositories/index-entry-mapper.js +15 -11
- package/dist/storage/repositories/index-fts-repository.js +5 -2
- package/dist/storage/repositories/index-llm-cache-repository.js +0 -1
- package/dist/storage/repositories/index-meta-repository.js +2 -3
- package/dist/storage/repositories/index-schema.js +10 -25
- package/dist/storage/repositories/index-utility-repository.js +15 -28
- package/dist/storage/repositories/index-vec-repository.js +6 -1
- package/dist/storage/repositories/outcome-repository.js +119 -0
- package/dist/storage/repositories/proposals-repository.js +296 -59
- package/dist/storage/repositories/registry-cache.js +19 -0
- package/dist/storage/repositories/salience-repository.js +172 -0
- package/dist/storage/repositories/task-history-repository.js +15 -13
- package/dist/storage/repositories/workflow-runs-repository.js +52 -40
- package/dist/tasks/backends/cron.js +105 -15
- package/dist/tasks/backends/index.js +1 -1
- package/dist/tasks/backends/launchd.js +85 -38
- package/dist/tasks/backends/schtasks.js +135 -15
- package/dist/tasks/embedded.js +56 -40
- package/dist/tasks/parser.js +7 -157
- package/dist/tasks/resolve-akm-bin.js +137 -59
- package/dist/tasks/runner.js +79 -42
- package/dist/tasks/scheduler-invocation.js +220 -10
- package/dist/tasks/schema.js +24 -1
- package/dist/tasks/task-id.js +1 -3
- package/dist/tasks/validator.js +20 -6
- package/dist/workflows/authoring/authoring.js +94 -143
- package/dist/workflows/authoring/scope-key.js +1 -1
- package/dist/workflows/exec/frozen-judge.js +28 -2
- package/dist/workflows/exec/native-executor.js +77 -57
- package/dist/workflows/exec/param-secrets.js +9 -9
- package/dist/workflows/exec/run-workflow.js +133 -79
- package/dist/workflows/exec/step-work.js +219 -346
- package/dist/{migrate-storage-node.mjs → workflows/exec/unit-dispatch.js} +1 -5
- package/dist/workflows/ir/compile.js +141 -270
- package/dist/workflows/ir/freeze.js +40 -30
- package/dist/workflows/ir/params.js +135 -11
- package/dist/workflows/ir/plan-hash.js +1 -1
- package/dist/workflows/ir/schema.js +25 -26
- package/dist/workflows/parser.js +872 -307
- package/dist/workflows/program/expressions.js +20 -208
- package/dist/workflows/program/schema.js +7 -10
- package/dist/workflows/renderer.js +95 -68
- package/dist/workflows/resource-limits.js +2 -0
- package/dist/workflows/runtime/checkin.js +3 -3
- package/dist/workflows/runtime/plan-classifier.js +16 -75
- package/dist/workflows/runtime/runs.js +186 -127
- package/dist/workflows/runtime/unit-checkin.js +1 -1
- package/dist/workflows/runtime/unit-phases.js +2 -2
- package/dist/workflows/runtime/workflow-asset-loader.js +232 -83
- package/dist/workflows/schema.js +1 -11
- package/dist/workflows/validate-summary.js +30 -36
- package/dist/workflows/validator.js +21 -62
- package/docs/README.md +68 -0
- package/docs/migration/README.md +8 -0
- package/docs/migration/release-notes/0.7.0.md +11 -11
- package/docs/migration/release-notes/0.9.0.md +208 -27
- package/docs/migration/v0.7-to-v0.8.md +46 -47
- package/docs/migration/v0.8-to-v0.9.md +564 -208
- package/docs/migration/v0.9.0-troubleshooting.md +561 -0
- package/docs/reference/README.md +12 -0
- package/docs/reference/cli.md +2253 -0
- package/docs/reference/configuration.md +358 -0
- package/docs/reference/data-and-telemetry.md +105 -42
- package/docs/reference/workflows.md +647 -0
- package/package.json +22 -11
- package/schemas/akm-asset-envelope.json +93 -0
- package/schemas/akm-config.json +81 -128
- package/schemas/akm-workflow.json +74 -73
- package/dist/assets/tasks/core/backup.yml +0 -5
- package/dist/assets/tasks/graph-refresh-weekly.yml +0 -10
- package/dist/cli/config-migrate.js +0 -1878
- package/dist/cli/config-validate.js +0 -41
- package/dist/commands/backup-cli.js +0 -56
- package/dist/commands/bundle/bundle-cli.js +0 -68
- package/dist/commands/bundle/bundle.js +0 -219
- package/dist/commands/graph/graph-cli.js +0 -124
- package/dist/commands/graph/graph.js +0 -489
- package/dist/commands/improve/extract-watch.js +0 -140
- package/dist/commands/mv-cli.js +0 -1221
- package/dist/commands/sources/history.js +0 -201
- package/dist/commands/tasks/default-tasks.js +0 -186
- package/dist/core/migration-backup.js +0 -1231
- package/dist/indexer/usage/unmigrated-vaults-guard.js +0 -95
- package/dist/llm/memory-infer-impl.js +0 -138
- package/dist/migrate/legacy/config-source-migration.js +0 -257
- package/dist/migrate/legacy/content-migration.js +0 -350
- package/dist/migrate/legacy/legacy-layout.js +0 -779
- package/dist/migrate/legacy/legacy-paths.js +0 -25
- package/dist/migrate/legacy/legacy-stash-json.js +0 -80
- package/dist/migrate/legacy/proposal-fs-import.js +0 -168
- package/dist/migrate/legacy/task-target-ref-migration.js +0 -278
- package/dist/migrate/legacy/three-db-cutover.js +0 -845
- package/dist/migrate/legacy/workflow-migrations-bodies.js +0 -52
- package/dist/migrate/legacy/workflow-migrations-frozen.js +0 -21
- package/dist/migrate/legacy-ref-grammar.js +0 -214
- package/dist/output/shapes/distill.js +0 -14
- package/dist/output/shapes/history.js +0 -11
- package/dist/output/text/distill.js +0 -6
- package/dist/output/text/enable-disable.js +0 -8
- package/dist/output/text/history.js +0 -6
- package/dist/registry/build-index.js +0 -382
- package/dist/schemas/akm-config.json +0 -4704
- package/dist/schemas/akm-task.json +0 -87
- package/dist/schemas/akm-workflow.json +0 -372
- package/dist/scripts/migrate-storage.js +0 -3816
- package/dist/workflows/authoring/workflow-program-template.yaml +0 -31
- package/dist/workflows/cli.js +0 -53
- package/dist/workflows/exec/brief.js +0 -481
- package/dist/workflows/exec/report.js +0 -1460
- package/dist/workflows/exec/watch.js +0 -116
- package/dist/workflows/program/parser.js +0 -813
- 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(
|
|
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
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
if (
|
|
109
|
-
|
|
110
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
163
|
-
|
|
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 = [
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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."
|
|
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
|
-
|
|
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
|
|
601
|
+
const rawFinalUrl = fetched.finalUrl;
|
|
602
|
+
const finalUrl = normalizeCrawlUrl(rawFinalUrl) ?? pageUrl;
|
|
246
603
|
assertWebsiteRequestUrl(finalUrl, Error, options);
|
|
247
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
788
|
+
const heading = title.replace(/([\\[\]`*_])/g, "\\$1").replace(/<(?=[a-zA-Z/!?])/g, "<");
|
|
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
|
-
`# ${
|
|
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
|
|
440
|
-
const
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
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, "<");
|
|
939
|
+
return escapeMarkdownStructure(inlineSafe).trim();
|
|
458
940
|
}
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
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
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
|
|
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
|
|
622
|
-
|
|
623
|
-
|
|
624
|
-
|
|
625
|
-
|
|
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
|
-
|
|
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";
|