@geraldmaron/construct 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (352) hide show
  1. package/.env.example +69 -0
  2. package/LICENSE +21 -0
  3. package/README.md +344 -0
  4. package/agents/prompts/cx-accessibility.md +29 -0
  5. package/agents/prompts/cx-ai-engineer.md +31 -0
  6. package/agents/prompts/cx-architect.md +40 -0
  7. package/agents/prompts/cx-business-strategist.md +25 -0
  8. package/agents/prompts/cx-data-analyst.md +30 -0
  9. package/agents/prompts/cx-data-engineer.md +32 -0
  10. package/agents/prompts/cx-debugger.md +27 -0
  11. package/agents/prompts/cx-designer.md +33 -0
  12. package/agents/prompts/cx-devil-advocate.md +36 -0
  13. package/agents/prompts/cx-docs-keeper.md +50 -0
  14. package/agents/prompts/cx-engineer.md +36 -0
  15. package/agents/prompts/cx-evaluator.md +26 -0
  16. package/agents/prompts/cx-explorer.md +38 -0
  17. package/agents/prompts/cx-legal-compliance.md +25 -0
  18. package/agents/prompts/cx-operations.md +28 -0
  19. package/agents/prompts/cx-orchestrator.md +30 -0
  20. package/agents/prompts/cx-platform-engineer.md +25 -0
  21. package/agents/prompts/cx-product-manager.md +30 -0
  22. package/agents/prompts/cx-qa.md +42 -0
  23. package/agents/prompts/cx-rd-lead.md +25 -0
  24. package/agents/prompts/cx-release-manager.md +31 -0
  25. package/agents/prompts/cx-researcher.md +31 -0
  26. package/agents/prompts/cx-reviewer.md +36 -0
  27. package/agents/prompts/cx-security.md +37 -0
  28. package/agents/prompts/cx-sre.md +26 -0
  29. package/agents/prompts/cx-test-automation.md +35 -0
  30. package/agents/prompts/cx-trace-reviewer.md +76 -0
  31. package/agents/prompts/cx-ux-researcher.md +30 -0
  32. package/agents/registry.json +879 -0
  33. package/agents/teams.json +94 -0
  34. package/bin/construct +613 -0
  35. package/commands/build/feature.md +21 -0
  36. package/commands/build/fix.md +20 -0
  37. package/commands/design/access.md +19 -0
  38. package/commands/design/flow.md +16 -0
  39. package/commands/design/ui.md +18 -0
  40. package/commands/measure/experiment.md +18 -0
  41. package/commands/measure/metrics.md +18 -0
  42. package/commands/measure/results.md +18 -0
  43. package/commands/plan/api.md +19 -0
  44. package/commands/plan/challenge.md +18 -0
  45. package/commands/plan/decide.md +20 -0
  46. package/commands/plan/feature.md +49 -0
  47. package/commands/plan/requirements.md +24 -0
  48. package/commands/remember/context.md +26 -0
  49. package/commands/remember/handoff.md +19 -0
  50. package/commands/remember/runbook.md +20 -0
  51. package/commands/review/code.md +21 -0
  52. package/commands/review/quality.md +19 -0
  53. package/commands/review/security.md +16 -0
  54. package/commands/ship/ready.md +19 -0
  55. package/commands/ship/release.md +19 -0
  56. package/commands/ship/status.md +18 -0
  57. package/commands/understand/docs.md +22 -0
  58. package/commands/understand/research.md +22 -0
  59. package/commands/understand/this.md +23 -0
  60. package/commands/understand/why.md +18 -0
  61. package/commands/work/clean.md +32 -0
  62. package/commands/work/drive.md +69 -0
  63. package/commands/work/optimize-prompts.md +46 -0
  64. package/commands/work/parallel-review.md +40 -0
  65. package/db/migrations/001_init.sql +38 -0
  66. package/langfuse/docker-compose.yml +82 -0
  67. package/lib/audit-skills.mjs +118 -0
  68. package/lib/auto-docs.mjs +293 -0
  69. package/lib/cli-commands.mjs +409 -0
  70. package/lib/codex-config.mjs +114 -0
  71. package/lib/comment-lint.mjs +191 -0
  72. package/lib/completions.mjs +170 -0
  73. package/lib/context-state.mjs +99 -0
  74. package/lib/cost.mjs +213 -0
  75. package/lib/distill.mjs +403 -0
  76. package/lib/efficiency.mjs +139 -0
  77. package/lib/env-config.mjs +53 -0
  78. package/lib/eval-harness.mjs +59 -0
  79. package/lib/features.mjs +209 -0
  80. package/lib/headhunt.mjs +597 -0
  81. package/lib/hooks/adaptive-lint.mjs +112 -0
  82. package/lib/hooks/agent-tracker.mjs +50 -0
  83. package/lib/hooks/bootstrap-guard.mjs +90 -0
  84. package/lib/hooks/comment-lint.mjs +26 -0
  85. package/lib/hooks/config-protection.mjs +52 -0
  86. package/lib/hooks/console-warn.mjs +43 -0
  87. package/lib/hooks/context-window-recovery.mjs +90 -0
  88. package/lib/hooks/continuation-enforcer.mjs +72 -0
  89. package/lib/hooks/dep-audit.mjs +155 -0
  90. package/lib/hooks/drive-guard.mjs +89 -0
  91. package/lib/hooks/edit-accumulator.mjs +36 -0
  92. package/lib/hooks/edit-error-recovery.mjs +46 -0
  93. package/lib/hooks/edit-guard.mjs +109 -0
  94. package/lib/hooks/env-check.mjs +80 -0
  95. package/lib/hooks/guard-bash.mjs +83 -0
  96. package/lib/hooks/mcp-audit.mjs +57 -0
  97. package/lib/hooks/mcp-health-check.mjs +51 -0
  98. package/lib/hooks/mcp-task-scope.mjs +47 -0
  99. package/lib/hooks/model-fallback.mjs +105 -0
  100. package/lib/hooks/pre-compact.mjs +212 -0
  101. package/lib/hooks/pre-push-gate.mjs +129 -0
  102. package/lib/hooks/read-tracker.mjs +129 -0
  103. package/lib/hooks/registry-sync.mjs +23 -0
  104. package/lib/hooks/scan-secrets.mjs +76 -0
  105. package/lib/hooks/session-start.mjs +95 -0
  106. package/lib/hooks/stop-notify.mjs +191 -0
  107. package/lib/hooks/stop-typecheck.mjs +83 -0
  108. package/lib/hooks/task-completed-guard.mjs +43 -0
  109. package/lib/hooks/teammate-idle-guard.mjs +54 -0
  110. package/lib/hooks/workflow-guard.mjs +62 -0
  111. package/lib/host-capabilities.mjs +123 -0
  112. package/lib/init-docs.mjs +382 -0
  113. package/lib/mcp/server.mjs +1123 -0
  114. package/lib/mcp-catalog.json +243 -0
  115. package/lib/mcp-manager.mjs +433 -0
  116. package/lib/mcp-platform-config.mjs +104 -0
  117. package/lib/model-router.mjs +810 -0
  118. package/lib/opencode-config.mjs +44 -0
  119. package/lib/opencode-runtime-plugin.mjs +909 -0
  120. package/lib/opencode-telemetry.mjs +433 -0
  121. package/lib/orchestration-policy.mjs +258 -0
  122. package/lib/prompt-composer.mjs +196 -0
  123. package/lib/prompt-metadata.mjs +68 -0
  124. package/lib/review.mjs +429 -0
  125. package/lib/role-preload.mjs +52 -0
  126. package/lib/schemas/decision.json +50 -0
  127. package/lib/schemas/implementation.json +51 -0
  128. package/lib/schemas/review-report.json +32 -0
  129. package/lib/schemas/test-report.json +43 -0
  130. package/lib/server/index.mjs +334 -0
  131. package/lib/server/static/app.js +841 -0
  132. package/lib/server/static/index.html +820 -0
  133. package/lib/service-manager.mjs +225 -0
  134. package/lib/setup.mjs +319 -0
  135. package/lib/status.mjs +707 -0
  136. package/lib/storage/backend.mjs +44 -0
  137. package/lib/storage/embeddings.mjs +70 -0
  138. package/lib/storage/hybrid-query.mjs +184 -0
  139. package/lib/storage/sql-store.mjs +55 -0
  140. package/lib/storage/state-source.mjs +101 -0
  141. package/lib/storage/sync.mjs +164 -0
  142. package/lib/storage/vector-store.mjs +59 -0
  143. package/lib/telemetry/backends/langfuse.mjs +58 -0
  144. package/lib/telemetry/backends/noop.mjs +16 -0
  145. package/lib/telemetry/langfuse-ingest.mjs +108 -0
  146. package/lib/telemetry/langfuse-model-sync.mjs +270 -0
  147. package/lib/telemetry/team-rollup.mjs +143 -0
  148. package/lib/toolkit-env.mjs +30 -0
  149. package/lib/validator.mjs +181 -0
  150. package/lib/workflow-state.mjs +794 -0
  151. package/package.json +53 -0
  152. package/personas/construct.md +94 -0
  153. package/platforms/claude/CLAUDE.md +30 -0
  154. package/platforms/claude/settings.template.json +472 -0
  155. package/platforms/opencode/config.template.json +65 -0
  156. package/platforms/opencode/plugins/construct-fallback.js +5 -0
  157. package/platforms/opencode/sync-config.mjs +69 -0
  158. package/rules/common/agents.md +55 -0
  159. package/rules/common/code-review.md +129 -0
  160. package/rules/common/coding-style.md +95 -0
  161. package/rules/common/comments.md +139 -0
  162. package/rules/common/cx-agent-routing.md +61 -0
  163. package/rules/common/cx-skill-routing.md +11 -0
  164. package/rules/common/development-workflow.md +49 -0
  165. package/rules/common/git-workflow.md +29 -0
  166. package/rules/common/hooks.md +35 -0
  167. package/rules/common/patterns.md +36 -0
  168. package/rules/common/performance.md +71 -0
  169. package/rules/common/security.md +34 -0
  170. package/rules/common/testing.md +62 -0
  171. package/rules/golang/coding-style.md +37 -0
  172. package/rules/golang/hooks.md +22 -0
  173. package/rules/golang/patterns.md +50 -0
  174. package/rules/golang/security.md +39 -0
  175. package/rules/golang/testing.md +36 -0
  176. package/rules/python/coding-style.md +47 -0
  177. package/rules/python/hooks.md +24 -0
  178. package/rules/python/patterns.md +44 -0
  179. package/rules/python/security.md +35 -0
  180. package/rules/python/testing.md +43 -0
  181. package/rules/swift/coding-style.md +52 -0
  182. package/rules/swift/hooks.md +25 -0
  183. package/rules/swift/patterns.md +71 -0
  184. package/rules/swift/security.md +38 -0
  185. package/rules/swift/testing.md +50 -0
  186. package/rules/typescript/coding-style.md +204 -0
  187. package/rules/typescript/hooks.md +27 -0
  188. package/rules/typescript/patterns.md +57 -0
  189. package/rules/typescript/security.md +33 -0
  190. package/rules/typescript/testing.md +23 -0
  191. package/rules/web/coding-style.md +101 -0
  192. package/rules/web/design-quality.md +68 -0
  193. package/rules/web/hooks.md +125 -0
  194. package/rules/web/patterns.md +84 -0
  195. package/rules/web/performance.md +69 -0
  196. package/rules/web/security.md +62 -0
  197. package/rules/web/testing.md +60 -0
  198. package/rules/zh/README.md +113 -0
  199. package/rules/zh/agents.md +55 -0
  200. package/rules/zh/code-review.md +129 -0
  201. package/rules/zh/coding-style.md +53 -0
  202. package/rules/zh/development-workflow.md +49 -0
  203. package/rules/zh/git-workflow.md +29 -0
  204. package/rules/zh/hooks.md +35 -0
  205. package/rules/zh/patterns.md +36 -0
  206. package/rules/zh/performance.md +60 -0
  207. package/rules/zh/security.md +34 -0
  208. package/rules/zh/testing.md +34 -0
  209. package/skills/ai/agent-dev.md +102 -0
  210. package/skills/ai/llm-security.md +105 -0
  211. package/skills/ai/ml-ops.md +169 -0
  212. package/skills/ai/orchestration-workflow.md +87 -0
  213. package/skills/ai/prompt-and-eval.md +114 -0
  214. package/skills/ai/prompt-optimizer.md +114 -0
  215. package/skills/ai/rag-system.md +104 -0
  216. package/skills/architecture/api-design.md +100 -0
  217. package/skills/architecture/caching.md +101 -0
  218. package/skills/architecture/cloud-native.md +97 -0
  219. package/skills/architecture/message-queue.md +100 -0
  220. package/skills/architecture/security-arch.md +105 -0
  221. package/skills/development/cpp.md +127 -0
  222. package/skills/development/go.md +129 -0
  223. package/skills/development/java.md +134 -0
  224. package/skills/development/kotlin.md +140 -0
  225. package/skills/development/mobile-crossplatform.md +144 -0
  226. package/skills/development/python.md +109 -0
  227. package/skills/development/rust.md +127 -0
  228. package/skills/development/shell.md +127 -0
  229. package/skills/development/swift.md +129 -0
  230. package/skills/development/typescript.md +129 -0
  231. package/skills/devops/ci-cd.md +108 -0
  232. package/skills/devops/containerization.md +106 -0
  233. package/skills/devops/cost-optimization.md +105 -0
  234. package/skills/devops/data-engineering.md +181 -0
  235. package/skills/devops/database.md +95 -0
  236. package/skills/devops/dependency-management.md +91 -0
  237. package/skills/devops/devsecops.md +96 -0
  238. package/skills/devops/git-workflow.md +100 -0
  239. package/skills/devops/incident-response.md +167 -0
  240. package/skills/devops/monorepo.md +87 -0
  241. package/skills/devops/observability.md +103 -0
  242. package/skills/devops/performance.md +104 -0
  243. package/skills/devops/testing.md +104 -0
  244. package/skills/docs/adr-workflow.md +32 -0
  245. package/skills/docs/backlog-proposal-workflow.md +19 -0
  246. package/skills/docs/customer-profile-workflow.md +22 -0
  247. package/skills/docs/evidence-ingest-workflow.md +23 -0
  248. package/skills/docs/init-docs.md +160 -0
  249. package/skills/docs/init-project.md +50 -0
  250. package/skills/docs/prd-workflow.md +57 -0
  251. package/skills/docs/prfaq-workflow.md +25 -0
  252. package/skills/docs/product-intelligence-review.md +22 -0
  253. package/skills/docs/product-intelligence-workflow.md +62 -0
  254. package/skills/docs/product-signal-workflow.md +27 -0
  255. package/skills/docs/research-workflow.md +28 -0
  256. package/skills/docs/runbook-workflow.md +24 -0
  257. package/skills/exploration/repo-map.md +297 -0
  258. package/skills/frameworks/django.md +159 -0
  259. package/skills/frameworks/nextjs.md +148 -0
  260. package/skills/frameworks/react.md +132 -0
  261. package/skills/frameworks/spring-boot.md +165 -0
  262. package/skills/frontend-design/accessibility.md +153 -0
  263. package/skills/frontend-design/component-patterns.md +111 -0
  264. package/skills/frontend-design/engineering.md +122 -0
  265. package/skills/frontend-design/state-management.md +118 -0
  266. package/skills/frontend-design/ui-aesthetics.md +102 -0
  267. package/skills/frontend-design/ux-principles.md +126 -0
  268. package/skills/quality-gates/review-work.md +90 -0
  269. package/skills/quality-gates/verify-change.md +94 -0
  270. package/skills/quality-gates/verify-module.md +96 -0
  271. package/skills/quality-gates/verify-quality.md +93 -0
  272. package/skills/quality-gates/verify-security.md +95 -0
  273. package/skills/roles/architect.ai-systems.md +37 -0
  274. package/skills/roles/architect.data.md +37 -0
  275. package/skills/roles/architect.enterprise.md +37 -0
  276. package/skills/roles/architect.integration.md +37 -0
  277. package/skills/roles/architect.md +68 -0
  278. package/skills/roles/architect.platform.md +37 -0
  279. package/skills/roles/data-analyst.experiment.md +37 -0
  280. package/skills/roles/data-analyst.md +68 -0
  281. package/skills/roles/data-analyst.product-intelligence.md +37 -0
  282. package/skills/roles/data-analyst.product.md +37 -0
  283. package/skills/roles/data-analyst.telemetry.md +37 -0
  284. package/skills/roles/data-engineer.pipeline.md +37 -0
  285. package/skills/roles/data-engineer.vector-retrieval.md +37 -0
  286. package/skills/roles/data-engineer.warehouse.md +37 -0
  287. package/skills/roles/debugger.md +68 -0
  288. package/skills/roles/designer.accessibility.md +43 -0
  289. package/skills/roles/designer.md +68 -0
  290. package/skills/roles/engineer.ai.md +49 -0
  291. package/skills/roles/engineer.data.md +49 -0
  292. package/skills/roles/engineer.md +85 -0
  293. package/skills/roles/engineer.platform.md +49 -0
  294. package/skills/roles/operator.docs.md +43 -0
  295. package/skills/roles/operator.md +68 -0
  296. package/skills/roles/operator.release.md +43 -0
  297. package/skills/roles/operator.sre.md +43 -0
  298. package/skills/roles/orchestrator.md +66 -0
  299. package/skills/roles/product-manager.ai-product.md +37 -0
  300. package/skills/roles/product-manager.business-strategy.md +43 -0
  301. package/skills/roles/product-manager.enterprise.md +37 -0
  302. package/skills/roles/product-manager.growth.md +37 -0
  303. package/skills/roles/product-manager.md +67 -0
  304. package/skills/roles/product-manager.platform.md +37 -0
  305. package/skills/roles/product-manager.product.md +37 -0
  306. package/skills/roles/qa.ai-eval.md +37 -0
  307. package/skills/roles/qa.api-contract.md +37 -0
  308. package/skills/roles/qa.data-pipeline.md +37 -0
  309. package/skills/roles/qa.md +68 -0
  310. package/skills/roles/qa.test-automation.md +49 -0
  311. package/skills/roles/qa.web-ui.md +37 -0
  312. package/skills/roles/researcher.explorer.md +43 -0
  313. package/skills/roles/researcher.md +68 -0
  314. package/skills/roles/researcher.ux.md +43 -0
  315. package/skills/roles/reviewer.devil-advocate.md +43 -0
  316. package/skills/roles/reviewer.evaluator.md +43 -0
  317. package/skills/roles/reviewer.md +68 -0
  318. package/skills/roles/reviewer.trace.md +43 -0
  319. package/skills/roles/security.ai.md +37 -0
  320. package/skills/roles/security.appsec.md +37 -0
  321. package/skills/roles/security.cloud.md +37 -0
  322. package/skills/roles/security.legal-compliance.md +49 -0
  323. package/skills/roles/security.md +68 -0
  324. package/skills/roles/security.privacy.md +37 -0
  325. package/skills/roles/security.supply-chain.md +37 -0
  326. package/skills/routing.md +139 -0
  327. package/skills/security/blue-team.md +83 -0
  328. package/skills/security/code-audit.md +94 -0
  329. package/skills/security/pentest.md +84 -0
  330. package/skills/security/red-team.md +81 -0
  331. package/skills/security/threat-intel.md +89 -0
  332. package/skills/security/vuln-research.md +87 -0
  333. package/skills/utility/clean-code.md +165 -0
  334. package/sync-agents.mjs +899 -0
  335. package/templates/docs/adr.md +27 -0
  336. package/templates/docs/backlog-proposal.md +26 -0
  337. package/templates/docs/customer-profile.md +34 -0
  338. package/templates/docs/evidence-brief.md +37 -0
  339. package/templates/docs/incident-report.md +46 -0
  340. package/templates/docs/memo.md +27 -0
  341. package/templates/docs/meta-prd.md +56 -0
  342. package/templates/docs/one-pager.md +24 -0
  343. package/templates/docs/prd-business.md +61 -0
  344. package/templates/docs/prd-platform.md +62 -0
  345. package/templates/docs/prd.md +47 -0
  346. package/templates/docs/prfaq.md +31 -0
  347. package/templates/docs/product-intelligence-report.md +31 -0
  348. package/templates/docs/research-brief.md +29 -0
  349. package/templates/docs/rfc-platform.md +60 -0
  350. package/templates/docs/rfc.md +46 -0
  351. package/templates/docs/runbook.md +33 -0
  352. package/templates/docs/signal-brief.md +25 -0
@@ -0,0 +1,810 @@
1
+ /**
2
+ * lib/model-router.mjs — Central model routing and selection logic.
3
+ *
4
+ * What it does:
5
+ * • Defines provider-family tier mappings and resolves model IDs.
6
+ * • Polls OpenRouter for free models, scores them, and selects appropriate
7
+ * alternatives for each tier (reasoning, standard, fast).
8
+ * • Provides helpers to apply free-preference modes (global, same‑family,
9
+ * explicit selection) and to persist selections to .env.
10
+ *
11
+ * Consumed by:
12
+ * • lib/cli-commands.mjs – model configuration commands.
13
+ * • lib/model-router.mjs – internal model inference utilities.
14
+ * • lib/setup.mjs – environment variable generation.
15
+ *
16
+ * Maintenance:
17
+ * • Adding new provider families → extend `PROVIDER_FAMILY_TIERS` with a
18
+ * `test`/`resolve` entry.
19
+ * • Changing scoring logic → modify `_score()` function.
20
+ * • Adjusting token‑budget behavior → edit `applyFreeSameFamilyPreferenceToTierSet()`.
21
+ * • Persisting selections → update `applyToEnv()` comment block.
22
+ */
23
+ import fs from "node:fs";
24
+ import path from "node:path";
25
+ import { findOpenCodeConfigPath } from "./opencode-config.mjs";
26
+
27
+ const OPENROUTER_MODELS_URL = "https://openrouter.ai/api/v1/models";
28
+ const FETCH_TIMEOUT_MS = 10_000;
29
+
30
+ /**
31
+ * Built‑in default model IDs for each tier when no overrides are present.
32
+ */
33
+ const BUILTIN_DEFAULTS = {
34
+ reasoning: "openrouter/deepseek/deepseek-r1",
35
+ standard: "openrouter/qwen/qwen3-coder:free",
36
+ fast: "openrouter/meta-llama/llama-3.3-70b-instruct:free",
37
+ };
38
+
39
+ export const MODEL_TIER_BY_WORK_CATEGORY = {
40
+ visual: "standard",
41
+ deep: "reasoning",
42
+ quick: "fast",
43
+ writing: "fast",
44
+ analysis: "standard",
45
+ };
46
+
47
+ /**
48
+ * Provider‑family definitions. Each entry contains:
49
+ * - `test`: RegExp that matches provider URLs.
50
+ * - `resolve`: Function that maps selected tier values to concrete model IDs.
51
+ *
52
+ * Families are consulted in order; the first match wins.
53
+ */
54
+ const PROVIDER_FAMILY_TIERS = [
55
+ {
56
+ test: (modelId) => /^anthropic\//.test(modelId),
57
+ resolve: ({ reasoning, standard, fast }) => ({
58
+ reasoning: reasoning ?? "anthropic/claude-opus-4-6",
59
+ standard: standard ?? "anthropic/claude-sonnet-4-6",
60
+ fast: fast ?? "anthropic/claude-haiku-4-5-20251001",
61
+ }),
62
+ },
63
+ {
64
+ test: (modelId) => /^openrouter\/anthropic\//.test(modelId),
65
+ resolve: ({ reasoning, standard, fast }) => ({
66
+ reasoning: reasoning ?? "openrouter/anthropic/claude-opus-4-6",
67
+ standard: standard ?? "openrouter/anthropic/claude-sonnet-4-6",
68
+ fast: fast ?? "openrouter/anthropic/claude-haiku-4-5-20251001",
69
+ }),
70
+ },
71
+ {
72
+ test: (modelId) => /^openrouter\/google\//.test(modelId),
73
+ resolve: ({ reasoning, standard, fast }) => ({
74
+ reasoning: reasoning ?? "openrouter/google/gemini-2.5-pro",
75
+ standard: standard ?? "openrouter/google/gemini-2.0-flash-001",
76
+ fast: fast ?? "openrouter/google/gemma-3-27b-it:free",
77
+ }),
78
+ },
79
+ {
80
+ test: (modelId) => /^openrouter\/deepseek\//.test(modelId),
81
+ resolve: ({ reasoning, standard, fast }) => ({
82
+ reasoning: reasoning ?? "openrouter/deepseek/deepseek-r1",
83
+ standard: standard ?? "openrouter/deepseek/deepseek-v3",
84
+ fast: fast ?? standard ?? "openrouter/qwen/qwen3-coder:free",
85
+ }),
86
+ },
87
+ {
88
+ test: (modelId) => /^openrouter\/qwen\//.test(modelId),
89
+ resolve: ({ reasoning, standard, fast }) => ({
90
+ reasoning: reasoning ?? "openrouter/qwen/qwen3-coder",
91
+ standard: standard ?? "openrouter/qwen/qwen3-coder:free",
92
+ fast: fast ?? "openrouter/qwen/qwen2.5-coder-32b-instruct",
93
+ }),
94
+ },
95
+ {
96
+ test: (modelId) => /^github-copilot\//.test(modelId),
97
+ resolve: ({ reasoning, standard, fast }) => ({
98
+ reasoning: reasoning ?? "github-copilot/gpt-5.4",
99
+ standard: standard ?? "github-copilot/gpt-5.1",
100
+ fast: fast ?? "github-copilot/gpt-5.1-mini",
101
+ }),
102
+ },
103
+ {
104
+ test: (modelId) => /^openai\//.test(modelId) || /^openrouter\/openai\//.test(modelId),
105
+ resolve: ({ reasoning, standard, fast }) => {
106
+ const prefix = /^openrouter\//.test(reasoning || standard || fast || "") ? "openrouter/openai" : "openai";
107
+ return {
108
+ reasoning: reasoning ?? `${prefix}/gpt-5.4`,
109
+ standard: standard ?? `${prefix}/gpt-5.1`,
110
+ fast: fast ?? `${prefix}/gpt-5.1-mini`,
111
+ };
112
+ },
113
+ },
114
+ {
115
+ test: (modelId) => /^openrouter\/meta-llama\//.test(modelId),
116
+ resolve: ({ reasoning, standard, fast }) => ({
117
+ reasoning: reasoning ?? "openrouter/meta-llama/llama-3.1-405b-instruct",
118
+ standard: standard ?? "openrouter/meta-llama/llama-3.3-70b-instruct",
119
+ fast: fast ?? "openrouter/meta-llama/llama-3.3-70b-instruct:free",
120
+ }),
121
+ },
122
+ ];
123
+
124
+ /**
125
+ * Find the provider‑family entry that matches a given model identifier.
126
+ * @param {string} modelId – The model ID string.
127
+ * @returns {object|null} – The matching tier‑resolution object, or `null`.
128
+ */
129
+ function matchProviderFamily(modelId) {
130
+ return PROVIDER_FAMILY_TIERS.find((entry) => entry.test(modelId));
131
+ }
132
+
133
+ /**
134
+ * Given a primary model the user selected, return tier → modelId mapping in
135
+ * the same provider family. Returns null if no family matches (caller should
136
+ * fall back to configured CX_MODEL_* values).
137
+ */
138
+ export function resolveTiersForPrimary(primaryModelId) {
139
+ if (!primaryModelId || typeof primaryModelId !== "string") return null;
140
+ const family = matchProviderFamily(primaryModelId);
141
+ if (!family) return null;
142
+ return family.resolve({ reasoning: undefined, standard: undefined, fast: undefined });
143
+ }
144
+
145
+ /**
146
+ * Read the OpenRouter API key from the OpenCode config file instead of
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.
151
+ */
152
+ export function readOpenRouterApiKeyFromOpenCodeConfig(
153
+ configPath = findOpenCodeConfigPath(),
154
+ ) {
155
+ try {
156
+ if (!fs.existsSync(configPath)) return "";
157
+ const config = JSON.parse(fs.readFileSync(configPath, "utf8"));
158
+ const auth = config?.provider?.openrouter?.options?.headers?.Authorization;
159
+ if (typeof auth !== "string") return "";
160
+ const value = auth.replace(/^Bearer\s+/i, "").trim();
161
+ if (!value || value.includes("__OPENROUTER_API_KEY__")) return "";
162
+ return value;
163
+ } catch {
164
+ return "";
165
+ }
166
+ }
167
+
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
+ }
261
+
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
+ function readEnvAssignments(envPath) {
271
+ const tierKeys = {
272
+ reasoning: "CX_MODEL_REASONING",
273
+ standard: "CX_MODEL_STANDARD",
274
+ fast: "CX_MODEL_FAST",
275
+ };
276
+
277
+ const envValues = {};
278
+ if (fs.existsSync(envPath)) {
279
+ for (const line of fs.readFileSync(envPath, "utf8").split("\n")) {
280
+ const trimmed = line.trim();
281
+ if (trimmed.startsWith("#") || !trimmed.includes("=")) continue;
282
+ const eqIdx = trimmed.indexOf("=");
283
+ const k = trimmed.slice(0, eqIdx).trim();
284
+ const v = trimmed.slice(eqIdx + 1).trim();
285
+ envValues[k] = v;
286
+ }
287
+ }
288
+
289
+ return Object.fromEntries(
290
+ Object.entries(tierKeys).map(([tier, key]) => [tier, envValues[key] || null])
291
+ );
292
+ }
293
+
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
+ function extractPrimary(def) {
300
+ if (typeof def === "string") return def;
301
+ if (def && typeof def === "object")
302
+ return def.primary ?? def.fallback?.[0] ?? null;
303
+ return null;
304
+ }
305
+
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
+ function getRegistryDefaults(registryModels = {}) {
313
+ return {
314
+ reasoning:
315
+ extractPrimary(registryModels.reasoning) ??
316
+ BUILTIN_DEFAULTS.reasoning,
317
+ standard:
318
+ extractPrimary(registryModels.standard) ??
319
+ BUILTIN_DEFAULTS.standard,
320
+ fast: extractPrimary(registryModels.fast) ?? BUILTIN_DEFAULTS.fast,
321
+ };
322
+ }
323
+
324
+ function normalizeEnvAssignments(envValues = {}) {
325
+ return {
326
+ reasoning: envValues.reasoning ?? envValues.CX_MODEL_REASONING ?? null,
327
+ standard: envValues.standard ?? envValues.CX_MODEL_STANDARD ?? null,
328
+ fast: envValues.fast ?? envValues.CX_MODEL_FAST ?? null,
329
+ };
330
+ }
331
+
332
+ function flattenText(value) {
333
+ if (value === null || value === undefined) return "";
334
+ if (typeof value === "string") return value;
335
+ if (typeof value === "number" || typeof value === "boolean") return String(value);
336
+ if (Array.isArray(value)) return value.map(flattenText).join("\n");
337
+ if (typeof value === "object") return Object.values(value).map(flattenText).join("\n");
338
+ return "";
339
+ }
340
+
341
+ function providerKey(modelId = "") {
342
+ if (typeof modelId !== "string" || !modelId) return "";
343
+ return modelId.replace(/^openrouter\//, "").split("/")[0] || "";
344
+ }
345
+
346
+ function resolveTierDefinition(definition) {
347
+ if (!definition || typeof definition !== "object") return { primary: null, fallback: [] };
348
+ return {
349
+ primary: extractPrimary(definition),
350
+ fallback: Array.isArray(definition.fallback) ? definition.fallback.filter((entry) => typeof entry === "string" && entry) : [],
351
+ };
352
+ }
353
+
354
+ export function classifyProviderFailure(input) {
355
+ const error = input?.error && typeof input.error === "object" ? input.error : input;
356
+ const text = flattenText([
357
+ error?.message,
358
+ error?.name,
359
+ error?.code,
360
+ error?.status,
361
+ error?.statusCode,
362
+ input?.message,
363
+ input?.error,
364
+ ]);
365
+ if (!text) return null;
366
+
367
+ const provider = [
368
+ error?.provider,
369
+ input?.provider,
370
+ input?.model?.provider,
371
+ input?.session?.provider,
372
+ ].find((value) => typeof value === "string" && value) || null;
373
+
374
+ const patterns = [
375
+ { kind: "rate_limit", retryable: true, test: /\b429\b|rate limit|usage limits?|too many requests|quota exceeded|weekly limit|monthly limit|daily limit/i },
376
+ { kind: "provider_unavailable", retryable: true, test: /model unavailable|model.*overloaded|ProviderModelNotFoundError|model.*not found|no such model/i },
377
+ { kind: "provider_unavailable", retryable: true, test: /service unavailable|temporarily unavailable|upstream error|server error|\b5\d\d\b/i },
378
+ { kind: "transient_network", retryable: true, test: /timeout|timed out|ETIMEDOUT|ECONNRESET|network error|fetch failed/i },
379
+ { kind: "auth_error", retryable: false, test: /unauthorized|forbidden|invalid api key|authentication failed/i },
380
+ ];
381
+
382
+ for (const pattern of patterns) {
383
+ if (pattern.test.test(text)) {
384
+ return {
385
+ kind: pattern.kind,
386
+ provider,
387
+ retryable: pattern.retryable,
388
+ };
389
+ }
390
+ }
391
+
392
+ return null;
393
+ }
394
+
395
+ export function resolveFallbackAction({
396
+ failure,
397
+ requestedTier = null,
398
+ workCategory = null,
399
+ currentModels = null,
400
+ registryModels = {},
401
+ } = {}) {
402
+ const classified = failure && typeof failure === "object" ? failure : classifyProviderFailure(failure);
403
+ if (!classified || !classified.retryable) return null;
404
+
405
+ const tier = requestedTier ?? selectModelTierForWorkCategory(workCategory) ?? "standard";
406
+ const tierDef = resolveTierDefinition(registryModels[tier]);
407
+ const currentModel = currentModels && typeof currentModels === "object"
408
+ ? (currentModels[tier]?.model ?? currentModels[tier] ?? null)
409
+ : null;
410
+ const currentProvider = providerKey(currentModel || "");
411
+ const failingProvider = providerKey(classified.provider || "");
412
+
413
+ const candidates = [
414
+ tierDef.primary,
415
+ ...tierDef.fallback,
416
+ ]
417
+ .filter((modelId) => typeof modelId === "string" && modelId)
418
+ .filter((modelId) => modelId !== currentModel)
419
+ .filter((modelId) => {
420
+ const candidateProvider = providerKey(modelId);
421
+ if (!candidateProvider) return true;
422
+ if (failingProvider && candidateProvider === failingProvider) return false;
423
+ if (currentProvider && candidateProvider === currentProvider) return false;
424
+ return true;
425
+ });
426
+
427
+ const targetModel = candidates[0] ?? null;
428
+ if (!targetModel) return null;
429
+
430
+ return {
431
+ action: "apply-models",
432
+ reason: classified.kind,
433
+ targetModel,
434
+ tier,
435
+ };
436
+ }
437
+
438
+ function resolveTierAssignments(envValues = {}, registryModels = {}) {
439
+ const normalizedEnv = normalizeEnvAssignments(envValues);
440
+ const explicitSources = envValues?.sources && typeof envValues.sources === "object"
441
+ ? envValues.sources
442
+ : {};
443
+ const defaults = getRegistryDefaults(registryModels);
444
+ const tiers = {};
445
+
446
+ for (const tier of ["reasoning", "standard", "fast"]) {
447
+ if (explicitSources[tier]) {
448
+ tiers[tier] = {
449
+ model: normalizedEnv[tier] ?? defaults[tier],
450
+ source: explicitSources[tier],
451
+ };
452
+ continue;
453
+ }
454
+
455
+ if (normalizedEnv[tier]) {
456
+ tiers[tier] = {
457
+ model: normalizedEnv[tier],
458
+ source: "env override",
459
+ };
460
+ continue;
461
+ }
462
+
463
+ tiers[tier] = {
464
+ model: defaults[tier],
465
+ source: registryModels[tier] ? "registry default" : "built-in default",
466
+ };
467
+ }
468
+
469
+ return tiers;
470
+ }
471
+
472
+ export function selectModelTierForWorkCategory(workCategory = "") {
473
+ return MODEL_TIER_BY_WORK_CATEGORY[workCategory] ?? null;
474
+ }
475
+
476
+ export function resolveExecutionContractModelMetadata({
477
+ envValues = {},
478
+ registryModels = {},
479
+ requestedTier = null,
480
+ workCategory = null,
481
+ } = {}) {
482
+ const tiers = resolveTierAssignments(envValues, registryModels);
483
+ const selectedTier = requestedTier ?? selectModelTierForWorkCategory(workCategory);
484
+ const selected = selectedTier ? tiers[selectedTier] : null;
485
+
486
+ return {
487
+ version: "v1",
488
+ workCategory: workCategory ?? null,
489
+ requestedTier: requestedTier ?? null,
490
+ selectedTier: selectedTier ?? null,
491
+ selectedModel: selected?.model ?? null,
492
+ selectedModelSource: selected?.source ?? null,
493
+ tiers,
494
+ };
495
+ }
496
+
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
+ export function inferTierModelsFromSelection(
537
+ selectedModel,
538
+ { registryModels = {}, existing = {} } = {}
539
+ ) {
540
+ if (!selectedModel) return null;
541
+ const family = matchProviderFamily(selectedModel);
542
+ if (!family) return null;
543
+
544
+ const registryDefaults = getRegistryDefaults(registryModels);
545
+ const current = {
546
+ reasoning: existing.reasoning ?? null,
547
+ standard: existing.standard ?? null,
548
+ fast: existing.fast ?? null,
549
+ };
550
+
551
+ const seeded = {
552
+ reasoning: current.reasoning === selectedModel ? selectedModel : current.reasoning,
553
+ standard: current.standard === selectedModel ? selectedModel : current.standard,
554
+ fast: current.fast === selectedModel ? selectedModel : current.fast,
555
+ };
556
+
557
+ const derived = family.resolve(seeded);
558
+ return {
559
+ reasoning: derived.reasoning ?? registryDefaults.reasoning,
560
+ standard: derived.standard ?? registryDefaults.standard,
561
+ fast: derived.fast ?? registryDefaults.fast,
562
+ };
563
+ }
564
+
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
+ export function applyFreePreferenceToTierSet(tierSet, { registryModels = {} } = {}) {
572
+ const defaults = getRegistryDefaults(registryModels);
573
+ return {
574
+ reasoning: preferFreeValue(
575
+ tierSet.reasoning,
576
+ tierSet.standard,
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
+ ),
592
+ };
593
+ }
594
+
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
+ ) {
607
+ const family = matchProviderFamily(selectedModel);
608
+ if (!family) return tierSet;
609
+
610
+ const sameFamily = family.resolve({ reasoning: null, standard: null, fast: null });
611
+
612
+ const next = { ...tierSet };
613
+ for (const tier of ["reasoning", "standard", "fast"]) {
614
+ if (tierSet[tier] === selectedModel) continue;
615
+ const candidate = sameFamily[tier];
616
+ if (candidate && isFreeModel(candidate)) next[tier] = candidate;
617
+ }
618
+ return next;
619
+ }
620
+
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
+ export function applyToEnv(envPath, selections) {
673
+ const existing = fs.existsSync(envPath) ? fs.readFileSync(envPath, "utf8") : "";
674
+ const date = new Date().toISOString().slice(0, 10);
675
+ const commentMarker = "# Auto-set by construct models --apply on";
676
+
677
+ const tierMap = {
678
+ reasoning: "CX_MODEL_REASONING",
679
+ standard: "CX_MODEL_STANDARD",
680
+ fast: "CX_MODEL_FAST",
681
+ };
682
+
683
+ // Strip out any old auto‑generated block
684
+ let lines = existing.split("\n");
685
+ lines = lines.filter((l) => {
686
+ const trimmed = l.trim();
687
+ if (trimmed.startsWith(commentMarker)) return false;
688
+ const key = trimmed.split("=")[0];
689
+ if (Object.values(tierMap).includes(key)) return false;
690
+ return true;
691
+ });
692
+
693
+ // Build the new comment + model lines
694
+ const modelLines = [`${commentMarker} ${date}`];
695
+ for (const [tier, envKey] of Object.entries(tierMap)) {
696
+ if (selections[tier]) {
697
+ modelLines.push(`${envKey}=${selections[tier]}`);
698
+ }
699
+ }
700
+
701
+ // Insert just before the first blank line (or at EOF)
702
+ const insertIdx = lines.findLastIndex((l) => l.trim() !== "") + 1;
703
+ lines.splice(
704
+ insertIdx === 0 ? lines.length : insertIdx,
705
+ 0,
706
+ "",
707
+ ...modelLines
708
+ );
709
+
710
+ fs.writeFileSync(envPath, lines.join("\n"));
711
+ }
712
+
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
+ export function resetEnv(envPath) {
718
+ if (!fs.existsSync(envPath)) return;
719
+ 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
+ ]);
725
+
726
+ const lines = fs.readFileSync(envPath, "utf8").split("\n");
727
+ const filtered = lines.filter((l) => {
728
+ const trimmed = l.trim();
729
+ if (trimmed.startsWith(commentMarker)) return false;
730
+ const key = trimmed.split("=")[0];
731
+ if (tierKeys.has(key)) return false;
732
+ return true;
733
+ });
734
+
735
+ fs.writeFileSync(envPath, filtered.join("\n"));
736
+ }
737
+
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
+ export function setTierModel(envPath, tier, modelId) {
745
+ applyToEnv(envPath, { [tier]: modelId });
746
+ }
747
+
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
+ ) {
768
+ const existing = readEnvAssignments(envPath);
769
+ existing[tier] = modelId;
770
+ const inferred = inferTierModelsFromSelection(modelId, {
771
+ registryModels,
772
+ existing,
773
+ }) || existing;
774
+ inferred[tier] = modelId;
775
+
776
+ let resolved = inferred;
777
+ if (options.preferFreeSameFamily) {
778
+ resolved = applyFreeSameFamilyPreferenceToTierSet(resolved, modelId);
779
+ } else if (options.preferFree) {
780
+ resolved = applyFreePreferenceToTierSet(resolved, { registryModels });
781
+ }
782
+ resolved[tier] = modelId;
783
+ applyToEnv(envPath, resolved);
784
+ return resolved;
785
+ }
786
+
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
+ export function readCurrentModels(envPath, registryModels = {}) {
795
+ const envValues = arguments.length > 2 ? arguments[2] : {};
796
+ const fileAssignments = readEnvAssignments(envPath);
797
+ const mergedAssignments = {
798
+ ...fileAssignments,
799
+ ...Object.fromEntries(
800
+ Object.entries(normalizeEnvAssignments(envValues)).filter(([, value]) => value)
801
+ ),
802
+ };
803
+ const tiers = resolveTierAssignments(mergedAssignments, registryModels);
804
+ const result = { sources: {} };
805
+ for (const tier of ["reasoning", "standard", "fast"]) {
806
+ result[tier] = tiers[tier].model;
807
+ result.sources[tier] = tiers[tier].source;
808
+ }
809
+ return result;
810
+ }