@geraldmaron/construct 1.0.0 → 1.0.2
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/.env.example +9 -6
- package/LICENSE +201 -21
- package/README.md +132 -257
- package/agents/contracts.json +387 -0
- package/agents/prompts/cx-accessibility.md +8 -0
- package/agents/prompts/cx-ai-engineer.md +92 -0
- package/agents/prompts/cx-architect.md +10 -2
- package/agents/prompts/cx-business-strategist.md +13 -0
- package/agents/prompts/cx-data-analyst.md +80 -0
- package/agents/prompts/cx-data-engineer.md +4 -0
- package/agents/prompts/cx-debugger.md +8 -0
- package/agents/prompts/cx-designer.md +19 -0
- package/agents/prompts/cx-devil-advocate.md +4 -0
- package/agents/prompts/cx-docs-keeper.md +128 -0
- package/agents/prompts/cx-engineer.md +13 -0
- package/agents/prompts/cx-evaluator.md +4 -0
- package/agents/prompts/cx-explorer.md +12 -0
- package/agents/prompts/cx-legal-compliance.md +13 -0
- package/agents/prompts/cx-operations.md +13 -0
- package/agents/prompts/cx-orchestrator.md +107 -4
- package/agents/prompts/cx-platform-engineer.md +75 -0
- package/agents/prompts/cx-product-manager.md +8 -0
- package/agents/prompts/cx-qa.md +100 -0
- package/agents/prompts/cx-rd-lead.md +13 -0
- package/agents/prompts/cx-release-manager.md +8 -0
- package/agents/prompts/cx-researcher.md +21 -1
- package/agents/prompts/cx-reviewer.md +8 -0
- package/agents/prompts/cx-security.md +104 -0
- package/agents/prompts/cx-sre.md +104 -0
- package/agents/prompts/cx-test-automation.md +4 -0
- package/agents/prompts/cx-trace-reviewer.md +7 -3
- package/agents/prompts/cx-ux-researcher.md +4 -0
- package/agents/registry.json +365 -90
- package/agents/role-manifests.json +217 -0
- package/bin/construct +3345 -141
- package/bin/construct-postinstall.mjs +87 -0
- package/commands/build/feature.md +6 -6
- package/commands/plan/feature.md +6 -5
- package/commands/plan/requirements.md +1 -1
- package/commands/ship/status.md +1 -1
- package/commands/work/drive.md +3 -3
- package/commands/work/optimize-prompts.md +3 -3
- package/db/{migrations → schema}/001_init.sql +2 -0
- package/db/schema/002_pgvector.sql +182 -0
- package/db/schema/003_intake.sql +47 -0
- package/examples/README.md +85 -0
- package/examples/internal/roles/architect/bad/clever-plan-without-contracts.md +30 -0
- package/examples/internal/roles/architect/golden/explicit-tradeoff-before-plan.md +23 -0
- package/examples/internal/roles/engineer/bad/speculative-abstraction.md +30 -0
- package/examples/internal/roles/engineer/golden/read-before-write.md +28 -0
- package/examples/internal/roles/orchestrator/bad/everything-becomes-multi-agent.md +29 -0
- package/examples/internal/roles/orchestrator/golden/minimal-dispatch.md +22 -0
- package/examples/internal/roles/qa/bad/coverage-theater.md +30 -0
- package/examples/internal/roles/qa/golden/regression-gate.md +23 -0
- package/examples/internal/roles/reviewer/bad/lgtm-without-verification.md +29 -0
- package/examples/internal/roles/reviewer/golden/find-structural-risk-first.md +28 -0
- package/examples/personas/construct/adversarial/ignore-instruction-to-skip-approval.md +22 -0
- package/examples/personas/construct/bad/commit-without-approval.md +30 -0
- package/examples/personas/construct/boundary/blocked-needs-main-input.md +29 -0
- package/examples/personas/construct/golden/branch-approval-before-mutation.md +36 -0
- package/examples/personas/construct/golden/focused-direct-answer.md +28 -0
- package/examples/provider-plugin/README.md +34 -0
- package/examples/provider-plugin/index.mjs +74 -0
- package/examples/provider-plugin/package.json +15 -0
- package/examples/seed-observations/README.md +38 -0
- package/examples/seed-observations/anti-patterns.md +44 -0
- package/examples/seed-observations/decisions.md +36 -0
- package/examples/seed-observations/patterns.md +42 -0
- package/lib/agent-contracts-enforce.mjs +158 -0
- package/lib/agent-contracts.mjs +231 -0
- package/lib/agents/postconditions.mjs +126 -0
- package/lib/agents/schema.mjs +124 -0
- package/lib/artifact-capture.mjs +183 -0
- package/lib/audit-trail.mjs +149 -0
- package/lib/auto-docs.mjs +245 -65
- package/lib/beads/auto-close.mjs +126 -0
- package/lib/beads/drift.mjs +171 -0
- package/lib/beads-automation.mjs +542 -0
- package/lib/beads-client.mjs +518 -0
- package/lib/beads-lock.mjs +377 -0
- package/lib/beads-optimistic.mjs +365 -0
- package/lib/bootstrap/built-ins.mjs +136 -0
- package/lib/bootstrap/lazy-install.mjs +161 -0
- package/lib/bootstrap/resources.mjs +120 -0
- package/lib/bootstrap.mjs +105 -0
- package/lib/cache-governor.js +213 -0
- package/lib/cache-strategy-anthropic.js +62 -0
- package/lib/cache-strategy-google.js +79 -0
- package/lib/cache-strategy-none.js +30 -0
- package/lib/cache-strategy-openai.js +48 -0
- package/lib/cache-strategy.js +91 -0
- package/lib/claude-allow.mjs +149 -0
- package/lib/cli-commands.mjs +413 -258
- package/lib/codex-config.mjs +3 -1
- package/lib/comment-lint.mjs +55 -6
- package/lib/completions.mjs +21 -6
- package/lib/config/alias.mjs +56 -0
- package/lib/config/project-config.mjs +335 -0
- package/lib/config/schema.mjs +159 -0
- package/lib/context-router.mjs +308 -0
- package/lib/cost-ledger.mjs +177 -0
- package/lib/cost.mjs +171 -10
- package/lib/dashboard-static.mjs +158 -0
- package/lib/deployment-mode.mjs +86 -0
- package/lib/deprecate.mjs +49 -0
- package/lib/dispatch-batch.js +183 -0
- package/lib/distill.mjs +21 -8
- package/lib/doc-stamp.mjs +164 -0
- package/lib/doc-verify.mjs +119 -0
- package/lib/docs-routing.mjs +89 -0
- package/lib/docs-verify.mjs +417 -0
- package/lib/doctor/audit.mjs +71 -0
- package/lib/doctor/cli.mjs +99 -0
- package/lib/doctor/escalate.mjs +29 -0
- package/lib/doctor/index.mjs +140 -0
- package/lib/doctor/report.mjs +170 -0
- package/lib/doctor/watchers/bd-watch.mjs +117 -0
- package/lib/doctor/watchers/cost.mjs +130 -0
- package/lib/doctor/watchers/disk.mjs +122 -0
- package/lib/doctor/watchers/handoffs.mjs +33 -0
- package/lib/doctor/watchers/process-pressure.mjs +60 -0
- package/lib/doctor/watchers/service-health.mjs +188 -0
- package/lib/document-extract.mjs +288 -0
- package/lib/document-ingest.mjs +230 -0
- package/lib/drop.mjs +282 -0
- package/lib/embed/approval-queue.mjs +176 -0
- package/lib/embed/artifact.mjs +349 -0
- package/lib/embed/authority-guard.mjs +155 -0
- package/lib/embed/cli.mjs +408 -0
- package/lib/embed/config.mjs +355 -0
- package/lib/embed/conflict-detection.mjs +264 -0
- package/lib/embed/customer-profiles.mjs +480 -0
- package/lib/embed/daemon.mjs +1309 -0
- package/lib/embed/demand-fetch.mjs +449 -0
- package/lib/embed/docs-lifecycle.mjs +349 -0
- package/lib/embed/inbox-live-watcher.mjs +119 -0
- package/lib/embed/inbox.mjs +343 -0
- package/lib/embed/intake-metrics.mjs +190 -0
- package/lib/embed/jobs/vector-sync.mjs +198 -0
- package/lib/embed/notifications.mjs +75 -0
- package/lib/embed/output.mjs +79 -0
- package/lib/embed/providers/github.mjs +295 -0
- package/lib/embed/providers/jira.mjs +192 -0
- package/lib/embed/providers/linear.mjs +186 -0
- package/lib/embed/providers/registry.mjs +115 -0
- package/lib/embed/providers/slack.mjs +203 -0
- package/lib/embed/recommendation-store.mjs +378 -0
- package/lib/embed/roadmap.mjs +374 -0
- package/lib/embed/role-framing.mjs +110 -0
- package/lib/embed/scheduler.mjs +99 -0
- package/lib/embed/semantic.mjs +325 -0
- package/lib/embed/snapshot.mjs +191 -0
- package/lib/embed/supervision.mjs +235 -0
- package/lib/embed/target-resolver.mjs +186 -0
- package/lib/embed/worker.mjs +62 -0
- package/lib/embed/workspaces.mjs +297 -0
- package/lib/engine/chunker-headings.mjs +110 -0
- package/lib/engine/compressor-heuristic.mjs +100 -0
- package/lib/engine/consolidate.mjs +287 -0
- package/lib/engine/contracts.mjs +126 -0
- package/lib/engine/defaults.mjs +129 -0
- package/lib/engine/eval-retrieval.mjs +148 -0
- package/lib/engine/fuser-rrf.mjs +62 -0
- package/lib/engine/index.mjs +37 -0
- package/lib/engine/registry.mjs +146 -0
- package/lib/engine/reranker-mmr.mjs +90 -0
- package/lib/engine/tokens.mjs +77 -0
- package/lib/entity-store.mjs +280 -0
- package/lib/env-config.mjs +69 -4
- package/lib/evals/retrieval-bench.mjs +159 -0
- package/lib/evaluator-optimizer.mjs +317 -0
- package/lib/features.mjs +159 -29
- package/lib/gates-audit.mjs +236 -0
- package/lib/git-hooks/prepare-commit-msg +58 -0
- package/lib/handoffs/cleanup.mjs +159 -0
- package/lib/handoffs/contract.mjs +162 -0
- package/lib/handoffs/inventory.mjs +120 -0
- package/lib/headhunt.mjs +28 -47
- package/lib/health-check.mjs +399 -0
- package/lib/hook-health.mjs +442 -0
- package/lib/hooks/_lib/log.mjs +82 -0
- package/lib/hooks/adaptive-lint.mjs +26 -2
- package/lib/hooks/agent-tracker.mjs +171 -14
- package/lib/hooks/audit-reads.mjs +109 -0
- package/lib/hooks/audit-trail.mjs +153 -0
- package/lib/hooks/bash-output-logger.mjs +71 -0
- package/lib/hooks/block-no-verify.mjs +41 -0
- package/lib/hooks/ci-status-check.mjs +82 -0
- package/lib/hooks/comment-lint.mjs +29 -7
- package/lib/hooks/config-protection.mjs +39 -18
- package/lib/hooks/context-watch.mjs +137 -0
- package/lib/hooks/context-window-recovery.mjs +4 -20
- package/lib/hooks/dep-audit.mjs +16 -0
- package/lib/hooks/doc-coupling-check.mjs +80 -0
- package/lib/hooks/edit-accumulator.mjs +3 -0
- package/lib/hooks/edit-error-recovery.mjs +3 -0
- package/lib/hooks/edit-guard.mjs +45 -4
- package/lib/hooks/env-check.mjs +3 -0
- package/lib/hooks/guard-bash.mjs +58 -1
- package/lib/hooks/mcp-audit.mjs +18 -20
- package/lib/hooks/mcp-health-check.mjs +36 -0
- package/lib/hooks/model-fallback.mjs +42 -56
- package/lib/hooks/policy-engine.mjs +209 -0
- package/lib/hooks/post-merge-docs-check.mjs +63 -0
- package/lib/hooks/pre-compact.mjs +4 -24
- package/lib/hooks/pre-push-gate.mjs +200 -37
- package/lib/hooks/proactive-activation.mjs +284 -0
- package/lib/hooks/read-tracker.mjs +27 -4
- package/lib/hooks/readme-age-check.mjs +77 -0
- package/lib/hooks/registry-sync.mjs +12 -5
- package/lib/hooks/scan-secrets.mjs +50 -7
- package/lib/hooks/session-optimize.mjs +311 -0
- package/lib/hooks/session-start.mjs +287 -28
- package/lib/hooks/stop-notify.mjs +236 -96
- package/lib/hooks/stop-typecheck.mjs +13 -1
- package/lib/hooks/test-watch.mjs +68 -0
- package/lib/host-capabilities.mjs +31 -10
- package/lib/init-docs.mjs +731 -291
- package/lib/init-unified.mjs +1000 -0
- package/lib/init-update.mjs +168 -0
- package/lib/init.mjs +107 -0
- package/lib/install/first-invocation.mjs +119 -0
- package/lib/install/stage-project.mjs +69 -0
- package/lib/intake/classify.mjs +278 -0
- package/lib/intake/feedback.mjs +273 -0
- package/lib/intake/filesystem-queue.mjs +158 -0
- package/lib/intake/intake-config.mjs +132 -0
- package/lib/intake/postgres-queue.mjs +198 -0
- package/lib/intake/prepare.mjs +139 -0
- package/lib/intake/queue.mjs +83 -0
- package/lib/intake/session-prelude.mjs +50 -0
- package/lib/integrations/intake-integrations.mjs +740 -0
- package/lib/intent-classifier.mjs +253 -0
- package/lib/knowledge/layout.mjs +72 -0
- package/lib/knowledge/rag.mjs +331 -0
- package/lib/knowledge/search.mjs +315 -0
- package/lib/knowledge/trends.mjs +261 -0
- package/lib/logger.mjs +85 -0
- package/lib/mcp/broker.mjs +124 -0
- package/lib/mcp/server.mjs +554 -854
- package/lib/mcp/tools/document.mjs +132 -0
- package/lib/mcp/tools/memory.mjs +209 -0
- package/lib/mcp/tools/project.mjs +349 -0
- package/lib/mcp/tools/skills.mjs +409 -0
- package/lib/mcp/tools/storage.mjs +60 -0
- package/lib/mcp/tools/telemetry.mjs +315 -0
- package/lib/mcp/tools/workflow.mjs +112 -0
- package/lib/mcp-catalog.json +54 -3
- package/lib/mcp-manager.mjs +96 -48
- package/lib/mcp-platform-config.mjs +22 -22
- package/lib/memory-stats.mjs +121 -0
- package/lib/mode-commands.mjs +124 -0
- package/lib/model-free-selector.mjs +184 -0
- package/lib/model-pricing.mjs +152 -0
- package/lib/model-registry.mjs +226 -0
- package/lib/model-router.mjs +362 -386
- package/lib/observation-store.mjs +391 -0
- package/lib/ollama-manager.mjs +428 -0
- package/lib/opencode-config.mjs +13 -3
- package/lib/opencode-runtime-plugin.mjs +70 -38
- package/lib/opencode-telemetry.mjs +64 -6
- package/lib/orchestration-policy.mjs +509 -6
- package/lib/overrides/resolver.mjs +207 -0
- package/lib/parity.mjs +147 -0
- package/lib/paths.mjs +33 -0
- package/lib/performance/generate.mjs +212 -0
- package/lib/plugin-registry.mjs +268 -0
- package/lib/policy/engine.mjs +130 -0
- package/lib/policy/unified-gates.mjs +96 -0
- package/lib/project-detection.mjs +129 -0
- package/lib/project-init-shared.mjs +272 -0
- package/lib/project-profile.mjs +447 -0
- package/lib/prompt-composer.js +434 -0
- package/lib/prompt-metadata.mjs +1 -1
- package/lib/provider-capabilities-anthropic.js +44 -0
- package/lib/provider-capabilities-deepseek.js +37 -0
- package/lib/provider-capabilities-generic.js +26 -0
- package/lib/provider-capabilities-google.js +47 -0
- package/lib/provider-capabilities-openai.js +45 -0
- package/lib/provider-capabilities.js +142 -0
- package/lib/providers/atlassian-confluence/index.mjs +103 -0
- package/lib/providers/atlassian-jira/index.mjs +100 -0
- package/lib/providers/auth-manager.mjs +126 -0
- package/lib/providers/circuit-breaker.mjs +124 -0
- package/lib/providers/contract.mjs +93 -0
- package/lib/providers/github/index.mjs +126 -0
- package/lib/providers/registry.mjs +184 -0
- package/lib/providers/salesforce/index.mjs +100 -0
- package/lib/providers/slack/index.mjs +80 -0
- package/lib/reflect.mjs +137 -0
- package/lib/research-lint.mjs +164 -0
- package/lib/resources/budget.mjs +259 -0
- package/lib/role-preload.mjs +21 -6
- package/lib/roles/approval-surface.mjs +54 -0
- package/lib/roles/cli.mjs +118 -0
- package/lib/roles/event-bus.mjs +79 -0
- package/lib/roles/fence.mjs +84 -0
- package/lib/roles/gateway.mjs +260 -0
- package/lib/roles/hook-emit.mjs +37 -0
- package/lib/roles/manifest.mjs +48 -0
- package/lib/roles/router.mjs +27 -0
- package/lib/runtime-pressure.mjs +360 -0
- package/lib/schema-artifact.mjs +134 -0
- package/lib/schema-infer.mjs +551 -0
- package/lib/server/auth.mjs +168 -0
- package/lib/server/chat.mjs +336 -0
- package/lib/server/cors.mjs +77 -0
- package/lib/server/csrf.mjs +91 -0
- package/lib/server/index.mjs +1927 -78
- package/lib/server/insights.mjs +765 -0
- package/lib/server/rate-limit.mjs +91 -0
- package/lib/server/static/assets/index-ab25c707.js +70 -0
- package/lib/server/static/assets/index-f0c80a2b.css +1 -0
- package/lib/server/static/index.html +12 -817
- package/lib/server/telemetry-login.mjs +108 -0
- package/lib/server/webhook.mjs +510 -0
- package/lib/service-manager.mjs +522 -58
- package/lib/services/pattern-promotion-service.mjs +167 -0
- package/lib/services/telemetry-backend.mjs +178 -0
- package/lib/session-store.mjs +374 -0
- package/lib/setup-prompts.mjs +96 -0
- package/lib/setup.mjs +523 -36
- package/lib/skills-apply.mjs +280 -0
- package/lib/skills-scope.mjs +118 -0
- package/lib/status.mjs +261 -70
- package/lib/storage/admin.mjs +355 -0
- package/lib/storage/backend.mjs +2 -1
- package/lib/storage/backup.mjs +347 -0
- package/lib/storage/embeddings-engine.mjs +133 -0
- package/lib/storage/embeddings-legacy.mjs +85 -0
- package/lib/storage/embeddings-local.mjs +108 -0
- package/lib/storage/embeddings-ollama.mjs +78 -0
- package/lib/storage/embeddings-openai.mjs +85 -0
- package/lib/storage/embeddings.mjs +92 -33
- package/lib/storage/file-lock.mjs +130 -0
- package/lib/storage/fusion.mjs +95 -0
- package/lib/storage/hybrid-query.mjs +34 -27
- package/lib/storage/migrations.mjs +187 -0
- package/lib/storage/postgres-backup.mjs +124 -0
- package/lib/storage/sql-store.mjs +5 -15
- package/lib/storage/state-source.mjs +12 -13
- package/lib/storage/store-version.mjs +115 -0
- package/lib/storage/sync.mjs +144 -35
- package/lib/storage/unified-storage.mjs +550 -0
- package/lib/storage/vector-client.mjs +286 -0
- package/lib/storage/vector-store.mjs +71 -30
- package/lib/task-graph/generate.mjs +135 -0
- package/lib/task-graph/schema.mjs +81 -0
- package/lib/task-graph/store.mjs +71 -0
- package/lib/telemetry/backends/local.mjs +62 -0
- package/lib/telemetry/backends/{langfuse.mjs → remote.mjs} +27 -14
- package/lib/telemetry/backfill.mjs +180 -0
- package/lib/telemetry/eval-datasets.mjs +203 -0
- package/lib/telemetry/{langfuse-ingest.mjs → ingest.mjs} +26 -20
- package/lib/telemetry/intent-verifications.mjs +86 -0
- package/lib/telemetry/llm-judge.mjs +350 -0
- package/lib/telemetry/model-pricing-catalog.mjs +557 -0
- package/lib/telemetry/setup.mjs +151 -0
- package/lib/telemetry/skill-calls.mjs +78 -0
- package/lib/telemetry/team-rollup.mjs +4 -4
- package/lib/token-engine.js +117 -0
- package/lib/token-estimator-anthropic.js +15 -0
- package/lib/token-estimator-deepseek.js +13 -0
- package/lib/token-estimator-default.js +13 -0
- package/lib/token-estimator-google.js +13 -0
- package/lib/token-estimator-openai.js +13 -0
- package/lib/toolkit-env.mjs +1 -1
- package/lib/tty-prompts.mjs +211 -0
- package/lib/uninstall/uninstall.mjs +423 -0
- package/lib/update.mjs +115 -0
- package/lib/upgrade.mjs +141 -0
- package/lib/validator.mjs +51 -2
- package/lib/validators/skills.mjs +142 -0
- package/lib/wireframe.mjs +422 -0
- package/lib/worker/entrypoint.mjs +241 -0
- package/lib/worker/evidence.mjs +107 -0
- package/lib/worker/run.mjs +154 -0
- package/lib/worker/trace.mjs +182 -0
- package/lib/workflow-state.mjs +14 -18
- package/package.json +21 -8
- package/personas/construct.md +53 -51
- package/platforms/claude/CLAUDE.md +1 -1
- package/platforms/claude/settings.template.json +115 -68
- package/platforms/opencode/config.template.json +3 -7
- package/rules/common/agents.md +11 -38
- package/rules/common/beads-hygiene.md +75 -0
- package/rules/common/code-review.md +11 -100
- package/rules/common/coding-style.md +5 -3
- package/rules/common/comments.md +30 -111
- package/rules/common/commit-approval.md +53 -0
- package/rules/common/cx-agent-routing.md +1 -0
- package/rules/common/development-workflow.md +23 -41
- package/rules/common/doc-ownership.md +54 -0
- package/rules/common/efficiency.md +57 -0
- package/rules/common/framing.md +76 -0
- package/rules/common/git-workflow.md +2 -4
- package/rules/common/patterns.md +3 -7
- package/rules/common/performance.md +15 -31
- package/rules/common/release-gates.md +69 -0
- package/rules/common/research.md +107 -0
- package/rules/common/security.md +6 -6
- package/rules/common/skill-composition.md +67 -0
- package/rules/common/testing.md +15 -27
- package/rules/golang/hooks.md +0 -4
- package/rules/policy/bootstrap.yaml +11 -0
- package/rules/policy/drive.yaml +8 -0
- package/rules/policy/task.yaml +9 -0
- package/rules/policy/workflow.yaml +9 -0
- package/rules/python/hooks.md +0 -4
- package/rules/swift/hooks.md +0 -4
- package/rules/typescript/hooks.md +0 -4
- package/rules/web/hooks.md +0 -2
- package/{sync-agents.mjs → scripts/sync-agents.mjs} +306 -61
- package/skills/ai/prompt-optimizer.md +7 -7
- package/skills/compliance/ai-disclosure.md +58 -0
- package/skills/compliance/data-privacy.md +46 -0
- package/skills/compliance/license-audit.md +40 -0
- package/skills/compliance/regulatory-review.md +61 -0
- package/skills/docs/document-ingest-workflow.md +52 -0
- package/skills/docs/evidence-ingest-workflow.md +9 -0
- package/skills/docs/init-docs.md +51 -18
- package/skills/docs/product-intelligence-workflow.md +12 -0
- package/skills/docs/product-signal-workflow.md +4 -0
- package/skills/docs/research-workflow.md +23 -8
- package/skills/docs/runbook-workflow.md +1 -1
- package/skills/operating/orchestration-reference.md +151 -0
- package/skills/roles/architect.md +5 -0
- package/skills/roles/engineer.md +32 -0
- package/skills/roles/operator.md +12 -0
- package/skills/roles/reviewer.md +23 -0
- package/skills/routing.md +1 -1
- package/templates/devcontainer/Dockerfile.devcontainer +38 -0
- package/templates/devcontainer/devcontainer.json +31 -0
- package/templates/distribution/bootstrap.ps1 +142 -0
- package/templates/distribution/bootstrap.sh +196 -0
- package/templates/distribution/run.mjs +187 -0
- package/templates/docs/adr.md +44 -8
- package/templates/docs/changelog-entry.md +43 -0
- package/templates/docs/construct_guide.md +149 -0
- package/templates/docs/evidence-brief.md +2 -2
- package/templates/docs/meta-prd.md +140 -19
- package/templates/docs/onboarding.md +57 -0
- package/templates/docs/prd.md +159 -15
- package/templates/docs/research-brief.md +4 -4
- package/templates/homebrew/construct.rb +67 -0
- package/langfuse/docker-compose.yml +0 -82
- package/lib/eval-harness.mjs +0 -59
- package/lib/hooks/bootstrap-guard.mjs +0 -90
- package/lib/hooks/console-warn.mjs +0 -43
- package/lib/hooks/continuation-enforcer.mjs +0 -72
- package/lib/hooks/drive-guard.mjs +0 -89
- package/lib/hooks/mcp-task-scope.mjs +0 -47
- package/lib/hooks/task-completed-guard.mjs +0 -43
- package/lib/hooks/teammate-idle-guard.mjs +0 -54
- package/lib/hooks/workflow-guard.mjs +0 -62
- package/lib/prompt-composer.mjs +0 -196
- package/lib/review.mjs +0 -429
- package/lib/server/static/app.js +0 -841
- package/lib/telemetry/langfuse-model-sync.mjs +0 -270
- package/rules/common/hooks.md +0 -35
- package/rules/zh/README.md +0 -113
- package/rules/zh/agents.md +0 -55
- package/rules/zh/code-review.md +0 -129
- package/rules/zh/coding-style.md +0 -53
- package/rules/zh/development-workflow.md +0 -49
- package/rules/zh/git-workflow.md +0 -29
- package/rules/zh/hooks.md +0 -35
- package/rules/zh/patterns.md +0 -36
- package/rules/zh/performance.md +0 -60
- package/rules/zh/security.md +0 -34
- package/rules/zh/testing.md +0 -34
package/lib/model-router.mjs
CHANGED
|
@@ -3,38 +3,39 @@
|
|
|
3
3
|
*
|
|
4
4
|
* What it does:
|
|
5
5
|
* • Defines provider-family tier mappings and resolves model IDs.
|
|
6
|
-
* •
|
|
7
|
-
* alternatives for each tier (reasoning, standard, fast).
|
|
8
|
-
* • Provides helpers to apply free-preference modes (global, same‑family,
|
|
6
|
+
* • Provides helpers to apply free-preference modes (global, same-family,
|
|
9
7
|
* explicit selection) and to persist selections to .env.
|
|
8
|
+
* • Provider-aware failover: classifies failures, resolves fallback candidates,
|
|
9
|
+
* and tracks per-provider cooldowns in ~/.cx/provider-cooldowns.json.
|
|
10
10
|
*
|
|
11
11
|
* Consumed by:
|
|
12
|
-
* •
|
|
13
|
-
* • lib/
|
|
14
|
-
* • lib/
|
|
12
|
+
* • bin/construct – model configuration commands.
|
|
13
|
+
* • lib/prompt-composer.js – execution contract metadata.
|
|
14
|
+
* • lib/mcp/server.mjs – runtime model resolution.
|
|
15
|
+
* • lib/setup.mjs – environment variable generation.
|
|
16
|
+
* • lib/hooks/model-fallback.mjs – automatic failover on rate-limit / outage.
|
|
15
17
|
*
|
|
16
18
|
* Maintenance:
|
|
17
|
-
* • Adding new provider families → extend `PROVIDER_FAMILY_TIERS
|
|
18
|
-
*
|
|
19
|
-
* •
|
|
20
|
-
* •
|
|
21
|
-
* • Persisting selections → update `applyToEnv()` comment block.
|
|
19
|
+
* • Adding new provider families → extend `PROVIDER_FAMILY_TIERS`.
|
|
20
|
+
* • Changing scoring logic → modify `score()` in model-free-selector.mjs.
|
|
21
|
+
* • Persisting selections → update `applyToEnv()`.
|
|
22
|
+
* • Cooldown window → change `PROVIDER_COOLDOWN_MS`.
|
|
22
23
|
*/
|
|
23
24
|
import fs from "node:fs";
|
|
24
25
|
import path from "node:path";
|
|
26
|
+
import { spawnSync } from "child_process";
|
|
25
27
|
import { findOpenCodeConfigPath } from "./opencode-config.mjs";
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
};
|
|
28
|
+
import {
|
|
29
|
+
isFreeModel,
|
|
30
|
+
pollFreeModels,
|
|
31
|
+
preferFreeValue,
|
|
32
|
+
selectForTier,
|
|
33
|
+
topForTier,
|
|
34
|
+
} from "./model-free-selector.mjs";
|
|
35
|
+
|
|
36
|
+
function uniqueStrings(values = []) {
|
|
37
|
+
return [...new Set(values.filter((value) => typeof value === "string" && value.trim()))];
|
|
38
|
+
}
|
|
38
39
|
|
|
39
40
|
export const MODEL_TIER_BY_WORK_CATEGORY = {
|
|
40
41
|
visual: "standard",
|
|
@@ -45,7 +46,7 @@ export const MODEL_TIER_BY_WORK_CATEGORY = {
|
|
|
45
46
|
};
|
|
46
47
|
|
|
47
48
|
/**
|
|
48
|
-
* Provider
|
|
49
|
+
* Provider-family definitions. Each entry contains:
|
|
49
50
|
* - `test`: RegExp that matches provider URLs.
|
|
50
51
|
* - `resolve`: Function that maps selected tier values to concrete model IDs.
|
|
51
52
|
*
|
|
@@ -53,54 +54,98 @@ export const MODEL_TIER_BY_WORK_CATEGORY = {
|
|
|
53
54
|
*/
|
|
54
55
|
const PROVIDER_FAMILY_TIERS = [
|
|
55
56
|
{
|
|
57
|
+
id: 'anthropic',
|
|
58
|
+
label: 'Anthropic (direct)',
|
|
56
59
|
test: (modelId) => /^anthropic\//.test(modelId),
|
|
57
60
|
resolve: ({ reasoning, standard, fast }) => ({
|
|
58
61
|
reasoning: reasoning ?? "anthropic/claude-opus-4-6",
|
|
59
62
|
standard: standard ?? "anthropic/claude-sonnet-4-6",
|
|
60
63
|
fast: fast ?? "anthropic/claude-haiku-4-5-20251001",
|
|
61
64
|
}),
|
|
65
|
+
options: {
|
|
66
|
+
reasoning: ["anthropic/claude-opus-4-6", "anthropic/claude-sonnet-4-6"],
|
|
67
|
+
standard: ["anthropic/claude-sonnet-4-6", "anthropic/claude-opus-4-6"],
|
|
68
|
+
fast: ["anthropic/claude-haiku-4-5-20251001", "anthropic/claude-sonnet-4-6"],
|
|
69
|
+
},
|
|
62
70
|
},
|
|
63
71
|
{
|
|
72
|
+
id: 'openrouter-anthropic',
|
|
73
|
+
label: 'Anthropic via OpenRouter',
|
|
64
74
|
test: (modelId) => /^openrouter\/anthropic\//.test(modelId),
|
|
65
75
|
resolve: ({ reasoning, standard, fast }) => ({
|
|
66
76
|
reasoning: reasoning ?? "openrouter/anthropic/claude-opus-4-6",
|
|
67
77
|
standard: standard ?? "openrouter/anthropic/claude-sonnet-4-6",
|
|
68
78
|
fast: fast ?? "openrouter/anthropic/claude-haiku-4-5-20251001",
|
|
69
79
|
}),
|
|
80
|
+
options: {
|
|
81
|
+
reasoning: ["openrouter/anthropic/claude-opus-4-6", "openrouter/anthropic/claude-sonnet-4-6"],
|
|
82
|
+
standard: ["openrouter/anthropic/claude-sonnet-4-6", "openrouter/anthropic/claude-opus-4-6"],
|
|
83
|
+
fast: ["openrouter/anthropic/claude-haiku-4-5-20251001", "openrouter/anthropic/claude-sonnet-4-6"],
|
|
84
|
+
},
|
|
70
85
|
},
|
|
71
86
|
{
|
|
87
|
+
id: 'openrouter-google',
|
|
88
|
+
label: 'Google via OpenRouter',
|
|
72
89
|
test: (modelId) => /^openrouter\/google\//.test(modelId),
|
|
73
90
|
resolve: ({ reasoning, standard, fast }) => ({
|
|
74
91
|
reasoning: reasoning ?? "openrouter/google/gemini-2.5-pro",
|
|
75
92
|
standard: standard ?? "openrouter/google/gemini-2.0-flash-001",
|
|
76
93
|
fast: fast ?? "openrouter/google/gemma-3-27b-it:free",
|
|
77
94
|
}),
|
|
95
|
+
options: {
|
|
96
|
+
reasoning: ["openrouter/google/gemini-2.5-pro", "openrouter/google/gemini-2.5-flash"],
|
|
97
|
+
standard: ["openrouter/google/gemini-2.0-flash-001", "openrouter/google/gemini-2.5-flash"],
|
|
98
|
+
fast: ["openrouter/google/gemma-3-27b-it:free", "openrouter/google/gemini-2.0-flash-001"],
|
|
99
|
+
},
|
|
78
100
|
},
|
|
79
101
|
{
|
|
102
|
+
id: 'openrouter-deepseek',
|
|
103
|
+
label: 'DeepSeek via OpenRouter',
|
|
80
104
|
test: (modelId) => /^openrouter\/deepseek\//.test(modelId),
|
|
81
105
|
resolve: ({ reasoning, standard, fast }) => ({
|
|
82
106
|
reasoning: reasoning ?? "openrouter/deepseek/deepseek-r1",
|
|
83
107
|
standard: standard ?? "openrouter/deepseek/deepseek-v3",
|
|
84
108
|
fast: fast ?? standard ?? "openrouter/qwen/qwen3-coder:free",
|
|
85
109
|
}),
|
|
110
|
+
options: {
|
|
111
|
+
reasoning: ["openrouter/deepseek/deepseek-r1", "openrouter/deepseek/deepseek-v3"],
|
|
112
|
+
standard: ["openrouter/deepseek/deepseek-v3", "openrouter/deepseek/deepseek-r1"],
|
|
113
|
+
fast: ["openrouter/qwen/qwen3-coder:free", "openrouter/deepseek/deepseek-v3"],
|
|
114
|
+
},
|
|
86
115
|
},
|
|
87
116
|
{
|
|
117
|
+
id: 'openrouter-qwen',
|
|
118
|
+
label: 'Qwen via OpenRouter',
|
|
88
119
|
test: (modelId) => /^openrouter\/qwen\//.test(modelId),
|
|
89
120
|
resolve: ({ reasoning, standard, fast }) => ({
|
|
90
121
|
reasoning: reasoning ?? "openrouter/qwen/qwen3-coder",
|
|
91
122
|
standard: standard ?? "openrouter/qwen/qwen3-coder:free",
|
|
92
123
|
fast: fast ?? "openrouter/qwen/qwen2.5-coder-32b-instruct",
|
|
93
124
|
}),
|
|
125
|
+
options: {
|
|
126
|
+
reasoning: ["openrouter/qwen/qwen3-coder", "openrouter/qwen/qwen3-coder:free"],
|
|
127
|
+
standard: ["openrouter/qwen/qwen3-coder:free", "openrouter/qwen/qwen3-coder"],
|
|
128
|
+
fast: ["openrouter/qwen/qwen2.5-coder-32b-instruct", "openrouter/qwen/qwen3-coder:free"],
|
|
129
|
+
},
|
|
94
130
|
},
|
|
95
131
|
{
|
|
132
|
+
id: 'github-copilot',
|
|
133
|
+
label: 'GitHub Copilot',
|
|
96
134
|
test: (modelId) => /^github-copilot\//.test(modelId),
|
|
97
135
|
resolve: ({ reasoning, standard, fast }) => ({
|
|
98
136
|
reasoning: reasoning ?? "github-copilot/gpt-5.4",
|
|
99
137
|
standard: standard ?? "github-copilot/gpt-5.1",
|
|
100
138
|
fast: fast ?? "github-copilot/gpt-5.1-mini",
|
|
101
139
|
}),
|
|
140
|
+
options: {
|
|
141
|
+
reasoning: ["github-copilot/gpt-5.4", "github-copilot/gpt-5.1"],
|
|
142
|
+
standard: ["github-copilot/gpt-5.1", "github-copilot/gpt-5.1-mini"],
|
|
143
|
+
fast: ["github-copilot/gpt-5.1-mini", "github-copilot/gpt-5.1"],
|
|
144
|
+
},
|
|
102
145
|
},
|
|
103
146
|
{
|
|
147
|
+
id: 'openai',
|
|
148
|
+
label: 'OpenAI',
|
|
104
149
|
test: (modelId) => /^openai\//.test(modelId) || /^openrouter\/openai\//.test(modelId),
|
|
105
150
|
resolve: ({ reasoning, standard, fast }) => {
|
|
106
151
|
const prefix = /^openrouter\//.test(reasoning || standard || fast || "") ? "openrouter/openai" : "openai";
|
|
@@ -110,30 +155,207 @@ const PROVIDER_FAMILY_TIERS = [
|
|
|
110
155
|
fast: fast ?? `${prefix}/gpt-5.1-mini`,
|
|
111
156
|
};
|
|
112
157
|
},
|
|
158
|
+
options: {
|
|
159
|
+
reasoning: ["openai/gpt-5.4", "openrouter/openai/gpt-5.4", "openai/gpt-5.1"],
|
|
160
|
+
standard: ["openai/gpt-5.1", "openrouter/openai/gpt-5.1", "openai/gpt-5.1-mini"],
|
|
161
|
+
fast: ["openai/gpt-5.1-mini", "openrouter/openai/gpt-5.1-mini", "openai/gpt-5.1"],
|
|
162
|
+
},
|
|
113
163
|
},
|
|
114
164
|
{
|
|
165
|
+
id: 'openrouter-llama',
|
|
166
|
+
label: 'Meta Llama via OpenRouter',
|
|
115
167
|
test: (modelId) => /^openrouter\/meta-llama\//.test(modelId),
|
|
116
168
|
resolve: ({ reasoning, standard, fast }) => ({
|
|
117
169
|
reasoning: reasoning ?? "openrouter/meta-llama/llama-3.1-405b-instruct",
|
|
118
170
|
standard: standard ?? "openrouter/meta-llama/llama-3.3-70b-instruct",
|
|
119
171
|
fast: fast ?? "openrouter/meta-llama/llama-3.3-70b-instruct:free",
|
|
120
172
|
}),
|
|
173
|
+
options: {
|
|
174
|
+
reasoning: ["openrouter/meta-llama/llama-3.1-405b-instruct", "openrouter/meta-llama/llama-3.3-70b-instruct"],
|
|
175
|
+
standard: ["openrouter/meta-llama/llama-3.3-70b-instruct", "openrouter/meta-llama/llama-3.3-70b-instruct:free"],
|
|
176
|
+
fast: ["openrouter/meta-llama/llama-3.3-70b-instruct:free", "openrouter/meta-llama/llama-3.3-70b-instruct"],
|
|
177
|
+
},
|
|
178
|
+
},
|
|
179
|
+
{
|
|
180
|
+
id: 'ollama',
|
|
181
|
+
label: 'Ollama (local)',
|
|
182
|
+
test: (modelId) => /^ollama\//.test(modelId),
|
|
183
|
+
resolve: ({ reasoning, standard, fast }) => ({
|
|
184
|
+
reasoning: reasoning ?? "ollama/llama3.1:70b",
|
|
185
|
+
standard: standard ?? "ollama/llama3.1:8b",
|
|
186
|
+
fast: fast ?? "ollama/llama3.2:3b",
|
|
187
|
+
}),
|
|
188
|
+
options: {
|
|
189
|
+
reasoning: ["ollama/llama3.1:70b", "ollama/qwen2.5:32b", "ollama/deepseek-r1:32b"],
|
|
190
|
+
standard: ["ollama/llama3.1:8b", "ollama/qwen2.5:7b", "ollama/mistral:7b"],
|
|
191
|
+
fast: ["ollama/llama3.2:3b", "ollama/phi3:mini", "ollama/qwen2.5:3b"],
|
|
192
|
+
},
|
|
193
|
+
local: true,
|
|
194
|
+
requiresEnv: ['OLLAMA_BASE_URL'],
|
|
195
|
+
pricingHint: 'free · runs locally',
|
|
196
|
+
},
|
|
197
|
+
{
|
|
198
|
+
id: 'local',
|
|
199
|
+
label: 'Local OpenAI-compatible server',
|
|
200
|
+
test: (modelId) => /^local\//.test(modelId),
|
|
201
|
+
resolve: ({ reasoning, standard, fast }) => ({
|
|
202
|
+
reasoning: reasoning ?? "local/custom-large",
|
|
203
|
+
standard: standard ?? "local/custom-medium",
|
|
204
|
+
fast: fast ?? "local/custom-small",
|
|
205
|
+
}),
|
|
206
|
+
options: {
|
|
207
|
+
reasoning: ["local/custom-large"],
|
|
208
|
+
standard: ["local/custom-medium"],
|
|
209
|
+
fast: ["local/custom-small"],
|
|
210
|
+
},
|
|
211
|
+
local: true,
|
|
212
|
+
requiresEnv: ['LOCAL_LLM_BASE_URL'],
|
|
213
|
+
pricingHint: 'free · runs locally',
|
|
121
214
|
},
|
|
122
215
|
];
|
|
123
216
|
|
|
217
|
+
export { PROVIDER_FAMILY_TIERS };
|
|
218
|
+
|
|
219
|
+
/**
|
|
220
|
+
* Map provider family IDs to the env var(s) that indicate the provider
|
|
221
|
+
* is configured (has credentials). Used by getProviderModelCatalog to
|
|
222
|
+
* mark which providers are available.
|
|
223
|
+
*/
|
|
224
|
+
const PROVIDER_ENV_MAP = {
|
|
225
|
+
'anthropic': ['ANTHROPIC_API_KEY'],
|
|
226
|
+
'openrouter-anthropic': ['OPENROUTER_API_KEY', 'OPEN_ROUTER_API_KEY'],
|
|
227
|
+
'openrouter-google': ['OPENROUTER_API_KEY', 'OPEN_ROUTER_API_KEY'],
|
|
228
|
+
'openrouter-deepseek': ['OPENROUTER_API_KEY', 'OPEN_ROUTER_API_KEY'],
|
|
229
|
+
'openrouter-qwen': ['OPENROUTER_API_KEY', 'OPEN_ROUTER_API_KEY'],
|
|
230
|
+
'github-copilot': ['GITHUB_TOKEN', 'GH_TOKEN'],
|
|
231
|
+
'openai': ['OPENAI_API_KEY'],
|
|
232
|
+
'openrouter-llama': ['OPENROUTER_API_KEY', 'OPEN_ROUTER_API_KEY'],
|
|
233
|
+
'ollama': ['OLLAMA_BASE_URL'],
|
|
234
|
+
'local': ['LOCAL_LLM_BASE_URL'],
|
|
235
|
+
};
|
|
236
|
+
|
|
124
237
|
/**
|
|
125
|
-
*
|
|
126
|
-
*
|
|
127
|
-
|
|
238
|
+
* Check if a provider is configured by checking env vars and CLI tools.
|
|
239
|
+
* Uses the passed env, then checks config.env, then checks gh CLI for Copilot.
|
|
240
|
+
*/
|
|
241
|
+
function isProviderConfigured(familyId, env) {
|
|
242
|
+
const varNames = PROVIDER_ENV_MAP[familyId];
|
|
243
|
+
if (!varNames?.length) return false;
|
|
244
|
+
|
|
245
|
+
// Special case: Ollama on default port (no OLLAMA_BASE_URL needed)
|
|
246
|
+
if (familyId === 'ollama') {
|
|
247
|
+
try {
|
|
248
|
+
const r = spawnSync('curl', ['-s', '--connect-timeout', '1', '-o', '/dev/null', '-w', '%{http_code}', 'http://localhost:11434/api/tags'], { encoding: 'utf8', timeout: 3000 });
|
|
249
|
+
if (r.status === 0 && r.stdout?.trim() === '200') return true;
|
|
250
|
+
} catch { /* not available */ }
|
|
251
|
+
try {
|
|
252
|
+
const r = spawnSync('ollama', ['--version'], { encoding: 'utf8', timeout: 2000 });
|
|
253
|
+
if (r.status === 0) return true;
|
|
254
|
+
} catch { /* not available */ }
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
// 1. Check passed env (usually process.env)
|
|
258
|
+
const anyInEnv = varNames.some(name => {
|
|
259
|
+
const val = env?.[name];
|
|
260
|
+
return val && typeof val === 'string' && val.length > 0;
|
|
261
|
+
});
|
|
262
|
+
if (anyInEnv) return true;
|
|
263
|
+
|
|
264
|
+
// 2. Check construct config.env
|
|
265
|
+
try {
|
|
266
|
+
const homeDir = process.env.HOME || process.env.USERPROFILE || '/tmp';
|
|
267
|
+
const cfgPath = path.join(homeDir, '.construct', 'config.env');
|
|
268
|
+
if (fs.existsSync(cfgPath)) {
|
|
269
|
+
const content = fs.readFileSync(cfgPath, 'utf8');
|
|
270
|
+
const anyInConfig = varNames.some(name => {
|
|
271
|
+
const re = new RegExp(`^${name}=["']?(.+?)["']?$`, 'm');
|
|
272
|
+
const m = content.match(re);
|
|
273
|
+
return m && m[1] && m[1].length > 0;
|
|
274
|
+
});
|
|
275
|
+
if (anyInConfig) return true;
|
|
276
|
+
}
|
|
277
|
+
} catch { /* skip */ }
|
|
278
|
+
|
|
279
|
+
// 3. Special case: GitHub Copilot via gh CLI
|
|
280
|
+
if (familyId === 'github-copilot') {
|
|
281
|
+
try {
|
|
282
|
+
const r = spawnSync('gh', ['auth', 'status'], { encoding: 'utf8', timeout: 3000 });
|
|
283
|
+
return r.status === 0;
|
|
284
|
+
} catch { return false; }
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
// 4. Check ~/.env
|
|
288
|
+
try {
|
|
289
|
+
const homeEnv = path.join(process.env.HOME || process.env.USERPROFILE || '/tmp', '.env');
|
|
290
|
+
if (fs.existsSync(homeEnv)) {
|
|
291
|
+
const content = fs.readFileSync(homeEnv, 'utf8');
|
|
292
|
+
const anyInHomeEnv = varNames.some(name => {
|
|
293
|
+
const re = new RegExp(`^${name}=["']?(.+?)["']?$`, 'm');
|
|
294
|
+
const m = content.match(re);
|
|
295
|
+
return m && m[1] && m[1].length > 0;
|
|
296
|
+
});
|
|
297
|
+
if (anyInHomeEnv) return true;
|
|
298
|
+
}
|
|
299
|
+
} catch { /* skip */ }
|
|
300
|
+
|
|
301
|
+
// 5. Check shell rc files for op:// refs (1Password CLI integration)
|
|
302
|
+
try {
|
|
303
|
+
const homeDir = process.env.HOME || process.env.USERPROFILE || '/tmp';
|
|
304
|
+
const shellFiles = ['.zshrc', '.bashrc', '.bash_profile', '.profile'].map(f => path.join(homeDir, f));
|
|
305
|
+
for (const rcPath of shellFiles) {
|
|
306
|
+
if (!fs.existsSync(rcPath)) continue;
|
|
307
|
+
const content = fs.readFileSync(rcPath, 'utf8');
|
|
308
|
+
const anyInRc = varNames.some(name => {
|
|
309
|
+
// Direct export (with or without command substitution)
|
|
310
|
+
const re = new RegExp(`^\\s*export\\s+${name}=`, 'm');
|
|
311
|
+
return re.test(content);
|
|
312
|
+
});
|
|
313
|
+
if (anyInRc) return true;
|
|
314
|
+
}
|
|
315
|
+
} catch { /* skip */ }
|
|
316
|
+
|
|
317
|
+
return false;
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
export function getProviderModelCatalog({ env = process.env } = {}) {
|
|
321
|
+
const providers = PROVIDER_FAMILY_TIERS.map((family) => {
|
|
322
|
+
const tiers = family.resolve({});
|
|
323
|
+
const options = {
|
|
324
|
+
reasoning: uniqueStrings([...(family.options?.reasoning ?? []), tiers.reasoning]),
|
|
325
|
+
standard: uniqueStrings([...(family.options?.standard ?? []), tiers.standard]),
|
|
326
|
+
fast: uniqueStrings([...(family.options?.fast ?? []), tiers.fast]),
|
|
327
|
+
};
|
|
328
|
+
return {
|
|
329
|
+
id: family.id,
|
|
330
|
+
label: family.label,
|
|
331
|
+
tiers,
|
|
332
|
+
options,
|
|
333
|
+
local: family.local === true,
|
|
334
|
+
requiresEnv: Array.isArray(family.requiresEnv) ? family.requiresEnv : [],
|
|
335
|
+
pricingHint: family.pricingHint ?? null,
|
|
336
|
+
configured: isProviderConfigured(family.id, env),
|
|
337
|
+
};
|
|
338
|
+
});
|
|
339
|
+
|
|
340
|
+
const tierOptions = {
|
|
341
|
+
reasoning: uniqueStrings(providers.flatMap((provider) => provider.options.reasoning)),
|
|
342
|
+
standard: uniqueStrings(providers.flatMap((provider) => provider.options.standard)),
|
|
343
|
+
fast: uniqueStrings(providers.flatMap((provider) => provider.options.fast)),
|
|
344
|
+
};
|
|
345
|
+
|
|
346
|
+
return { providers, tierOptions };
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
/**
|
|
350
|
+
* Find the provider-family entry that matches a given model identifier.
|
|
128
351
|
*/
|
|
129
352
|
function matchProviderFamily(modelId) {
|
|
130
353
|
return PROVIDER_FAMILY_TIERS.find((entry) => entry.test(modelId));
|
|
131
354
|
}
|
|
132
355
|
|
|
133
356
|
/**
|
|
134
|
-
* Given a primary model the user selected, return tier
|
|
135
|
-
* the same provider family. Returns null if no family matches
|
|
136
|
-
* fall back to configured CX_MODEL_* values).
|
|
357
|
+
* Given a primary model the user selected, return tier -> modelId mapping in
|
|
358
|
+
* the same provider family. Returns null if no family matches.
|
|
137
359
|
*/
|
|
138
360
|
export function resolveTiersForPrimary(primaryModelId) {
|
|
139
361
|
if (!primaryModelId || typeof primaryModelId !== "string") return null;
|
|
@@ -143,11 +365,7 @@ export function resolveTiersForPrimary(primaryModelId) {
|
|
|
143
365
|
}
|
|
144
366
|
|
|
145
367
|
/**
|
|
146
|
-
* Read the OpenRouter API key from the OpenCode config file
|
|
147
|
-
* requiring it to be exported as `OPENROUTER_API_KEY`.
|
|
148
|
-
* @param {string} configPath – Path to the OpenCode config (defaults to the
|
|
149
|
-
* standard location).
|
|
150
|
-
* @returns {string} – The API key, or an empty string if not found.
|
|
368
|
+
* Read the OpenRouter API key from the OpenCode config file.
|
|
151
369
|
*/
|
|
152
370
|
export function readOpenRouterApiKeyFromOpenCodeConfig(
|
|
153
371
|
configPath = findOpenCodeConfigPath(),
|
|
@@ -165,108 +383,8 @@ export function readOpenRouterApiKeyFromOpenCodeConfig(
|
|
|
165
383
|
}
|
|
166
384
|
}
|
|
167
385
|
|
|
168
|
-
|
|
169
|
-
* Fetch the list of currently available free models from OpenRouter.
|
|
170
|
-
* Returns an array sorted by descending capability score.
|
|
171
|
-
* Returns `[]` (with a logged error) if the API key is missing or the request fails.
|
|
172
|
-
* @param {string} apiKey – OpenRouter bearer token.
|
|
173
|
-
* @returns {Promise<Array<{id:string,name:string,contextLength:number,isFree:boolean,score:number}>>}
|
|
174
|
-
*/
|
|
175
|
-
export async function pollFreeModels(apiKey) {
|
|
176
|
-
if (!apiKey) {
|
|
177
|
-
console.error(
|
|
178
|
-
"Error: OPENROUTER_API_KEY is not set. Cannot poll free models."
|
|
179
|
-
);
|
|
180
|
-
return [];
|
|
181
|
-
}
|
|
182
|
-
|
|
183
|
-
const controller = new AbortController();
|
|
184
|
-
const timeout = setTimeout(() => controller.abort(), FETCH_TIMEOUT_MS);
|
|
185
|
-
|
|
186
|
-
let data;
|
|
187
|
-
try {
|
|
188
|
-
const res = await fetch(OPENROUTER_MODELS_URL, {
|
|
189
|
-
headers: { Authorization: `Bearer ${apiKey}` },
|
|
190
|
-
signal: controller.signal,
|
|
191
|
-
});
|
|
192
|
-
if (!res.ok) {
|
|
193
|
-
console.error(
|
|
194
|
-
`Error: OpenRouter models endpoint returned ${res.status} ${res.statusText}`
|
|
195
|
-
);
|
|
196
|
-
return [];
|
|
197
|
-
}
|
|
198
|
-
data = await res.json();
|
|
199
|
-
} catch (err) {
|
|
200
|
-
if (err.name === "AbortError") {
|
|
201
|
-
console.error(
|
|
202
|
-
"Error: OpenRouter request timed out after 10 seconds."
|
|
203
|
-
);
|
|
204
|
-
} else {
|
|
205
|
-
console.error(`Error: Failed to fetch OpenRouter models — ${err.message}`);
|
|
206
|
-
}
|
|
207
|
-
return [];
|
|
208
|
-
} finally {
|
|
209
|
-
clearTimeout(timeout);
|
|
210
|
-
}
|
|
211
|
-
|
|
212
|
-
const models = (data?.data ?? [])
|
|
213
|
-
.filter(
|
|
214
|
-
(m) =>
|
|
215
|
-
m?.pricing?.prompt === "0" && m?.pricing?.completion === "0"
|
|
216
|
-
)
|
|
217
|
-
.map((m) => ({
|
|
218
|
-
id: `openrouter/${m.id}`,
|
|
219
|
-
name: m.name ?? m.id,
|
|
220
|
-
contextLength: m.context_length ?? 0,
|
|
221
|
-
isFree: true,
|
|
222
|
-
}));
|
|
223
|
-
|
|
224
|
-
return models.sort((a, b) => _score(b, "standard") - _score(a, "standard"));
|
|
225
|
-
}
|
|
226
|
-
|
|
227
|
-
/**
|
|
228
|
-
* Score a model for a given tier (higher is better).
|
|
229
|
-
* @private
|
|
230
|
-
* @param {object} model – Model object as returned by `pollFreeModels`.
|
|
231
|
-
* @param {"reasoning"|"standard"|"fast"} tier – The tier to score for.
|
|
232
|
-
* @returns {number} – Numerical score used for ranking.
|
|
233
|
-
*/
|
|
234
|
-
function _score(model, tier) {
|
|
235
|
-
const { id, contextLength } = model;
|
|
236
|
-
let score = 0;
|
|
237
|
-
|
|
238
|
-
// Context‑length weighting
|
|
239
|
-
if (contextLength >= 128_000) score += 40;
|
|
240
|
-
else if (contextLength >= 32_000) score += 20;
|
|
241
|
-
else if (contextLength >= 16_000) score += 10;
|
|
242
|
-
|
|
243
|
-
// Provider‑agnostic capability weighting – no vendor bias
|
|
244
|
-
if (id.includes("gemma-4")) score += 30;
|
|
245
|
-
if (id.includes("gemma-3")) score += 25;
|
|
246
|
-
if (id.includes("gpt-4o")) score += 25;
|
|
247
|
-
if (id.includes("nemotron-3-super")) score += 20;
|
|
248
|
-
if (id.includes("llama-3.3-70b")) score += 20;
|
|
249
|
-
if (id.includes("qwen3") && contextLength >= 32_000) score += 20;
|
|
250
|
-
if (id.includes("deepseek-r1")) score += 20;
|
|
251
|
-
if (id.includes("deepseek-v3")) score += 15;
|
|
252
|
-
// Extra boost for reasoning‑heavy models when the tier is reasoning
|
|
253
|
-
if (
|
|
254
|
-
tier === "reasoning" &&
|
|
255
|
-
(id.includes("deepseek-r1") ||
|
|
256
|
-
id.includes("nemotron-3-super") ||
|
|
257
|
-
id.includes("qwen3"))
|
|
258
|
-
) {
|
|
259
|
-
score += 15;
|
|
260
|
-
}
|
|
386
|
+
// --- Internal helpers ---
|
|
261
387
|
|
|
262
|
-
return score;
|
|
263
|
-
}
|
|
264
|
-
|
|
265
|
-
/**
|
|
266
|
-
* Read CX_MODEL_* assignments from a .env file.
|
|
267
|
-
* @param {string} envPath – Path to the .env file.
|
|
268
|
-
* @returns {{reasoning:any,standard:any,fast:any}} – Current values.
|
|
269
|
-
*/
|
|
270
388
|
function readEnvAssignments(envPath) {
|
|
271
389
|
const tierKeys = {
|
|
272
390
|
reasoning: "CX_MODEL_REASONING",
|
|
@@ -291,11 +409,6 @@ function readEnvAssignments(envPath) {
|
|
|
291
409
|
);
|
|
292
410
|
}
|
|
293
411
|
|
|
294
|
-
/**
|
|
295
|
-
* Extract the primary model ID from a definition that may be a string or an object.
|
|
296
|
-
* @param {string|object} def – Definition to inspect.
|
|
297
|
-
* @returns {string|null} – Primary model ID if found.
|
|
298
|
-
*/
|
|
299
412
|
function extractPrimary(def) {
|
|
300
413
|
if (typeof def === "string") return def;
|
|
301
414
|
if (def && typeof def === "object")
|
|
@@ -303,21 +416,11 @@ function extractPrimary(def) {
|
|
|
303
416
|
return null;
|
|
304
417
|
}
|
|
305
418
|
|
|
306
|
-
/**
|
|
307
|
-
* Build a defaults object from registry‑provided model mappings.
|
|
308
|
-
* Falls back to `BUILTIN_DEFAULTS` when a registry entry is missing.
|
|
309
|
-
* @param {{reasoning:any,standard:any,fast:any}} registryModels – Optional overrides.
|
|
310
|
-
* @returns {{reasoning:string,standard:string,fast:string}} – Resolved defaults.
|
|
311
|
-
*/
|
|
312
419
|
function getRegistryDefaults(registryModels = {}) {
|
|
313
420
|
return {
|
|
314
|
-
reasoning:
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
standard:
|
|
318
|
-
extractPrimary(registryModels.standard) ??
|
|
319
|
-
BUILTIN_DEFAULTS.standard,
|
|
320
|
-
fast: extractPrimary(registryModels.fast) ?? BUILTIN_DEFAULTS.fast,
|
|
421
|
+
reasoning: extractPrimary(registryModels.reasoning) ?? null,
|
|
422
|
+
standard: extractPrimary(registryModels.standard) ?? null,
|
|
423
|
+
fast: extractPrimary(registryModels.fast) ?? null,
|
|
321
424
|
};
|
|
322
425
|
}
|
|
323
426
|
|
|
@@ -351,24 +454,95 @@ function resolveTierDefinition(definition) {
|
|
|
351
454
|
};
|
|
352
455
|
}
|
|
353
456
|
|
|
457
|
+
// --- Provider cooldown helpers ---
|
|
458
|
+
|
|
459
|
+
const PROVIDER_COOLDOWN_MS = 5 * 60 * 1000;
|
|
460
|
+
|
|
461
|
+
/**
|
|
462
|
+
* Read the per-provider cooldown state file.
|
|
463
|
+
* Returns a map of providerKey → expiresAt timestamp.
|
|
464
|
+
*/
|
|
465
|
+
export function readProviderCooldowns(cooldownPath) {
|
|
466
|
+
try {
|
|
467
|
+
const raw = fs.readFileSync(cooldownPath, "utf8");
|
|
468
|
+
const parsed = JSON.parse(raw);
|
|
469
|
+
if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) return parsed;
|
|
470
|
+
} catch {
|
|
471
|
+
// missing or corrupt — treat as empty
|
|
472
|
+
}
|
|
473
|
+
return {};
|
|
474
|
+
}
|
|
475
|
+
|
|
476
|
+
/**
|
|
477
|
+
* Record a cooldown for a provider key, expiring after PROVIDER_COOLDOWN_MS.
|
|
478
|
+
*/
|
|
479
|
+
export function writeProviderCooldown(cooldownPath, provider, now = Date.now()) {
|
|
480
|
+
if (!provider) return;
|
|
481
|
+
const existing = readProviderCooldowns(cooldownPath);
|
|
482
|
+
existing[provider] = now + PROVIDER_COOLDOWN_MS;
|
|
483
|
+
fs.mkdirSync(path.dirname(cooldownPath), { recursive: true });
|
|
484
|
+
fs.writeFileSync(cooldownPath, JSON.stringify(existing, null, 2));
|
|
485
|
+
}
|
|
486
|
+
|
|
487
|
+
/**
|
|
488
|
+
* Return true if the given provider is still within its cooldown window.
|
|
489
|
+
*/
|
|
490
|
+
export function isProviderOnCooldown(cooldownPath, provider, now = Date.now()) {
|
|
491
|
+
if (!provider) return false;
|
|
492
|
+
const state = readProviderCooldowns(cooldownPath);
|
|
493
|
+
const expiresAt = state[provider];
|
|
494
|
+
return typeof expiresAt === "number" && now < expiresAt;
|
|
495
|
+
}
|
|
496
|
+
|
|
497
|
+
/**
|
|
498
|
+
* High-level entry point for the fallback hook.
|
|
499
|
+
*
|
|
500
|
+
* Given raw hook input, current env path, and a cooldown file path:
|
|
501
|
+
* 1. Classifies the failure.
|
|
502
|
+
* 2. Resolves a candidate model from the tier's fallback list, skipping any
|
|
503
|
+
* provider currently on cooldown.
|
|
504
|
+
* 3. Returns { targetModel, tier, reason } or null if nothing actionable.
|
|
505
|
+
*/
|
|
506
|
+
export function selectFallbackModel({
|
|
507
|
+
hookInput,
|
|
508
|
+
envPath,
|
|
509
|
+
cooldownPath,
|
|
510
|
+
registryModels = {},
|
|
511
|
+
now = Date.now(),
|
|
512
|
+
} = {}) {
|
|
513
|
+
const classified = classifyProviderFailure(hookInput);
|
|
514
|
+
if (!classified || !classified.retryable) return null;
|
|
515
|
+
|
|
516
|
+
const failingProvider = providerKey(classified.provider || "");
|
|
517
|
+
if (failingProvider && isProviderOnCooldown(cooldownPath, failingProvider, now)) return null;
|
|
518
|
+
|
|
519
|
+
const currentModels = readCurrentModels(envPath, registryModels);
|
|
520
|
+
const action = resolveFallbackAction({
|
|
521
|
+
failure: classified,
|
|
522
|
+
currentModels,
|
|
523
|
+
registryModels,
|
|
524
|
+
});
|
|
525
|
+
if (!action) return null;
|
|
526
|
+
|
|
527
|
+
const candidateProvider = providerKey(action.targetModel);
|
|
528
|
+
if (candidateProvider && isProviderOnCooldown(cooldownPath, candidateProvider, now)) return null;
|
|
529
|
+
|
|
530
|
+
return { targetModel: action.targetModel, tier: action.tier, reason: action.reason };
|
|
531
|
+
}
|
|
532
|
+
|
|
533
|
+
// --- Public API ---
|
|
534
|
+
|
|
354
535
|
export function classifyProviderFailure(input) {
|
|
355
536
|
const error = input?.error && typeof input.error === "object" ? input.error : input;
|
|
356
537
|
const text = flattenText([
|
|
357
|
-
error?.message,
|
|
358
|
-
error?.
|
|
359
|
-
|
|
360
|
-
error?.status,
|
|
361
|
-
error?.statusCode,
|
|
362
|
-
input?.message,
|
|
363
|
-
input?.error,
|
|
538
|
+
error?.message, error?.name, error?.code,
|
|
539
|
+
error?.status, error?.statusCode,
|
|
540
|
+
input?.message, input?.error,
|
|
364
541
|
]);
|
|
365
542
|
if (!text) return null;
|
|
366
543
|
|
|
367
544
|
const provider = [
|
|
368
|
-
error?.provider,
|
|
369
|
-
input?.provider,
|
|
370
|
-
input?.model?.provider,
|
|
371
|
-
input?.session?.provider,
|
|
545
|
+
error?.provider, input?.provider, input?.model?.provider, input?.session?.provider,
|
|
372
546
|
].find((value) => typeof value === "string" && value) || null;
|
|
373
547
|
|
|
374
548
|
const patterns = [
|
|
@@ -381,11 +555,7 @@ export function classifyProviderFailure(input) {
|
|
|
381
555
|
|
|
382
556
|
for (const pattern of patterns) {
|
|
383
557
|
if (pattern.test.test(text)) {
|
|
384
|
-
return {
|
|
385
|
-
kind: pattern.kind,
|
|
386
|
-
provider,
|
|
387
|
-
retryable: pattern.retryable,
|
|
388
|
-
};
|
|
558
|
+
return { kind: pattern.kind, provider, retryable: pattern.retryable };
|
|
389
559
|
}
|
|
390
560
|
}
|
|
391
561
|
|
|
@@ -410,10 +580,7 @@ export function resolveFallbackAction({
|
|
|
410
580
|
const currentProvider = providerKey(currentModel || "");
|
|
411
581
|
const failingProvider = providerKey(classified.provider || "");
|
|
412
582
|
|
|
413
|
-
const candidates = [
|
|
414
|
-
tierDef.primary,
|
|
415
|
-
...tierDef.fallback,
|
|
416
|
-
]
|
|
583
|
+
const candidates = [tierDef.primary, ...tierDef.fallback]
|
|
417
584
|
.filter((modelId) => typeof modelId === "string" && modelId)
|
|
418
585
|
.filter((modelId) => modelId !== currentModel)
|
|
419
586
|
.filter((modelId) => {
|
|
@@ -427,43 +594,25 @@ export function resolveFallbackAction({
|
|
|
427
594
|
const targetModel = candidates[0] ?? null;
|
|
428
595
|
if (!targetModel) return null;
|
|
429
596
|
|
|
430
|
-
return {
|
|
431
|
-
action: "apply-models",
|
|
432
|
-
reason: classified.kind,
|
|
433
|
-
targetModel,
|
|
434
|
-
tier,
|
|
435
|
-
};
|
|
597
|
+
return { action: "apply-models", reason: classified.kind, targetModel, tier };
|
|
436
598
|
}
|
|
437
599
|
|
|
438
600
|
function resolveTierAssignments(envValues = {}, registryModels = {}) {
|
|
439
601
|
const normalizedEnv = normalizeEnvAssignments(envValues);
|
|
440
|
-
const explicitSources = envValues?.sources && typeof envValues.sources === "object"
|
|
441
|
-
? envValues.sources
|
|
442
|
-
: {};
|
|
602
|
+
const explicitSources = envValues?.sources && typeof envValues.sources === "object" ? envValues.sources : {};
|
|
443
603
|
const defaults = getRegistryDefaults(registryModels);
|
|
444
604
|
const tiers = {};
|
|
445
605
|
|
|
446
606
|
for (const tier of ["reasoning", "standard", "fast"]) {
|
|
447
607
|
if (explicitSources[tier]) {
|
|
448
|
-
tiers[tier] = {
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
}
|
|
454
|
-
|
|
455
|
-
if (normalizedEnv[tier]) {
|
|
456
|
-
tiers[tier] = {
|
|
457
|
-
model: normalizedEnv[tier],
|
|
458
|
-
source: "env override",
|
|
459
|
-
};
|
|
460
|
-
continue;
|
|
608
|
+
tiers[tier] = { model: normalizedEnv[tier] ?? defaults[tier], source: explicitSources[tier] };
|
|
609
|
+
} else if (normalizedEnv[tier]) {
|
|
610
|
+
tiers[tier] = { model: normalizedEnv[tier], source: "env override" };
|
|
611
|
+
} else if (defaults[tier]) {
|
|
612
|
+
tiers[tier] = { model: defaults[tier], source: "registry" };
|
|
613
|
+
} else {
|
|
614
|
+
tiers[tier] = { model: null, source: "not configured" };
|
|
461
615
|
}
|
|
462
|
-
|
|
463
|
-
tiers[tier] = {
|
|
464
|
-
model: defaults[tier],
|
|
465
|
-
source: registryModels[tier] ? "registry default" : "built-in default",
|
|
466
|
-
};
|
|
467
616
|
}
|
|
468
617
|
|
|
469
618
|
return tiers;
|
|
@@ -494,45 +643,6 @@ export function resolveExecutionContractModelMetadata({
|
|
|
494
643
|
};
|
|
495
644
|
}
|
|
496
645
|
|
|
497
|
-
/**
|
|
498
|
-
* Check if a model ID ends with `:free`.
|
|
499
|
-
* @param {string} modelId – Model identifier.
|
|
500
|
-
* @returns {boolean} – True if the model is marked as free.
|
|
501
|
-
*/
|
|
502
|
-
function isFreeModel(modelId = "") {
|
|
503
|
-
return /:free$/i.test(modelId);
|
|
504
|
-
}
|
|
505
|
-
|
|
506
|
-
/**
|
|
507
|
-
* Choose a free model when multiple candidates exist.
|
|
508
|
-
* Preference order: explicit primary > fallback > registry default > builtin default.
|
|
509
|
-
* @param {string} primary – Explicitly selected model.
|
|
510
|
-
* @param {string} fallback – Second‑choice candidate.
|
|
511
|
-
* @param {string} registryDefault – Default from registry.
|
|
512
|
-
* @param {string} builtinDefault – Built‑in fallback.
|
|
513
|
-
* @returns {string|null} – The chosen model ID, or `null` if none selected.
|
|
514
|
-
*/
|
|
515
|
-
function preferFreeValue(
|
|
516
|
-
primary,
|
|
517
|
-
fallback,
|
|
518
|
-
registryDefault,
|
|
519
|
-
builtinDefault
|
|
520
|
-
) {
|
|
521
|
-
if (primary && isFreeModel(primary)) return primary;
|
|
522
|
-
if (fallback && isFreeModel(fallback)) return fallback;
|
|
523
|
-
if (registryDefault && isFreeModel(registryDefault))
|
|
524
|
-
return registryDefault;
|
|
525
|
-
if (builtinDefault && isFreeModel(builtinDefault))
|
|
526
|
-
return builtinDefault;
|
|
527
|
-
return primary ?? fallback ?? registryDefault ?? builtinDefault ?? null;
|
|
528
|
-
}
|
|
529
|
-
|
|
530
|
-
/**
|
|
531
|
-
* Infer tier‑specific models from a user‑selected model.
|
|
532
|
-
* @param {string} selectedModel – Model explicitly chosen by the user.
|
|
533
|
-
* @param {{registryModels:object,existing:object}} [options] – Context objects.
|
|
534
|
-
* @returns {{reasoning:string,standard:string,fast:string}|null} – Inferred tier assignments.
|
|
535
|
-
*/
|
|
536
646
|
export function inferTierModelsFromSelection(
|
|
537
647
|
selectedModel,
|
|
538
648
|
{ registryModels = {}, existing = {} } = {}
|
|
@@ -562,53 +672,20 @@ export function inferTierModelsFromSelection(
|
|
|
562
672
|
};
|
|
563
673
|
}
|
|
564
674
|
|
|
565
|
-
/**
|
|
566
|
-
* Apply a global “prefer‑free” preference to an existing tier set.
|
|
567
|
-
* @param {{reasoning:string,standard:string,fast:string}} tierSet – Current tier assignments.
|
|
568
|
-
* @param {{registryModels:object}} [options] – Registry context.
|
|
569
|
-
* @returns {{reasoning:string,standard:string,fast:string}} – Updated tier set.
|
|
570
|
-
*/
|
|
571
675
|
export function applyFreePreferenceToTierSet(tierSet, { registryModels = {} } = {}) {
|
|
572
676
|
const defaults = getRegistryDefaults(registryModels);
|
|
573
677
|
return {
|
|
574
|
-
reasoning: preferFreeValue(
|
|
575
|
-
|
|
576
|
-
|
|
577
|
-
defaults.reasoning,
|
|
578
|
-
BUILTIN_DEFAULTS.reasoning
|
|
579
|
-
),
|
|
580
|
-
standard: preferFreeValue(
|
|
581
|
-
tierSet.standard,
|
|
582
|
-
tierSet.fast,
|
|
583
|
-
defaults.standard,
|
|
584
|
-
BUILTIN_DEFAULTS.standard
|
|
585
|
-
),
|
|
586
|
-
fast: preferFreeValue(
|
|
587
|
-
tierSet.fast,
|
|
588
|
-
tierSet.standard,
|
|
589
|
-
defaults.fast,
|
|
590
|
-
BUILTIN_DEFAULTS.fast
|
|
591
|
-
),
|
|
678
|
+
reasoning: preferFreeValue(tierSet.reasoning, tierSet.standard, defaults.reasoning, null),
|
|
679
|
+
standard: preferFreeValue(tierSet.standard, tierSet.fast, defaults.standard, null),
|
|
680
|
+
fast: preferFreeValue(tierSet.fast, tierSet.standard, defaults.fast, null),
|
|
592
681
|
};
|
|
593
682
|
}
|
|
594
683
|
|
|
595
|
-
|
|
596
|
-
* Apply “prefer‑free‑same‑family” logic to a tier set based on an explicit
|
|
597
|
-
* model selection. Only swaps a tier to a free sibling if that sibling stays
|
|
598
|
-
* within the same provider family.
|
|
599
|
-
* @param {{reasoning:string,standard:string,fast:string}} tierSet – Current assignments.
|
|
600
|
-
* @param {string} selectedModel – Explicitly selected model ID.
|
|
601
|
-
* @returns {{reasoning:string,standard:string,fast:string}} – Updated tier set.
|
|
602
|
-
*/
|
|
603
|
-
export function applyFreeSameFamilyPreferenceToTierSet(
|
|
604
|
-
tierSet,
|
|
605
|
-
selectedModel
|
|
606
|
-
) {
|
|
684
|
+
export function applyFreeSameFamilyPreferenceToTierSet(tierSet, selectedModel) {
|
|
607
685
|
const family = matchProviderFamily(selectedModel);
|
|
608
686
|
if (!family) return tierSet;
|
|
609
687
|
|
|
610
688
|
const sameFamily = family.resolve({ reasoning: null, standard: null, fast: null });
|
|
611
|
-
|
|
612
689
|
const next = { ...tierSet };
|
|
613
690
|
for (const tier of ["reasoning", "standard", "fast"]) {
|
|
614
691
|
if (tierSet[tier] === selectedModel) continue;
|
|
@@ -618,57 +695,6 @@ export function applyFreeSameFamilyPreferenceToTierSet(
|
|
|
618
695
|
return next;
|
|
619
696
|
}
|
|
620
697
|
|
|
621
|
-
/**
|
|
622
|
-
* Select the best free model for a given tier from an array of free models.
|
|
623
|
-
* Falls back to a registry‑provided fallback chain if no suitable free model exists.
|
|
624
|
-
* @param {Array} freeModels – Output of `pollFreeModels`.
|
|
625
|
-
* @param {"reasoning"|"standard"|"fast"} tier – Target tier.
|
|
626
|
-
* @param {string[]} [registryFallbacks=[]] – Ordered list of fallback IDs.
|
|
627
|
-
* @returns {string|null} – Best model ID, or `null` if none found.
|
|
628
|
-
*/
|
|
629
|
-
export function selectForTier(freeModels, tier, registryFallbacks = []) {
|
|
630
|
-
const minContext = tier === "fast" ? 8_000 : tier === "standard" ? 16_000 : 32_000;
|
|
631
|
-
|
|
632
|
-
if (freeModels && freeModels.length > 0) {
|
|
633
|
-
const candidates = freeModels
|
|
634
|
-
.filter((m) => m.contextLength >= minContext)
|
|
635
|
-
.map((m) => ({ ...m, tierScore: _score(m, tier) }))
|
|
636
|
-
.sort((a, b) => b.tierScore - a.tierScore);
|
|
637
|
-
|
|
638
|
-
if (candidates[0]?.id) return candidates[0].id;
|
|
639
|
-
}
|
|
640
|
-
|
|
641
|
-
// No suitable free model found – walk the registry fallback chain
|
|
642
|
-
return registryFallbacks[0] ?? null;
|
|
643
|
-
}
|
|
644
|
-
|
|
645
|
-
/**
|
|
646
|
-
* Return the top N candidates for a given tier (used for UI lists).
|
|
647
|
-
* @param {Array} freeModels – Array of model objects.
|
|
648
|
-
* @param {"reasoning"|"standard"|"fast"} tier – Target tier.
|
|
649
|
-
* @param {number} n – Number of candidates to return (default = 3).
|
|
650
|
-
* @returns {Array} – Sorted candidate objects.
|
|
651
|
-
*/
|
|
652
|
-
export function topForTier(freeModels, tier, n = 3) {
|
|
653
|
-
if (!freeModels || freeModels.length === 0) return [];
|
|
654
|
-
|
|
655
|
-
const minContext = tier === "fast" ? 8_000 : tier === "standard" ? 16_000 : 32_000;
|
|
656
|
-
return freeModels
|
|
657
|
-
.filter((m) => m.contextLength >= minContext)
|
|
658
|
-
.map((m) => ({ ...m, tierScore: _score(m, tier) }))
|
|
659
|
-
.sort((a, b) => b.tierScore - a.tierScore)
|
|
660
|
-
.slice(0, n);
|
|
661
|
-
}
|
|
662
|
-
|
|
663
|
-
/**
|
|
664
|
-
* Persist CX_MODEL_* assignments into a .env file.
|
|
665
|
-
* • Removes any existing comment block that was generated by `--apply`.
|
|
666
|
-
* • Inserts a fresh comment with the current date.
|
|
667
|
-
* • Writes the new CX_MODEL_* lines at the top of the file (or appends if the file only contains comments).
|
|
668
|
-
*
|
|
669
|
-
* @param {string} envPath – Path to the .env file.
|
|
670
|
-
* @param {{reasoning?:string,standard?:string,fast?:string}} selections – Model IDs to store.
|
|
671
|
-
*/
|
|
672
698
|
export function applyToEnv(envPath, selections) {
|
|
673
699
|
const existing = fs.existsSync(envPath) ? fs.readFileSync(envPath, "utf8") : "";
|
|
674
700
|
const date = new Date().toISOString().slice(0, 10);
|
|
@@ -680,7 +706,6 @@ export function applyToEnv(envPath, selections) {
|
|
|
680
706
|
fast: "CX_MODEL_FAST",
|
|
681
707
|
};
|
|
682
708
|
|
|
683
|
-
// Strip out any old auto‑generated block
|
|
684
709
|
let lines = existing.split("\n");
|
|
685
710
|
lines = lines.filter((l) => {
|
|
686
711
|
const trimmed = l.trim();
|
|
@@ -690,38 +715,21 @@ export function applyToEnv(envPath, selections) {
|
|
|
690
715
|
return true;
|
|
691
716
|
});
|
|
692
717
|
|
|
693
|
-
// Build the new comment + model lines
|
|
694
718
|
const modelLines = [`${commentMarker} ${date}`];
|
|
695
719
|
for (const [tier, envKey] of Object.entries(tierMap)) {
|
|
696
|
-
if (selections[tier]) {
|
|
697
|
-
modelLines.push(`${envKey}=${selections[tier]}`);
|
|
698
|
-
}
|
|
720
|
+
if (selections[tier]) modelLines.push(`${envKey}=${selections[tier]}`);
|
|
699
721
|
}
|
|
700
722
|
|
|
701
|
-
// Insert just before the first blank line (or at EOF)
|
|
702
723
|
const insertIdx = lines.findLastIndex((l) => l.trim() !== "") + 1;
|
|
703
|
-
lines.splice(
|
|
704
|
-
insertIdx === 0 ? lines.length : insertIdx,
|
|
705
|
-
0,
|
|
706
|
-
"",
|
|
707
|
-
...modelLines
|
|
708
|
-
);
|
|
724
|
+
lines.splice(insertIdx === 0 ? lines.length : insertIdx, 0, "", ...modelLines);
|
|
709
725
|
|
|
710
726
|
fs.writeFileSync(envPath, lines.join("\n"));
|
|
711
727
|
}
|
|
712
728
|
|
|
713
|
-
/**
|
|
714
|
-
* Remove all CX_MODEL_* entries and their associated comment from a .env file.
|
|
715
|
-
* @param {string} envPath – Path to the .env file.
|
|
716
|
-
*/
|
|
717
729
|
export function resetEnv(envPath) {
|
|
718
730
|
if (!fs.existsSync(envPath)) return;
|
|
719
731
|
const commentMarker = "# Auto-set by construct models --apply on";
|
|
720
|
-
const tierKeys = new Set([
|
|
721
|
-
"CX_MODEL_REASONING",
|
|
722
|
-
"CX_MODEL_STANDARD",
|
|
723
|
-
"CX_MODEL_FAST",
|
|
724
|
-
]);
|
|
732
|
+
const tierKeys = new Set(["CX_MODEL_REASONING", "CX_MODEL_STANDARD", "CX_MODEL_FAST"]);
|
|
725
733
|
|
|
726
734
|
const lines = fs.readFileSync(envPath, "utf8").split("\n");
|
|
727
735
|
const filtered = lines.filter((l) => {
|
|
@@ -735,42 +743,14 @@ export function resetEnv(envPath) {
|
|
|
735
743
|
fs.writeFileSync(envPath, filtered.join("\n"));
|
|
736
744
|
}
|
|
737
745
|
|
|
738
|
-
/**
|
|
739
|
-
* Set a single tier’s model in the .env file.
|
|
740
|
-
* @param {string} envPath – Path to the .env file.
|
|
741
|
-
* @param {"reasoning"|"standard"|"fast"} tier – Target tier.
|
|
742
|
-
* @param {string} modelId – Model identifier to store.
|
|
743
|
-
*/
|
|
744
746
|
export function setTierModel(envPath, tier, modelId) {
|
|
745
747
|
applyToEnv(envPath, { [tier]: modelId });
|
|
746
748
|
}
|
|
747
749
|
|
|
748
|
-
|
|
749
|
-
* Replace an existing model selection with a new one, applying any
|
|
750
|
-
* preference‑override flags (`preferFreeSameFamily` or `preferFree`).
|
|
751
|
-
*
|
|
752
|
-
* applyToEnv() → writes the updated assignments.
|
|
753
|
-
*
|
|
754
|
-
* @param {string} envPath – Path to the .env file.
|
|
755
|
-
* @param {"reasoning"|"standard"|"fast"} tier – Tier whose model should be set.
|
|
756
|
-
* @param {string} modelId – New model ID.
|
|
757
|
-
* @param {{registryModels:object,existing:object}} [context] – Additional context objects.
|
|
758
|
-
* @param {object} [options] – Preference flags.
|
|
759
|
-
* @returns {object} – The resolved tier assignments after overrides.
|
|
760
|
-
*/
|
|
761
|
-
export function setModelWithTierInference(
|
|
762
|
-
envPath,
|
|
763
|
-
tier,
|
|
764
|
-
modelId,
|
|
765
|
-
registryModels = {},
|
|
766
|
-
options = {}
|
|
767
|
-
) {
|
|
750
|
+
export function setModelWithTierInference(envPath, tier, modelId, registryModels = {}, options = {}) {
|
|
768
751
|
const existing = readEnvAssignments(envPath);
|
|
769
752
|
existing[tier] = modelId;
|
|
770
|
-
const inferred = inferTierModelsFromSelection(modelId, {
|
|
771
|
-
registryModels,
|
|
772
|
-
existing,
|
|
773
|
-
}) || existing;
|
|
753
|
+
const inferred = inferTierModelsFromSelection(modelId, { registryModels, existing }) || existing;
|
|
774
754
|
inferred[tier] = modelId;
|
|
775
755
|
|
|
776
756
|
let resolved = inferred;
|
|
@@ -784,13 +764,6 @@ export function setModelWithTierInference(
|
|
|
784
764
|
return resolved;
|
|
785
765
|
}
|
|
786
766
|
|
|
787
|
-
/**
|
|
788
|
-
* Read current model assignments from .env, falling back to registry defaults.
|
|
789
|
-
* Provides a source‑tracking map for debugging.
|
|
790
|
-
* @param {string} envPath – Path to the .env file.
|
|
791
|
-
* @param {{reasoning:any,standard:any,fast:any}} registryModels – Reference data.
|
|
792
|
-
* @returns {{reasoning:string,standard:string,fast:string,sources:Record<string,string>>}
|
|
793
|
-
*/
|
|
794
767
|
export function readCurrentModels(envPath, registryModels = {}) {
|
|
795
768
|
const envValues = arguments.length > 2 ? arguments[2] : {};
|
|
796
769
|
const fileAssignments = readEnvAssignments(envPath);
|
|
@@ -808,3 +781,6 @@ export function readCurrentModels(envPath, registryModels = {}) {
|
|
|
808
781
|
}
|
|
809
782
|
return result;
|
|
810
783
|
}
|
|
784
|
+
|
|
785
|
+
export { resolveProviderCapabilities } from './provider-capabilities.js';
|
|
786
|
+
export { resolveProviderCapabilitiesSync } from './provider-capabilities.js';
|