@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
|
@@ -0,0 +1,557 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* lib/telemetry/model-pricing-catalog.mjs — Model pricing catalog with live / static fallback.
|
|
3
|
+
*
|
|
4
|
+
* Pricing source priority (highest to lowest):
|
|
5
|
+
* 1. Static table — cold-start fallback; always wins for known Anthropic/Copilot models
|
|
6
|
+
* 2. LiteLLM pricing JSON — community-maintained, no auth, fetched at startup with 24h disk cache
|
|
7
|
+
* 3. OpenRouter — live but includes markup; used for model sync only, not cost accounting
|
|
8
|
+
*
|
|
9
|
+
* Anthropic prompt-caching ratios applied against base input price:
|
|
10
|
+
* cache_read = 0.10 × input
|
|
11
|
+
* cache_write_5m = 1.25 × input
|
|
12
|
+
* cache_write_1h = 2.00 × input
|
|
13
|
+
*
|
|
14
|
+
* @see https://raw.githubusercontent.com/BerriAI/litellm/main/model_prices_and_context_window.json
|
|
15
|
+
*/
|
|
16
|
+
import { readFileSync, writeFileSync, existsSync, mkdirSync } from "fs";
|
|
17
|
+
import { join } from "path";
|
|
18
|
+
import { homedir } from "os";
|
|
19
|
+
|
|
20
|
+
const OPENROUTER_MODELS_URL = "https://openrouter.ai/api/v1/models";
|
|
21
|
+
const LITELLM_PRICING_URL =
|
|
22
|
+
"https://raw.githubusercontent.com/BerriAI/litellm/main/model_prices_and_context_window.json";
|
|
23
|
+
const PRICING_CACHE_TTL_MS = 24 * 60 * 60 * 1000;
|
|
24
|
+
const PRICING_CACHE_PATH = join(homedir(), ".cx", "pricing-cache.json");
|
|
25
|
+
|
|
26
|
+
export function describePricingCatalogFreshness() {
|
|
27
|
+
try {
|
|
28
|
+
if (!existsSync(PRICING_CACHE_PATH)) {
|
|
29
|
+
return {
|
|
30
|
+
present: false,
|
|
31
|
+
ageMs: null,
|
|
32
|
+
ageHours: null,
|
|
33
|
+
stale: false,
|
|
34
|
+
source: 'static-only',
|
|
35
|
+
message: 'LiteLLM cache missing — using static + OpenRouter only. Run `node bin/construct status` once to populate.',
|
|
36
|
+
};
|
|
37
|
+
}
|
|
38
|
+
const cached = JSON.parse(readFileSync(PRICING_CACHE_PATH, 'utf8'));
|
|
39
|
+
const fetchedAt = Number(cached?.fetchedAt) || 0;
|
|
40
|
+
if (!fetchedAt) {
|
|
41
|
+
return {
|
|
42
|
+
present: true,
|
|
43
|
+
ageMs: null,
|
|
44
|
+
ageHours: null,
|
|
45
|
+
stale: true,
|
|
46
|
+
source: 'litellm',
|
|
47
|
+
message: 'LiteLLM cache exists but has no fetchedAt timestamp — treat as stale.',
|
|
48
|
+
};
|
|
49
|
+
}
|
|
50
|
+
const ageMs = Date.now() - fetchedAt;
|
|
51
|
+
const ageHours = ageMs / (60 * 60 * 1000);
|
|
52
|
+
const stale = ageMs >= PRICING_CACHE_TTL_MS;
|
|
53
|
+
return {
|
|
54
|
+
present: true,
|
|
55
|
+
ageMs,
|
|
56
|
+
ageHours,
|
|
57
|
+
stale,
|
|
58
|
+
source: 'litellm',
|
|
59
|
+
message: stale
|
|
60
|
+
? `LiteLLM cache is ${ageHours.toFixed(1)}h old (TTL 24h) — refresh on next plugin load.`
|
|
61
|
+
: `LiteLLM cache fresh (${ageHours.toFixed(1)}h old).`,
|
|
62
|
+
};
|
|
63
|
+
} catch (err) {
|
|
64
|
+
return {
|
|
65
|
+
present: false,
|
|
66
|
+
ageMs: null,
|
|
67
|
+
ageHours: null,
|
|
68
|
+
stale: true,
|
|
69
|
+
source: 'unknown',
|
|
70
|
+
message: `pricing cache unreadable: ${err.message}`,
|
|
71
|
+
};
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
// Anthropic cache pricing multipliers applied against the base input price.
|
|
76
|
+
const ANTHROPIC_CACHE_READ_RATIO = 0.1;
|
|
77
|
+
const ANTHROPIC_CACHE_WRITE_5M_RATIO = 1.25;
|
|
78
|
+
const ANTHROPIC_CACHE_WRITE_1H_RATIO = 2.0;
|
|
79
|
+
|
|
80
|
+
function withAnthropicCachePricing(entry) {
|
|
81
|
+
const inputPrice = entry.inputPrice;
|
|
82
|
+
return {
|
|
83
|
+
...entry,
|
|
84
|
+
cacheReadPrice: inputPrice * ANTHROPIC_CACHE_READ_RATIO,
|
|
85
|
+
cacheWrite5mPrice: inputPrice * ANTHROPIC_CACHE_WRITE_5M_RATIO,
|
|
86
|
+
cacheWrite1hPrice: inputPrice * ANTHROPIC_CACHE_WRITE_1H_RATIO,
|
|
87
|
+
};
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
async function fetchLiteLLMPricing(fetchImpl = globalThis.fetch) {
|
|
91
|
+
try {
|
|
92
|
+
if (existsSync(PRICING_CACHE_PATH)) {
|
|
93
|
+
const cached = JSON.parse(readFileSync(PRICING_CACHE_PATH, "utf8"));
|
|
94
|
+
if (cached?.fetchedAt && Date.now() - cached.fetchedAt < PRICING_CACHE_TTL_MS) {
|
|
95
|
+
return cached.models || [];
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
} catch { /* non-critical */ }
|
|
99
|
+
|
|
100
|
+
try {
|
|
101
|
+
const res = await fetchImpl(LITELLM_PRICING_URL, {
|
|
102
|
+
headers: { "User-Agent": "construct-telemetry/1.0" },
|
|
103
|
+
signal: AbortSignal.timeout(12_000),
|
|
104
|
+
});
|
|
105
|
+
if (!res.ok) return [];
|
|
106
|
+
const raw = await res.json();
|
|
107
|
+
const models = [];
|
|
108
|
+
for (const [id, data] of Object.entries(raw)) {
|
|
109
|
+
if (!data?.input_cost_per_token) continue;
|
|
110
|
+
const inputPrice = Number(data.input_cost_per_token) || 0;
|
|
111
|
+
const outputPrice = Number(data.output_cost_per_token) || 0;
|
|
112
|
+
const cacheReadPrice = Number(data.cache_read_input_token_cost) || 0;
|
|
113
|
+
|
|
114
|
+
// LiteLLM exposes a single cache_creation field with no 5m/1h split.
|
|
115
|
+
// Map it to 5m; fall back to the ratio for 1h.
|
|
116
|
+
|
|
117
|
+
const cacheWrite5mPrice = Number(data.cache_creation_input_token_cost) || 0;
|
|
118
|
+
const cacheWrite1hPrice =
|
|
119
|
+
Number(data.cache_creation_input_token_cost_1h) ||
|
|
120
|
+
inputPrice * ANTHROPIC_CACHE_WRITE_1H_RATIO;
|
|
121
|
+
const maxInputTokens = Number(data.max_input_tokens) || 0;
|
|
122
|
+
const escaped = id.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
123
|
+
models.push({
|
|
124
|
+
modelName: id,
|
|
125
|
+
matchPattern: `(?i)^${escaped}$`,
|
|
126
|
+
inputPrice,
|
|
127
|
+
outputPrice,
|
|
128
|
+
cacheReadPrice,
|
|
129
|
+
cacheWrite5mPrice,
|
|
130
|
+
cacheWrite1hPrice,
|
|
131
|
+
maxInputTokens,
|
|
132
|
+
source: "litellm",
|
|
133
|
+
});
|
|
134
|
+
}
|
|
135
|
+
try {
|
|
136
|
+
mkdirSync(join(homedir(), ".cx"), { recursive: true });
|
|
137
|
+
writeFileSync(PRICING_CACHE_PATH, JSON.stringify({ fetchedAt: Date.now(), models }));
|
|
138
|
+
} catch { /* non-critical */ }
|
|
139
|
+
return models;
|
|
140
|
+
} catch {
|
|
141
|
+
return [];
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
const ANTHROPIC_MODELS = [
|
|
146
|
+
{
|
|
147
|
+
modelName: "claude-opus-4-7",
|
|
148
|
+
matchPattern: "(?i)^(anthropic/)?claude-opus-4-7$",
|
|
149
|
+
inputPrice: 15 / 1_000_000,
|
|
150
|
+
outputPrice: 75 / 1_000_000,
|
|
151
|
+
maxInputTokens: 200_000,
|
|
152
|
+
},
|
|
153
|
+
{
|
|
154
|
+
modelName: "claude-opus-4-5",
|
|
155
|
+
matchPattern: "(?i)^(anthropic/)?claude-opus-4-5$",
|
|
156
|
+
inputPrice: 15 / 1_000_000,
|
|
157
|
+
outputPrice: 75 / 1_000_000,
|
|
158
|
+
maxInputTokens: 200_000,
|
|
159
|
+
},
|
|
160
|
+
{
|
|
161
|
+
modelName: "claude-sonnet-4-6",
|
|
162
|
+
matchPattern: "(?i)^(anthropic/)?claude-sonnet-4-6$",
|
|
163
|
+
inputPrice: 3 / 1_000_000,
|
|
164
|
+
outputPrice: 15 / 1_000_000,
|
|
165
|
+
maxInputTokens: 200_000,
|
|
166
|
+
},
|
|
167
|
+
{
|
|
168
|
+
modelName: "claude-sonnet-4-5",
|
|
169
|
+
matchPattern: "(?i)^(anthropic/)?claude-sonnet-4-5$",
|
|
170
|
+
inputPrice: 3 / 1_000_000,
|
|
171
|
+
outputPrice: 15 / 1_000_000,
|
|
172
|
+
maxInputTokens: 200_000,
|
|
173
|
+
},
|
|
174
|
+
{
|
|
175
|
+
modelName: "claude-haiku-4-5",
|
|
176
|
+
matchPattern: "(?i)^(anthropic/)?claude-haiku-4-5(-\\d+)?$",
|
|
177
|
+
inputPrice: 1 / 1_000_000,
|
|
178
|
+
outputPrice: 5 / 1_000_000,
|
|
179
|
+
maxInputTokens: 200_000,
|
|
180
|
+
},
|
|
181
|
+
].map(withAnthropicCachePricing);
|
|
182
|
+
|
|
183
|
+
// Per-request billing — register at $0 so telemetry records usage without phantom costs.
|
|
184
|
+
const GITHUB_COPILOT_MODELS = [
|
|
185
|
+
{ modelName: "github-copilot/gpt-5.4", matchPattern: "(?i)^(github-copilot/)?gpt-5\\.4$" },
|
|
186
|
+
{ modelName: "github-copilot/gpt-4.1", matchPattern: "(?i)^(github-copilot/)?gpt-4\\.1$" },
|
|
187
|
+
{ modelName: "github-copilot/claude-sonnet-4-5", matchPattern: "(?i)^github-copilot/claude-sonnet" },
|
|
188
|
+
{ modelName: "github-copilot/gemini-2.5-pro", matchPattern: "(?i)^github-copilot/gemini" },
|
|
189
|
+
{ modelName: "github-copilot/o4-mini", matchPattern: "(?i)^github-copilot/o4" },
|
|
190
|
+
].map((m) => ({ ...m, inputPrice: 0, outputPrice: 0 }));
|
|
191
|
+
|
|
192
|
+
let syncDone = false;
|
|
193
|
+
let cachedPricingCatalog = buildPricingCatalog();
|
|
194
|
+
|
|
195
|
+
function normalizeModelKey(modelName = "") {
|
|
196
|
+
return String(modelName ?? "").trim().toLowerCase();
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
function indexPricingEntry(map, model) {
|
|
200
|
+
if (!model?.modelName) return;
|
|
201
|
+
const key = normalizeModelKey(model.modelName);
|
|
202
|
+
const inputPrice = Number(model.inputPrice) || 0;
|
|
203
|
+
const entry = {
|
|
204
|
+
modelName: model.modelName,
|
|
205
|
+
inputPrice,
|
|
206
|
+
outputPrice: Number(model.outputPrice) || 0,
|
|
207
|
+
cacheReadPrice: Number(model.cacheReadPrice) || 0,
|
|
208
|
+
cacheWrite5mPrice: Number(model.cacheWrite5mPrice) || 0,
|
|
209
|
+
cacheWrite1hPrice: Number(model.cacheWrite1hPrice) || 0,
|
|
210
|
+
maxInputTokens: Number(model.maxInputTokens) || 0,
|
|
211
|
+
source: model.source || "catalog",
|
|
212
|
+
};
|
|
213
|
+
map.set(key, entry);
|
|
214
|
+
if (key.startsWith("anthropic/")) map.set(key.replace(/^anthropic\//, ""), entry);
|
|
215
|
+
if (key.startsWith("github-copilot/")) map.set(key.replace(/^github-copilot\//, ""), entry);
|
|
216
|
+
if (key.startsWith("openrouter/")) map.set(key.replace(/^openrouter\//, ""), entry);
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
// Priority is fallback → live, so live always wins. Static entries are
|
|
220
|
+
// indexed first as the offline fallback (they only apply if no live
|
|
221
|
+
// source has the model). OpenRouter is indexed next (live but includes
|
|
222
|
+
// routing markup). LiteLLM is indexed last and overrides everything —
|
|
223
|
+
// it's the authoritative source of provider pricing for paid models,
|
|
224
|
+
// refreshed daily. Anthropic / OpenAI publish prices on docs pages with
|
|
225
|
+
// no programmatic API today; LiteLLM is the closest thing to a live
|
|
226
|
+
// provider pricing feed Construct can actually consume.
|
|
227
|
+
//
|
|
228
|
+
// GitHub Copilot stays static at $0 — bills per-request, not per-token.
|
|
229
|
+
export function buildPricingCatalog(openRouterModels = [], litellmModels = []) {
|
|
230
|
+
const map = new Map();
|
|
231
|
+
for (const model of [...ANTHROPIC_MODELS, ...GITHUB_COPILOT_MODELS]) {
|
|
232
|
+
indexPricingEntry(map, { ...model, source: "static-fallback" });
|
|
233
|
+
}
|
|
234
|
+
for (const model of openRouterModels) {
|
|
235
|
+
indexPricingEntry(map, { ...model, source: "openrouter" });
|
|
236
|
+
}
|
|
237
|
+
for (const model of litellmModels) {
|
|
238
|
+
indexPricingEntry(map, { ...model, source: model.source || "litellm" });
|
|
239
|
+
}
|
|
240
|
+
for (const model of GITHUB_COPILOT_MODELS) {
|
|
241
|
+
indexPricingEntry(map, { ...model, source: "static" });
|
|
242
|
+
}
|
|
243
|
+
return map;
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
// Sanity guard: when LiteLLM and the static fallback disagree by more
|
|
247
|
+
// than 25% on a known model, return a warning so a bad upstream entry
|
|
248
|
+
// doesn't silently overbill or underbill the operator. 25% is the
|
|
249
|
+
// threshold that allows for normal price drift (10–15% is common)
|
|
250
|
+
// without tripping on real provider price changes.
|
|
251
|
+
export function checkPricingDivergence(litellmModels = []) {
|
|
252
|
+
const warnings = [];
|
|
253
|
+
const staticByKey = new Map();
|
|
254
|
+
for (const m of ANTHROPIC_MODELS) staticByKey.set(normalizeModelKey(m.modelName), m);
|
|
255
|
+
|
|
256
|
+
for (const live of litellmModels) {
|
|
257
|
+
const key = normalizeModelKey(live.modelName);
|
|
258
|
+
const stat = staticByKey.get(key) || staticByKey.get(key.replace(/^anthropic\//, ''));
|
|
259
|
+
if (!stat) continue;
|
|
260
|
+
const liveInput = Number(live.inputPrice) || 0;
|
|
261
|
+
const liveOutput = Number(live.outputPrice) || 0;
|
|
262
|
+
const staticInput = Number(stat.inputPrice) || 0;
|
|
263
|
+
const staticOutput = Number(stat.outputPrice) || 0;
|
|
264
|
+
const inputDelta = staticInput > 0 ? Math.abs(liveInput - staticInput) / staticInput : 0;
|
|
265
|
+
const outputDelta = staticOutput > 0 ? Math.abs(liveOutput - staticOutput) / staticOutput : 0;
|
|
266
|
+
if (inputDelta > 0.25 || outputDelta > 0.25) {
|
|
267
|
+
warnings.push({
|
|
268
|
+
modelName: live.modelName,
|
|
269
|
+
liveInput,
|
|
270
|
+
liveOutput,
|
|
271
|
+
staticInput,
|
|
272
|
+
staticOutput,
|
|
273
|
+
inputDelta,
|
|
274
|
+
outputDelta,
|
|
275
|
+
message: `${live.modelName}: LiteLLM input $${liveInput.toFixed(8)}/tok vs static $${staticInput.toFixed(8)}/tok (Δ ${(inputDelta * 100).toFixed(0)}%); output $${liveOutput.toFixed(8)} vs $${staticOutput.toFixed(8)} (Δ ${(outputDelta * 100).toFixed(0)}%).`,
|
|
276
|
+
});
|
|
277
|
+
}
|
|
278
|
+
}
|
|
279
|
+
return warnings;
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
export function resolveModelPricing(modelName, catalog = cachedPricingCatalog) {
|
|
283
|
+
const key = normalizeModelKey(modelName);
|
|
284
|
+
if (!key) return null;
|
|
285
|
+
return catalog.get(key) || null;
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
// Anthropic's two published context tiers. Treated as model facts, not tunables:
|
|
289
|
+
// 200k — baseline across Opus/Sonnet/Haiku
|
|
290
|
+
// 1M — Anthropic's beta long-context tier (anthropic-beta: context-1m-*)
|
|
291
|
+
// Resolution prefers catalog data; if observed usage exceeds catalog baseline
|
|
292
|
+
// the session must be on the next published tier, so we step up to it rather
|
|
293
|
+
// than treating the observation itself as the ceiling.
|
|
294
|
+
const ANTHROPIC_TIERS = [200_000, 1_000_000];
|
|
295
|
+
|
|
296
|
+
// Resolves the active context window in tokens for a given model. Catalog
|
|
297
|
+
// maxInputTokens (LiteLLM live or static) is the primary source. Observation
|
|
298
|
+
// above the catalog baseline implies a higher published tier — step up to it
|
|
299
|
+
// rather than treat the observation itself as the ceiling. Final fallback is
|
|
300
|
+
// the smallest published tier (200k).
|
|
301
|
+
|
|
302
|
+
export function resolveContextWindow(modelName, observedMaxTokens = 0, catalog = cachedPricingCatalog) {
|
|
303
|
+
const observed = Number(observedMaxTokens) || 0;
|
|
304
|
+
const pricing = resolveModelPricing(modelName, catalog);
|
|
305
|
+
const catalogWindow = Number(pricing?.maxInputTokens) || 0;
|
|
306
|
+
const baseline = catalogWindow || ANTHROPIC_TIERS[0];
|
|
307
|
+
if (observed > baseline) {
|
|
308
|
+
const nextTier = ANTHROPIC_TIERS.find((tier) => tier >= observed);
|
|
309
|
+
return nextTier || observed;
|
|
310
|
+
}
|
|
311
|
+
return baseline;
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
export function estimateUsageCost(modelName, usage = {}, catalog = cachedPricingCatalog) {
|
|
315
|
+
const pricing = resolveModelPricing(modelName, catalog);
|
|
316
|
+
if (!pricing) return { costUsd: 0, costSource: "unavailable", modelName: modelName || null };
|
|
317
|
+
|
|
318
|
+
const inputTokens = Number(usage.inputTokens ?? 0);
|
|
319
|
+
const outputTokens = Number(usage.outputTokens ?? 0);
|
|
320
|
+
const reasoningTokens = Number(usage.reasoningTokens ?? 0);
|
|
321
|
+
const cacheReadInputTokens = Number(usage.cacheReadInputTokens ?? 0);
|
|
322
|
+
const cacheCreation5mInputTokens = Number(usage.cacheCreation5mInputTokens ?? 0);
|
|
323
|
+
const cacheCreation1hInputTokens = Number(usage.cacheCreation1hInputTokens ?? 0);
|
|
324
|
+
const explicitCacheCreationInputTokens = Number(usage.cacheCreationInputTokens ?? 0);
|
|
325
|
+
|
|
326
|
+
// Aggregate cache_creation minus the known split subtypes; remainder billed at 5m rate.
|
|
327
|
+
const residualCacheCreationInputTokens = Math.max(
|
|
328
|
+
0,
|
|
329
|
+
explicitCacheCreationInputTokens - cacheCreation5mInputTokens - cacheCreation1hInputTokens,
|
|
330
|
+
);
|
|
331
|
+
|
|
332
|
+
const billableOutputTokens = outputTokens + reasoningTokens;
|
|
333
|
+
const cacheReadPrice = pricing.cacheReadPrice || pricing.inputPrice * ANTHROPIC_CACHE_READ_RATIO;
|
|
334
|
+
const cacheWrite5mPrice = pricing.cacheWrite5mPrice || pricing.inputPrice * ANTHROPIC_CACHE_WRITE_5M_RATIO;
|
|
335
|
+
const cacheWrite1hPrice = pricing.cacheWrite1hPrice || pricing.inputPrice * ANTHROPIC_CACHE_WRITE_1H_RATIO;
|
|
336
|
+
|
|
337
|
+
const inputCost = inputTokens * pricing.inputPrice;
|
|
338
|
+
const cacheReadCost = cacheReadInputTokens * cacheReadPrice;
|
|
339
|
+
const cacheWrite5mCost = (cacheCreation5mInputTokens + residualCacheCreationInputTokens) * cacheWrite5mPrice;
|
|
340
|
+
const cacheWrite1hCost = cacheCreation1hInputTokens * cacheWrite1hPrice;
|
|
341
|
+
const outputCost = billableOutputTokens * pricing.outputPrice;
|
|
342
|
+
const costUsd = inputCost + cacheReadCost + cacheWrite5mCost + cacheWrite1hCost + outputCost;
|
|
343
|
+
|
|
344
|
+
return {
|
|
345
|
+
costUsd,
|
|
346
|
+
costSource: `estimated:${pricing.source}`,
|
|
347
|
+
modelName: pricing.modelName,
|
|
348
|
+
pricing,
|
|
349
|
+
breakdown: {
|
|
350
|
+
inputCostUsd: inputCost,
|
|
351
|
+
cacheReadCostUsd: cacheReadCost,
|
|
352
|
+
cacheWrite5mCostUsd: cacheWrite5mCost,
|
|
353
|
+
cacheWrite1hCostUsd: cacheWrite1hCost,
|
|
354
|
+
outputCostUsd: outputCost,
|
|
355
|
+
billableInputTokens: inputTokens,
|
|
356
|
+
billableOutputTokens,
|
|
357
|
+
cacheReadInputTokens,
|
|
358
|
+
cacheCreation5mInputTokens: cacheCreation5mInputTokens + residualCacheCreationInputTokens,
|
|
359
|
+
cacheCreation1hInputTokens,
|
|
360
|
+
},
|
|
361
|
+
};
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
function buildAuthHeader(publicKey, secretKey) {
|
|
365
|
+
return `Basic ${Buffer.from(`${publicKey}:${secretKey}`).toString("base64")}`;
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
async function fetchOpenRouterModels(fetchImpl = globalThis.fetch) {
|
|
369
|
+
try {
|
|
370
|
+
const res = await fetchImpl(OPENROUTER_MODELS_URL, {
|
|
371
|
+
headers: { "User-Agent": "construct-telemetry/1.0" },
|
|
372
|
+
signal: AbortSignal.timeout(10_000),
|
|
373
|
+
});
|
|
374
|
+
if (!res.ok) return [];
|
|
375
|
+
const { data } = await res.json();
|
|
376
|
+
if (!Array.isArray(data)) return [];
|
|
377
|
+
return data
|
|
378
|
+
.filter((m) => m?.id && m?.pricing)
|
|
379
|
+
.map((m) => {
|
|
380
|
+
const inputPrice = parseFloat(m.pricing.prompt) || 0;
|
|
381
|
+
const outputPrice = parseFloat(m.pricing.completion) || 0;
|
|
382
|
+
const cacheReadPrice = parseFloat(m.pricing.input_cache_read) || 0;
|
|
383
|
+
const cacheWritePrice = parseFloat(m.pricing.input_cache_write) || 0;
|
|
384
|
+
const escaped = m.id.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
385
|
+
return {
|
|
386
|
+
modelName: m.id,
|
|
387
|
+
matchPattern: `(?i)^(openrouter/)?${escaped}$`,
|
|
388
|
+
inputPrice,
|
|
389
|
+
outputPrice,
|
|
390
|
+
cacheReadPrice,
|
|
391
|
+
cacheWrite5mPrice: cacheWritePrice,
|
|
392
|
+
cacheWrite1hPrice: cacheWritePrice,
|
|
393
|
+
};
|
|
394
|
+
});
|
|
395
|
+
} catch {
|
|
396
|
+
return [];
|
|
397
|
+
}
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
async function listModels(baseUrl, authHeader, fetchImpl) {
|
|
401
|
+
const all = [];
|
|
402
|
+
let page = 1;
|
|
403
|
+
try {
|
|
404
|
+
while (true) {
|
|
405
|
+
const res = await fetchImpl(`${baseUrl}/api/public/models?limit=100&page=${page}`, {
|
|
406
|
+
headers: { Authorization: authHeader },
|
|
407
|
+
signal: AbortSignal.timeout(8_000),
|
|
408
|
+
});
|
|
409
|
+
if (!res.ok) break;
|
|
410
|
+
const body = await res.json();
|
|
411
|
+
const items = Array.isArray(body?.data) ? body.data : [];
|
|
412
|
+
all.push(...items);
|
|
413
|
+
if (items.length < 100) break;
|
|
414
|
+
page++;
|
|
415
|
+
}
|
|
416
|
+
} catch { /* non-critical */ }
|
|
417
|
+
return all;
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
async function deleteModel(baseUrl, authHeader, id, fetchImpl) {
|
|
421
|
+
try {
|
|
422
|
+
await fetchImpl(`${baseUrl}/api/public/models/${id}`, {
|
|
423
|
+
method: "DELETE",
|
|
424
|
+
headers: { Authorization: authHeader },
|
|
425
|
+
signal: AbortSignal.timeout(5_000),
|
|
426
|
+
});
|
|
427
|
+
} catch { /* non-critical */ }
|
|
428
|
+
}
|
|
429
|
+
|
|
430
|
+
async function upsertModel(baseUrl, authHeader, model, existing, fetchImpl) {
|
|
431
|
+
const match = existing.find((e) => e.modelName === model.modelName);
|
|
432
|
+
if (match) {
|
|
433
|
+
const priceChanged =
|
|
434
|
+
Math.abs((match.inputPrice ?? 0) - (model.inputPrice ?? 0)) > 1e-12 ||
|
|
435
|
+
Math.abs((match.outputPrice ?? 0) - (model.outputPrice ?? 0)) > 1e-12 ||
|
|
436
|
+
Math.abs((match.cacheReadPrice ?? 0) - (model.cacheReadPrice ?? 0)) > 1e-12 ||
|
|
437
|
+
Math.abs((match.cacheWrite5mPrice ?? 0) - (model.cacheWrite5mPrice ?? 0)) > 1e-12 ||
|
|
438
|
+
Math.abs((match.cacheWrite1hPrice ?? 0) - (model.cacheWrite1hPrice ?? 0)) > 1e-12;
|
|
439
|
+
const patternChanged = (match.matchPattern ?? '') !== (model.matchPattern ?? '');
|
|
440
|
+
if (!priceChanged && !patternChanged) return "unchanged";
|
|
441
|
+
await deleteModel(baseUrl, authHeader, match.id, fetchImpl);
|
|
442
|
+
}
|
|
443
|
+
const prices = {
|
|
444
|
+
input: { price: Number(model.inputPrice) || 0 },
|
|
445
|
+
output: { price: Number(model.outputPrice) || 0 },
|
|
446
|
+
};
|
|
447
|
+
if (model.cacheReadPrice) prices.input_cache_read = { price: Number(model.cacheReadPrice) };
|
|
448
|
+
if (model.cacheWrite5mPrice) prices.input_cache_write_5m = { price: Number(model.cacheWrite5mPrice) };
|
|
449
|
+
if (model.cacheWrite1hPrice) prices.input_cache_write_1h = { price: Number(model.cacheWrite1hPrice) };
|
|
450
|
+
const body = JSON.stringify({
|
|
451
|
+
modelName: model.modelName,
|
|
452
|
+
matchPattern: model.matchPattern,
|
|
453
|
+
unit: "TOKENS",
|
|
454
|
+
inputPrice: Number(model.inputPrice) || 0,
|
|
455
|
+
outputPrice: Number(model.outputPrice) || 0,
|
|
456
|
+
prices,
|
|
457
|
+
tokenizerId: model.modelName.includes("claude") ? "claude" : null,
|
|
458
|
+
});
|
|
459
|
+
try {
|
|
460
|
+
const res = await fetchImpl(`${baseUrl}/api/public/models`, {
|
|
461
|
+
method: "POST",
|
|
462
|
+
headers: { Authorization: authHeader, "Content-Type": "application/json" },
|
|
463
|
+
body,
|
|
464
|
+
signal: AbortSignal.timeout(5_000),
|
|
465
|
+
});
|
|
466
|
+
return res.ok ? "created" : "error";
|
|
467
|
+
} catch {
|
|
468
|
+
return "error";
|
|
469
|
+
}
|
|
470
|
+
}
|
|
471
|
+
|
|
472
|
+
/**
|
|
473
|
+
* Refresh the in-process pricing catalog from LiteLLM (24h disk cache). Safe to call
|
|
474
|
+
* at plugin init — returns immediately when the cache is fresh. Does not touch telemetry backend.
|
|
475
|
+
*
|
|
476
|
+
* @param {Function} [fetchImpl]
|
|
477
|
+
* @returns {Promise<void>}
|
|
478
|
+
*/
|
|
479
|
+
export async function refreshPricingCatalog(fetchImpl = globalThis.fetch, { force = false } = {}) {
|
|
480
|
+
if (force) {
|
|
481
|
+
try {
|
|
482
|
+
if (existsSync(PRICING_CACHE_PATH)) {
|
|
483
|
+
const fs = await import('node:fs');
|
|
484
|
+
fs.unlinkSync(PRICING_CACHE_PATH);
|
|
485
|
+
}
|
|
486
|
+
} catch { /* non-critical */ }
|
|
487
|
+
}
|
|
488
|
+
const litellmModels = await fetchLiteLLMPricing(fetchImpl);
|
|
489
|
+
const divergences = checkPricingDivergence(litellmModels);
|
|
490
|
+
if (litellmModels.length > 0) {
|
|
491
|
+
cachedPricingCatalog = buildPricingCatalog([], litellmModels);
|
|
492
|
+
}
|
|
493
|
+
return { fetched: litellmModels.length, divergences };
|
|
494
|
+
}
|
|
495
|
+
|
|
496
|
+
/**
|
|
497
|
+
* Syncs model pricing into the telemetry backend. Safe to call multiple times — only runs once per process.
|
|
498
|
+
*
|
|
499
|
+
* @param {object} opts
|
|
500
|
+
* @param {string} opts.baseUrl — Telemetry base URL
|
|
501
|
+
* @param {string} opts.publicKey
|
|
502
|
+
* @param {string} opts.secretKey
|
|
503
|
+
* @param {Function} [opts.fetchImpl] — injectable fetch for tests
|
|
504
|
+
* @param {boolean} [opts.force] — bypass the once-per-process guard
|
|
505
|
+
* @returns {Promise<{synced: number, unchanged: number, errors: number}>}
|
|
506
|
+
*/
|
|
507
|
+
export async function syncModelPricing({
|
|
508
|
+
baseUrl = (process.env.CONSTRUCT_TELEMETRY_BASEURL ?? "").replace(/\/$/, ""),
|
|
509
|
+
publicKey = process.env.CONSTRUCT_TELEMETRY_PUBLIC_KEY,
|
|
510
|
+
secretKey = process.env.CONSTRUCT_TELEMETRY_SECRET_KEY,
|
|
511
|
+
fetchImpl = globalThis.fetch,
|
|
512
|
+
force = false,
|
|
513
|
+
} = {}) {
|
|
514
|
+
if (!force && syncDone) return { synced: 0, unchanged: 0, errors: 0 };
|
|
515
|
+
if (!publicKey || !secretKey || !fetchImpl) return { synced: 0, unchanged: 0, errors: 0 };
|
|
516
|
+
|
|
517
|
+
syncDone = true;
|
|
518
|
+
|
|
519
|
+
const authHeader = buildAuthHeader(publicKey, secretKey);
|
|
520
|
+
|
|
521
|
+
const [openRouterModels, litellmModels, existing] = await Promise.all([
|
|
522
|
+
fetchOpenRouterModels(fetchImpl),
|
|
523
|
+
fetchLiteLLMPricing(fetchImpl),
|
|
524
|
+
listModels(baseUrl, authHeader, fetchImpl),
|
|
525
|
+
]);
|
|
526
|
+
|
|
527
|
+
cachedPricingCatalog = buildPricingCatalog(openRouterModels, litellmModels);
|
|
528
|
+
|
|
529
|
+
const byKey = new Map();
|
|
530
|
+
for (const model of openRouterModels) byKey.set(normalizeModelKey(model.modelName), model);
|
|
531
|
+
for (const model of litellmModels) byKey.set(normalizeModelKey(model.modelName), model);
|
|
532
|
+
for (const model of [...ANTHROPIC_MODELS, ...GITHUB_COPILOT_MODELS]) {
|
|
533
|
+
byKey.set(normalizeModelKey(model.modelName), model);
|
|
534
|
+
}
|
|
535
|
+
const allModels = [...byKey.values()];
|
|
536
|
+
|
|
537
|
+
let synced = 0;
|
|
538
|
+
let unchanged = 0;
|
|
539
|
+
let errors = 0;
|
|
540
|
+
|
|
541
|
+
for (const model of allModels) {
|
|
542
|
+
const result = await upsertModel(baseUrl, authHeader, model, existing, fetchImpl);
|
|
543
|
+
if (result === "created") synced++;
|
|
544
|
+
else if (result === "unchanged") unchanged++;
|
|
545
|
+
else errors++;
|
|
546
|
+
}
|
|
547
|
+
|
|
548
|
+
return { synced, unchanged, errors };
|
|
549
|
+
}
|
|
550
|
+
|
|
551
|
+
export function resetSyncGuard() {
|
|
552
|
+
syncDone = false;
|
|
553
|
+
}
|
|
554
|
+
|
|
555
|
+
export function resetPricingCatalog() {
|
|
556
|
+
cachedPricingCatalog = buildPricingCatalog();
|
|
557
|
+
}
|