@geraldmaron/construct 1.1.0 → 1.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +30 -18
- package/bin/construct +392 -91
- package/bin/construct-postinstall.mjs +30 -2
- package/examples/distribution/README.md +42 -0
- package/examples/distribution/manifest.json +53 -0
- package/examples/distribution/sources/adr.md +84 -0
- package/examples/distribution/sources/deck-one-pager.md +65 -0
- package/examples/distribution/sources/prd-platform.md +161 -0
- package/examples/distribution/sources/research-brief.md +80 -0
- package/examples/distribution/sources/rfc-platform.md +82 -0
- package/examples/distribution/sources/runbook.md +103 -0
- package/examples/distribution/sources/strategy.md +88 -0
- package/lib/adapters-sync.mjs +67 -0
- package/lib/artifact-gate-notice.mjs +38 -0
- package/lib/artifact-manifest.mjs +112 -0
- package/lib/artifact-release-gate.mjs +171 -0
- package/lib/artifact-reviewers.mjs +68 -0
- package/lib/artifact-type-from-path.mjs +79 -0
- package/lib/audit-skills.mjs +4 -0
- package/lib/audit-specialists.mjs +285 -0
- package/lib/audit-trail.mjs +77 -10
- package/lib/auto-docs.mjs +87 -18
- package/lib/beads/drift.mjs +27 -6
- package/lib/beads-client.mjs +49 -121
- package/lib/beads-optimistic.mjs +5 -12
- package/lib/brand-fonts.mjs +93 -0
- package/lib/brand-prose.mjs +214 -0
- package/lib/brand-tokens.mjs +92 -0
- package/lib/bridges/copilot-proxy.mjs +13 -26
- package/lib/capability-ledger.mjs +156 -0
- package/lib/certification/artifact-fixtures.mjs +132 -0
- package/lib/certification/artifact-gates.mjs +63 -0
- package/lib/certification/artifact-provenance.mjs +97 -0
- package/lib/certification/canonical-scenarios.mjs +78 -0
- package/lib/certification/cli.mjs +191 -0
- package/lib/certification/dashboard-api.mjs +71 -0
- package/lib/certification/demo-parity.mjs +116 -0
- package/lib/certification/document-io-fixtures.mjs +246 -0
- package/lib/certification/document-workflow.mjs +97 -0
- package/lib/certification/eval-bridge.mjs +77 -0
- package/lib/certification/model-routing.mjs +149 -0
- package/lib/certification/prompt-budget.mjs +119 -0
- package/lib/certification/rc-gate.mjs +206 -0
- package/lib/certification/real-llm-scenarios.mjs +303 -0
- package/lib/certification/role-cards.mjs +113 -0
- package/lib/certification/role-overlays.mjs +117 -0
- package/lib/certification/run.mjs +122 -0
- package/lib/certification/runner.mjs +323 -0
- package/lib/certification/scenarios.mjs +51 -0
- package/lib/certification/skill-inventory.mjs +289 -0
- package/lib/certification/skill-scenarios.mjs +147 -0
- package/lib/certification/specialist-contracts.mjs +85 -0
- package/lib/certification/specialist-scenarios.mjs +175 -0
- package/lib/certification/stale-impact.mjs +146 -0
- package/lib/certification/status.mjs +252 -0
- package/lib/certification/store.mjs +77 -0
- package/lib/chat/cli.mjs +333 -0
- package/lib/chat/command-suggest.mjs +161 -0
- package/lib/chat/commands.mjs +215 -0
- package/lib/chat/config.mjs +142 -0
- package/lib/chat/context-compactor.mjs +250 -0
- package/lib/chat/context-continuation.mjs +253 -0
- package/lib/chat/continuation-source.mjs +58 -0
- package/lib/chat/demo-guide.mjs +61 -0
- package/lib/chat/design-tokens.mjs +91 -0
- package/lib/chat/desktop-binary.mjs +79 -0
- package/lib/chat/desktop-build.mjs +130 -0
- package/lib/chat/desktop-launcher.mjs +133 -0
- package/lib/chat/evidence.mjs +145 -0
- package/lib/chat/export.mjs +74 -0
- package/lib/chat/harness/driver.mjs +91 -0
- package/lib/chat/list-picker.mjs +112 -0
- package/lib/chat/model-picker.mjs +356 -0
- package/lib/chat/openrouter-fallback.mjs +151 -0
- package/lib/chat/permission-prompt.mjs +33 -0
- package/lib/chat/picker-catalog.mjs +45 -0
- package/lib/chat/policy-telemetry.mjs +34 -0
- package/lib/chat/present.mjs +245 -0
- package/lib/chat/session-context.mjs +39 -0
- package/lib/chat/session-persist.mjs +73 -0
- package/lib/chat/session-restore.mjs +71 -0
- package/lib/chat/session-settings.mjs +53 -0
- package/lib/chat/system-prompt.mjs +52 -0
- package/lib/chat/transparency.mjs +93 -0
- package/lib/chat/tui/color-scheme.mjs +42 -0
- package/lib/chat/tui/markdown.mjs +123 -0
- package/lib/chat/tui/presentation.mjs +100 -0
- package/lib/chat/tui/render.mjs +500 -0
- package/lib/chat/tui/turn-block.mjs +284 -0
- package/lib/chat/tui/turn-present.mjs +18 -0
- package/lib/chat/tui/turn-state.mjs +88 -0
- package/lib/chat/tui/usage.mjs +122 -0
- package/lib/chat/web-commands.mjs +146 -0
- package/lib/chat/web-launcher.mjs +63 -0
- package/lib/chat/web-picker-keys.mjs +46 -0
- package/lib/chat/web-session.mjs +159 -0
- package/lib/cli-commands.mjs +232 -11
- package/lib/comment-lint.mjs +6 -4
- package/lib/config/schema.mjs +36 -5
- package/lib/contract-schemas/decision.json +50 -0
- package/lib/contract-schemas/implementation.json +51 -0
- package/lib/contract-schemas/review-report.json +32 -0
- package/lib/contract-schemas/test-report.json +43 -0
- package/lib/contracts/construct-handoff.mjs +60 -0
- package/lib/contracts/validate.mjs +32 -3
- package/lib/contracts/violation-log.mjs +58 -5
- package/lib/dashboard-demo.mjs +71 -0
- package/lib/dashboard-static.mjs +19 -5
- package/lib/decisions/registry.mjs +5 -3
- package/lib/deck-export-pptx.mjs +1152 -0
- package/lib/demo-recording.mjs +142 -0
- package/lib/demo-script.mjs +114 -0
- package/lib/demo-surface.mjs +249 -0
- package/lib/demo.mjs +703 -0
- package/lib/diagram-export.mjs +192 -0
- package/lib/diagram.mjs +302 -0
- package/lib/docs-verify.mjs +25 -7
- package/lib/doctor/index.mjs +4 -2
- package/lib/doctor/project-adapters.mjs +44 -0
- package/lib/doctor/watchers/cx-budget.mjs +98 -0
- package/lib/doctor/watchers/graph-staleness.mjs +52 -0
- package/lib/doctor/watchers/process-pressure.mjs +4 -3
- package/lib/doctor/watchers/service-health.mjs +1 -1
- package/lib/document-export.mjs +360 -36
- package/lib/document-extract.mjs +88 -0
- package/lib/document-ingest.mjs +32 -18
- package/lib/embed/cli.mjs +2 -2
- package/lib/embed/daemon.mjs +18 -46
- package/lib/embed/semantic.mjs +5 -2
- package/lib/embed/worker.mjs +2 -2
- package/lib/embedded-contract/model-resolve.mjs +10 -9
- package/lib/env-config.mjs +26 -0
- package/lib/evals/dataset.mjs +137 -0
- package/lib/evals/gates.mjs +175 -0
- package/lib/graph/build-co-change.mjs +54 -0
- package/lib/graph/build-from-registry.mjs +136 -0
- package/lib/graph/build-import-graph.mjs +153 -0
- package/lib/graph/cli.mjs +110 -0
- package/lib/graph/impact-cli.mjs +89 -0
- package/lib/graph/impact.mjs +108 -0
- package/lib/graph/staleness.mjs +44 -0
- package/lib/graph/store.mjs +172 -0
- package/lib/handoffs/cleanup.mjs +1 -1
- package/lib/health-check.mjs +90 -76
- package/lib/hooks/artifact-release-gate.mjs +43 -0
- package/lib/hooks/audit-trail.mjs +14 -28
- package/lib/hooks/brand-prose-lint.mjs +38 -0
- package/lib/hooks/graph-impact-advisory.mjs +62 -0
- package/lib/hooks/session-optimize.mjs +84 -207
- package/lib/hooks/session-start.mjs +4 -5
- package/lib/host-disposition.mjs +7 -0
- package/lib/improvement/cli.mjs +189 -0
- package/lib/improvement/controller.mjs +137 -0
- package/lib/improvement/proposal.mjs +120 -0
- package/lib/improvement/specialist-loop.mjs +192 -0
- package/lib/improvement/store.mjs +89 -0
- package/lib/improvement/surface.mjs +219 -0
- package/lib/ingest-tooling.mjs +97 -0
- package/lib/init/doc-lanes.mjs +165 -0
- package/lib/init-docs.mjs +31 -178
- package/lib/init-unified.mjs +27 -97
- package/lib/init-update-guide.mjs +102 -0
- package/lib/init-update.mjs +32 -1
- package/lib/init.mjs +8 -0
- package/lib/install/desktop-binary-download.mjs +85 -0
- package/lib/install/legacy-global-cleanup.mjs +189 -0
- package/lib/intake/daemon.mjs +2 -0
- package/lib/intake/git-queue.mjs +9 -8
- package/lib/intake/queue.mjs +2 -1
- package/lib/intake/session-prelude.mjs +90 -1
- package/lib/libreoffice-export.mjs +97 -0
- package/lib/logging/rotate.mjs +9 -0
- package/lib/maintenance/docker-reclaim.mjs +206 -0
- package/lib/mcp/external-schema-cost.mjs +74 -0
- package/lib/mcp/server.mjs +84 -9
- package/lib/mcp/stdio-mcp-probe.mjs +188 -0
- package/lib/mcp/tool-budget.mjs +55 -4
- package/lib/mcp/tools/document.mjs +18 -4
- package/lib/mcp/tools/skills.mjs +32 -3
- package/lib/mcp/tools/workflow.mjs +13 -1
- package/lib/model-free-selector.mjs +18 -0
- package/lib/model-registry.mjs +13 -229
- package/lib/model-router.mjs +425 -93
- package/lib/models/behavior-matrix.mjs +289 -0
- package/lib/models/catalog.mjs +209 -0
- package/lib/models/execution-capability-profile.mjs +196 -0
- package/lib/models/execution-policy.mjs +307 -0
- package/lib/models/provider-poll.mjs +383 -0
- package/lib/npm-spawn-env.mjs +17 -0
- package/lib/ollama/installed-models.mjs +129 -0
- package/lib/oracle/actions.mjs +187 -0
- package/lib/oracle/artifact-gate.mjs +99 -0
- package/lib/oracle/cli.mjs +204 -0
- package/lib/oracle/daemon-entry.mjs +14 -0
- package/lib/oracle/dispatch.mjs +81 -0
- package/lib/oracle/execute.mjs +143 -0
- package/lib/oracle/gaps.mjs +76 -0
- package/lib/oracle/index.mjs +84 -0
- package/lib/oracle/issues.mjs +164 -0
- package/lib/oracle/org-graph.mjs +170 -0
- package/lib/oracle/policy.mjs +80 -0
- package/lib/oracle/read-model.mjs +398 -0
- package/lib/oracle/reconcile.mjs +191 -0
- package/lib/oracle/routing.mjs +89 -0
- package/lib/oracle/synthesize.mjs +384 -0
- package/lib/oracle/verdicts.mjs +51 -0
- package/lib/orchestration/worker.mjs +88 -27
- package/lib/orchestration-policy.mjs +50 -0
- package/lib/parity.mjs +96 -2
- package/lib/persona-sections.mjs +66 -0
- package/lib/playwright-demo.mjs +252 -0
- package/lib/project-init-shared.mjs +4 -1
- package/lib/prompt-composer.js +9 -4
- package/lib/prompt-validation-contract.mjs +24 -0
- package/lib/provider-capabilities.js +57 -11
- package/lib/providers/contract/adapters/confluence/index.mjs +181 -0
- package/lib/providers/contract/adapters/git/index.mjs +115 -0
- package/lib/providers/contract/adapters/github/index.mjs +166 -0
- package/lib/providers/contract/adapters/jira/index.mjs +187 -0
- package/lib/providers/contract/adapters/slack/index.mjs +175 -0
- package/lib/providers/contract/contract-tests.mjs +57 -0
- package/lib/providers/contract/errors.mjs +48 -0
- package/lib/providers/contract/interface.mjs +50 -0
- package/lib/providers/contract/registry.mjs +102 -0
- package/lib/providers/copilot-auth.mjs +297 -0
- package/lib/providers/credential-bootstrap.mjs +178 -0
- package/lib/providers/credential-catalog.mjs +46 -0
- package/lib/providers/credential-sources.mjs +63 -0
- package/lib/providers/creds.mjs +5 -2
- package/lib/providers/op-run.mjs +59 -0
- package/lib/providers/secret-resolver.mjs +159 -0
- package/lib/publish-template.mjs +163 -0
- package/lib/publish-tooling.mjs +119 -0
- package/lib/publish.mjs +305 -0
- package/lib/registry/cli.mjs +82 -0
- package/lib/registry/consolidation.mjs +147 -0
- package/lib/registry/generate-docs.mjs +75 -0
- package/lib/registry/skill-verification.mjs +57 -0
- package/lib/registry/surface-map.mjs +76 -0
- package/lib/registry/validate.mjs +135 -0
- package/lib/resources/budget.mjs +82 -0
- package/lib/resources/process-budget.mjs +45 -0
- package/lib/rules-delivery.mjs +9 -2
- package/lib/rules-read.mjs +26 -0
- package/lib/runtime-env.mjs +23 -0
- package/lib/runtime-pressure.mjs +51 -7
- package/lib/schema-infer.mjs +13 -2
- package/lib/server/chat-loop.mjs +622 -0
- package/lib/server/demo-preview.mjs +63 -0
- package/lib/server/index.mjs +229 -8
- package/lib/server/langfuse-login.mjs +3 -3
- package/lib/service-manager.mjs +61 -9
- package/lib/session-store.mjs +1 -1
- package/lib/setup.mjs +102 -2
- package/lib/specialists/prompt-schema.mjs +19 -10
- package/lib/specialists/roster.mjs +43 -0
- package/lib/specialists/scaffold.mjs +56 -0
- package/lib/storage/backend.mjs +6 -6
- package/lib/storage/embeddings-local.mjs +6 -3
- package/lib/storage/file-lock.mjs +18 -11
- package/lib/storage/hybrid-query.mjs +7 -4
- package/lib/storage/state-source.mjs +5 -5
- package/lib/storage/sync.mjs +2 -3
- package/lib/telemetry/rule-calls.mjs +52 -0
- package/lib/template-registry.mjs +2 -2
- package/lib/templates/visual-requirements.mjs +27 -51
- package/lib/test-corpus-inventory.mjs +313 -0
- package/lib/uninstall/uninstall.mjs +19 -7
- package/lib/update.mjs +12 -0
- package/lib/upgrade.mjs +14 -0
- package/lib/wireframe.mjs +20 -14
- package/lib/worker/run.mjs +17 -4
- package/lib/worker/trace.mjs +11 -3
- package/package.json +29 -13
- package/personas/construct.md +13 -13
- package/platforms/claude/settings.template.json +36 -0
- package/rules/common/patterns.md +1 -1
- package/rules/common/release-gates.md +4 -3
- package/scripts/sync-specialists.mjs +187 -60
- package/skills/devops/data-engineering.md +1 -1
- package/skills/docs/adr-workflow.md +1 -0
- package/skills/docs/backlog-proposal-workflow.md +1 -0
- package/skills/docs/codebase-research-workflow.md +40 -0
- package/skills/docs/customer-profile-workflow.md +1 -0
- package/skills/docs/document-ingest-workflow.md +1 -0
- package/skills/docs/evidence-ingest-workflow.md +1 -0
- package/skills/docs/init-docs.md +2 -2
- package/skills/docs/init-project.md +8 -3
- package/skills/docs/prd-workflow.md +24 -1
- package/skills/docs/prfaq-workflow.md +1 -0
- package/skills/docs/product-intelligence-workflow.md +1 -0
- package/skills/docs/product-signal-workflow.md +1 -0
- package/skills/docs/research-workflow.md +54 -37
- package/skills/docs/runbook-workflow.md +1 -0
- package/skills/docs/strategy-workflow.md +1 -0
- package/skills/docs/user-research-workflow.md +40 -0
- package/skills/operating/orchestration-reference.md +1 -1
- package/skills/roles/architect.md +5 -0
- package/skills/roles/operator.docs.md +4 -0
- package/skills/routing.md +4 -2
- package/specialists/artifact-manifest.json +480 -0
- package/specialists/artifact-manifest.schema.json +53 -0
- package/specialists/audit-enrichments.json +454 -0
- package/specialists/contracts.json +37 -25
- package/specialists/prompts/_shared/validation-contract.md +26 -0
- package/specialists/prompts/cx-accessibility.md +21 -2
- package/specialists/prompts/cx-ai-engineer.md +24 -1
- package/specialists/prompts/cx-architect.md +4 -1
- package/specialists/prompts/cx-business-strategist.md +23 -2
- package/specialists/prompts/cx-data-analyst.md +22 -1
- package/specialists/prompts/cx-data-engineer.md +23 -2
- package/specialists/prompts/cx-debugger.md +21 -2
- package/specialists/prompts/cx-designer.md +26 -3
- package/specialists/prompts/cx-devil-advocate.md +19 -2
- package/specialists/prompts/cx-docs-keeper.md +28 -1
- package/specialists/prompts/cx-engineer.md +22 -1
- package/specialists/prompts/cx-evaluator.md +18 -1
- package/specialists/prompts/cx-explorer.md +21 -2
- package/specialists/prompts/cx-legal-compliance.md +21 -2
- package/specialists/prompts/cx-operations.md +21 -2
- package/specialists/prompts/cx-oracle.md +94 -0
- package/specialists/prompts/cx-orchestrator.md +20 -1
- package/specialists/prompts/cx-platform-engineer.md +25 -2
- package/specialists/prompts/cx-product-manager.md +32 -1
- package/specialists/prompts/cx-qa.md +24 -2
- package/specialists/prompts/cx-rd-lead.md +23 -2
- package/specialists/prompts/cx-release-manager.md +23 -2
- package/specialists/prompts/cx-researcher.md +22 -2
- package/specialists/prompts/cx-reviewer.md +18 -1
- package/specialists/prompts/cx-security.md +22 -2
- package/specialists/prompts/cx-sre.md +30 -3
- package/specialists/prompts/cx-test-automation.md +7 -1
- package/specialists/prompts/cx-trace-reviewer.md +22 -3
- package/specialists/prompts/cx-ux-researcher.md +21 -2
- package/specialists/registry.json +52 -173
- package/specialists/tone-profiles.json +42 -0
- package/templates/demos/playwright/demo-recording.config.mjs +47 -0
- package/templates/demos/recordings/agentic-platforms-prd.json +19 -0
- package/templates/demos/scripts/agentic-platforms-prd.json +44 -0
- package/templates/demos/specs/_helpers/scroll-artifact.ts +89 -0
- package/templates/demos/tapes/agentic-platforms-prd.tape +49 -0
- package/templates/demos/tapes/resource-guard-rails.tape +49 -0
- package/templates/demos/vhs/construct-cockpit.json +24 -0
- package/templates/distribution/construct-brand.typ +446 -0
- package/templates/distribution/construct-decision.typ +38 -0
- package/templates/distribution/construct-deck.html +95 -0
- package/templates/distribution/construct-pdf.typ +38 -0
- package/templates/distribution/construct-prd.typ +38 -0
- package/templates/distribution/construct-research.typ +38 -0
- package/templates/distribution/construct-web.html +92 -0
- package/templates/distribution/fonts/Geist-Bold.ttf +0 -0
- package/templates/distribution/fonts/Geist-Medium.ttf +0 -0
- package/templates/distribution/fonts/Geist-Regular.ttf +0 -0
- package/templates/distribution/fonts/Geist-SemiBold.ttf +0 -0
- package/templates/distribution/fonts/GeistMono-Medium.ttf +0 -0
- package/templates/distribution/fonts/GeistMono-Regular.ttf +0 -0
- package/templates/distribution/fonts/GeistMono-SemiBold.ttf +0 -0
- package/templates/distribution/fonts/IBMPlexMono-Regular.otf +0 -0
- package/templates/distribution/fonts/JetBrainsMono-Medium.ttf +0 -0
- package/templates/distribution/fonts/JetBrainsMono-Regular.ttf +0 -0
- package/templates/distribution/fonts/JetBrainsMono-SemiBold.ttf +0 -0
- package/templates/distribution/fonts/PlusJakartaSans-Bold.ttf +0 -0
- package/templates/distribution/fonts/PlusJakartaSans-Medium.ttf +0 -0
- package/templates/distribution/fonts/PlusJakartaSans-Regular.ttf +0 -0
- package/templates/distribution/fonts/PlusJakartaSans-SemiBold.ttf +0 -0
- package/templates/distribution/fonts/README.md +36 -0
- package/templates/distribution/fonts/SpaceGrotesk-Variable.ttf +0 -0
- package/templates/distribution/fonts/handwritten/Caveat.ttf +0 -0
- package/templates/distribution/fonts/legacy/IBMPlexMono-Regular.otf +0 -0
- package/templates/distribution/fonts/legacy/Inter-Medium.otf +0 -0
- package/templates/distribution/fonts/legacy/Inter-Regular.otf +0 -0
- package/templates/distribution/fonts/legacy/Inter-SemiBold.otf +0 -0
- package/templates/distribution/fonts/legacy/InterDisplay-SemiBold.otf +0 -0
- package/templates/distribution/fonts/legacy/SourceSerif4-Regular.otf +0 -0
- package/templates/distribution/fonts/legacy/SourceSerif4-Semibold.otf +0 -0
- package/templates/distribution/icons/activity.svg +15 -0
- package/templates/distribution/icons/alert-triangle.svg +17 -0
- package/templates/distribution/icons/book-open.svg +16 -0
- package/templates/distribution/icons/bot.svg +20 -0
- package/templates/distribution/icons/brain.svg +22 -0
- package/templates/distribution/icons/circle-check.svg +16 -0
- package/templates/distribution/icons/clipboard-check.svg +17 -0
- package/templates/distribution/icons/cpu.svg +28 -0
- package/templates/distribution/icons/database.svg +17 -0
- package/templates/distribution/icons/eye.svg +16 -0
- package/templates/distribution/icons/file-text.svg +19 -0
- package/templates/distribution/icons/gauge.svg +16 -0
- package/templates/distribution/icons/git-branch.svg +17 -0
- package/templates/distribution/icons/key.svg +17 -0
- package/templates/distribution/icons/layers.svg +17 -0
- package/templates/distribution/icons/list-checks.svg +19 -0
- package/templates/distribution/icons/lock.svg +16 -0
- package/templates/distribution/icons/message-square.svg +15 -0
- package/templates/distribution/icons/network.svg +19 -0
- package/templates/distribution/icons/route.svg +17 -0
- package/templates/distribution/icons/scale.svg +19 -0
- package/templates/distribution/icons/search.svg +16 -0
- package/templates/distribution/icons/send.svg +16 -0
- package/templates/distribution/icons/server.svg +18 -0
- package/templates/distribution/icons/shield-check.svg +16 -0
- package/templates/distribution/icons/users.svg +18 -0
- package/templates/distribution/icons/webhook.svg +17 -0
- package/templates/distribution/icons/workflow.svg +17 -0
- package/templates/distribution/icons/wrench.svg +15 -0
- package/templates/distribution/run.mjs +18 -1
- package/templates/docs/adr.md +7 -0
- package/templates/docs/construct_guide.md +10 -10
- package/templates/docs/customer-profile.md +4 -0
- package/templates/docs/one-pager.md +4 -0
- package/templates/docs/postmortem.md +31 -0
- package/templates/docs/prd-platform.md +19 -0
- package/templates/docs/prd.md +15 -0
- package/templates/docs/prfaq.md +7 -0
- package/templates/docs/qa-strategy.md +103 -0
- package/templates/docs/research-brief.md +8 -0
- package/templates/docs/rfc-platform.md +12 -0
- package/templates/docs/test-plan.md +7 -0
- package/lib/ingest/chunker.mjs +0 -94
- package/lib/ingest/pipeline.mjs +0 -53
- package/lib/ingest/store.mjs +0 -82
- package/lib/mode-commands.mjs +0 -122
- package/lib/policy/unified-gates.mjs +0 -96
- package/lib/profiles/validate-custom.mjs +0 -114
- package/lib/services/telemetry-backend.mjs +0 -177
- package/lib/storage/fusion.mjs +0 -95
|
@@ -0,0 +1,383 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* lib/models/provider-poll.mjs — live, per-provider model enumeration for the picker.
|
|
3
|
+
*
|
|
4
|
+
* Each configured provider is queried at its own list endpoint so the picker
|
|
5
|
+
* shows the provider's actual catalog rather than a shipped guess: OpenRouter
|
|
6
|
+
* /api/v1/models, OpenAI /v1/models, Anthropic /v1/models, GitHub Copilot
|
|
7
|
+
* /models, and Ollama /api/tags. Providers without a list API (or whose poll
|
|
8
|
+
* fails) fall back to the curated tier options, flagged source:'curated' so the
|
|
9
|
+
* UI never implies a live result it could not verify. Every poller is
|
|
10
|
+
* best-effort: it resolves to [] on any error and never throws, and results are
|
|
11
|
+
* cached to ~/.cx/provider-catalog-cache.json so repeat opens are instant.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import fs from 'node:fs';
|
|
15
|
+
import os from 'node:os';
|
|
16
|
+
import path from 'node:path';
|
|
17
|
+
import { resolveFirstSecret } from '../providers/secret-resolver.mjs';
|
|
18
|
+
import { getProviderModelCatalog } from '../model-router.mjs';
|
|
19
|
+
import { listInstalledOllamaModels, toOllamaNativeModelId } from '../ollama/installed-models.mjs';
|
|
20
|
+
import { getPricingForModels } from '../model-pricing.mjs';
|
|
21
|
+
|
|
22
|
+
const FETCH_TIMEOUT_MS = 10_000;
|
|
23
|
+
const CACHE_TTL_MS = 10 * 60 * 1000;
|
|
24
|
+
const CACHE_FILENAME = 'provider-catalog-cache.json';
|
|
25
|
+
|
|
26
|
+
const PROVIDER_LABELS = {
|
|
27
|
+
anthropic: 'Anthropic',
|
|
28
|
+
openai: 'OpenAI',
|
|
29
|
+
'github-copilot': 'GitHub Copilot',
|
|
30
|
+
openrouter: 'OpenRouter',
|
|
31
|
+
ollama: 'Ollama (local)',
|
|
32
|
+
local: 'Local',
|
|
33
|
+
};
|
|
34
|
+
|
|
35
|
+
// Render order for groups: first-party hosted, then the OpenRouter aggregator,
|
|
36
|
+
// then local runtimes. Unknown providers sort after these by label.
|
|
37
|
+
const PROVIDER_ORDER = ['anthropic', 'openai', 'github-copilot', 'openrouter', 'ollama', 'local'];
|
|
38
|
+
|
|
39
|
+
function cachePath(homeDir = os.homedir()) {
|
|
40
|
+
return path.join(homeDir, '.cx', CACHE_FILENAME);
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
async function fetchJson(url, { headers = {}, signal } = {}) {
|
|
44
|
+
const controller = new AbortController();
|
|
45
|
+
const timeout = setTimeout(() => controller.abort(), FETCH_TIMEOUT_MS);
|
|
46
|
+
try {
|
|
47
|
+
const res = await fetch(url, { headers, signal: signal || controller.signal });
|
|
48
|
+
if (!res.ok) return null;
|
|
49
|
+
return await res.json();
|
|
50
|
+
} catch {
|
|
51
|
+
return null;
|
|
52
|
+
} finally {
|
|
53
|
+
clearTimeout(timeout);
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function isFreePricing(pricing) {
|
|
58
|
+
return pricing?.prompt === '0' && pricing?.completion === '0';
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
// OpenRouter advertises capabilities through supported_parameters and the
|
|
62
|
+
// architecture modality lists, so reasoning/tools/vision are read, not inferred.
|
|
63
|
+
function openRouterCapabilities(model) {
|
|
64
|
+
const params = Array.isArray(model.supported_parameters) ? model.supported_parameters : [];
|
|
65
|
+
const inputs = model.architecture?.input_modalities || [];
|
|
66
|
+
return {
|
|
67
|
+
reasoning: params.includes('reasoning') || params.includes('include_reasoning'),
|
|
68
|
+
tools: params.includes('tools') || params.includes('tool_choice'),
|
|
69
|
+
vision: Array.isArray(inputs) && inputs.includes('image'),
|
|
70
|
+
};
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
export async function pollOpenRouter({ env = process.env } = {}) {
|
|
74
|
+
const apiKey = resolveFirstSecret(['OPENROUTER_API_KEY', 'OPEN_ROUTER_API_KEY'], { env });
|
|
75
|
+
const headers = apiKey ? { Authorization: `Bearer ${apiKey}` } : {};
|
|
76
|
+
const data = await fetchJson('https://openrouter.ai/api/v1/models', { headers });
|
|
77
|
+
if (!data) return null;
|
|
78
|
+
const list = Array.isArray(data?.data) ? data.data : [];
|
|
79
|
+
const models = [];
|
|
80
|
+
for (const m of list) {
|
|
81
|
+
if (!m?.id) continue;
|
|
82
|
+
const free = isFreePricing(m.pricing);
|
|
83
|
+
const input = Number(m.pricing?.prompt);
|
|
84
|
+
const output = Number(m.pricing?.completion);
|
|
85
|
+
const caps = openRouterCapabilities(m);
|
|
86
|
+
models.push({
|
|
87
|
+
id: `openrouter/${m.id}`,
|
|
88
|
+
label: m.name || m.id,
|
|
89
|
+
provider: 'openrouter',
|
|
90
|
+
free,
|
|
91
|
+
pricing: Number.isFinite(input) && Number.isFinite(output)
|
|
92
|
+
? { input: input * 1_000_000, output: output * 1_000_000 }
|
|
93
|
+
: null,
|
|
94
|
+
context: Number(m.context_length) || null,
|
|
95
|
+
reasoning: caps.reasoning,
|
|
96
|
+
tools: caps.tools,
|
|
97
|
+
toolsKnown: true,
|
|
98
|
+
vision: caps.vision,
|
|
99
|
+
source: 'live',
|
|
100
|
+
});
|
|
101
|
+
}
|
|
102
|
+
return models;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
// The OpenAI list endpoint returns every artifact id (embeddings, audio, image,
|
|
106
|
+
// moderation) with no capability or pricing metadata. Keep only chat-capable
|
|
107
|
+
// families and mark the o-series as reasoning, which is definitional rather than
|
|
108
|
+
// guessed.
|
|
109
|
+
const OPENAI_NON_CHAT = /(embedding|whisper|tts|audio|realtime|image|dall-e|moderation|search|babbage|davinci|transcribe|sora)/i;
|
|
110
|
+
|
|
111
|
+
export async function pollOpenAI({ env = process.env } = {}) {
|
|
112
|
+
const apiKey = resolveFirstSecret(['OPENAI_API_KEY'], { env });
|
|
113
|
+
if (!apiKey) return null;
|
|
114
|
+
const data = await fetchJson('https://api.openai.com/v1/models', {
|
|
115
|
+
headers: { Authorization: `Bearer ${apiKey}` },
|
|
116
|
+
});
|
|
117
|
+
if (!data) return null;
|
|
118
|
+
const list = Array.isArray(data?.data) ? data.data : [];
|
|
119
|
+
const models = [];
|
|
120
|
+
for (const m of list) {
|
|
121
|
+
const nativeId = m?.id;
|
|
122
|
+
if (!nativeId) continue;
|
|
123
|
+
if (OPENAI_NON_CHAT.test(nativeId)) continue;
|
|
124
|
+
if (!/^(gpt-|o\d|chatgpt)/i.test(nativeId)) continue;
|
|
125
|
+
models.push({
|
|
126
|
+
id: `openai/${nativeId}`,
|
|
127
|
+
label: nativeId,
|
|
128
|
+
provider: 'openai',
|
|
129
|
+
free: false,
|
|
130
|
+
pricing: null,
|
|
131
|
+
context: null,
|
|
132
|
+
reasoning: /^o\d/i.test(nativeId),
|
|
133
|
+
tools: !/instruct/i.test(nativeId),
|
|
134
|
+
toolsKnown: true,
|
|
135
|
+
vision: false,
|
|
136
|
+
source: 'live',
|
|
137
|
+
});
|
|
138
|
+
}
|
|
139
|
+
return models;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
export async function pollAnthropic({ env = process.env } = {}) {
|
|
143
|
+
const apiKey = resolveFirstSecret(['ANTHROPIC_API_KEY'], { env });
|
|
144
|
+
if (!apiKey) return null;
|
|
145
|
+
const data = await fetchJson('https://api.anthropic.com/v1/models?limit=100', {
|
|
146
|
+
headers: { 'x-api-key': apiKey, 'anthropic-version': '2023-06-01' },
|
|
147
|
+
});
|
|
148
|
+
if (!data) return null;
|
|
149
|
+
const list = Array.isArray(data?.data) ? data.data : [];
|
|
150
|
+
const models = [];
|
|
151
|
+
for (const m of list) {
|
|
152
|
+
const nativeId = m?.id;
|
|
153
|
+
if (!nativeId) continue;
|
|
154
|
+
models.push({
|
|
155
|
+
id: `anthropic/${nativeId}`,
|
|
156
|
+
label: m.display_name || nativeId,
|
|
157
|
+
provider: 'anthropic',
|
|
158
|
+
free: false,
|
|
159
|
+
pricing: null,
|
|
160
|
+
context: null,
|
|
161
|
+
reasoning: false,
|
|
162
|
+
tools: true,
|
|
163
|
+
toolsKnown: true,
|
|
164
|
+
vision: false,
|
|
165
|
+
source: 'live',
|
|
166
|
+
});
|
|
167
|
+
}
|
|
168
|
+
return models;
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
// Copilot tags reasoning support with a reasoning_effort list; a list that is
|
|
172
|
+
// only ['none'] is not a reasoning model. adaptive_thinking is the Claude-family
|
|
173
|
+
// equivalent.
|
|
174
|
+
function copilotReasoning(supports) {
|
|
175
|
+
const effort = supports.reasoning_effort;
|
|
176
|
+
if (Array.isArray(effort) && effort.some((e) => e && e !== 'none')) return true;
|
|
177
|
+
return Boolean(supports.adaptive_thinking);
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
export async function pollCopilot() {
|
|
181
|
+
try {
|
|
182
|
+
const { getCopilotToken, copilotApiHeaders, COPILOT_API_BASE } = await import('../providers/copilot-auth.mjs');
|
|
183
|
+
const token = await getCopilotToken();
|
|
184
|
+
const data = await fetchJson(`${COPILOT_API_BASE}/models`, {
|
|
185
|
+
headers: { Accept: 'application/json', Authorization: `Bearer ${token}`, ...copilotApiHeaders() },
|
|
186
|
+
});
|
|
187
|
+
if (!data) return null;
|
|
188
|
+
const list = Array.isArray(data?.data) ? data.data : (Array.isArray(data) ? data : []);
|
|
189
|
+
const seen = new Set();
|
|
190
|
+
const models = [];
|
|
191
|
+
for (const m of list) {
|
|
192
|
+
const nativeId = m?.id || m?.model;
|
|
193
|
+
if (!nativeId || seen.has(nativeId)) continue;
|
|
194
|
+
if (m?.model_picker_enabled === false) continue;
|
|
195
|
+
if (m?.capabilities?.type && m.capabilities.type !== 'chat') continue;
|
|
196
|
+
seen.add(nativeId);
|
|
197
|
+
const caps = m?.capabilities || {};
|
|
198
|
+
const supports = caps.supports || {};
|
|
199
|
+
models.push({
|
|
200
|
+
id: `github-copilot/${nativeId}`,
|
|
201
|
+
label: m?.name || nativeId,
|
|
202
|
+
provider: 'github-copilot',
|
|
203
|
+
free: false,
|
|
204
|
+
pricing: null,
|
|
205
|
+
context: Number(caps.limits?.max_prompt_tokens || caps.limits?.max_context_window_tokens) || null,
|
|
206
|
+
reasoning: copilotReasoning(supports),
|
|
207
|
+
tools: Boolean(supports.tool_calls),
|
|
208
|
+
toolsKnown: true,
|
|
209
|
+
vision: Boolean(supports.vision) || Boolean(caps.limits?.vision),
|
|
210
|
+
source: 'live',
|
|
211
|
+
});
|
|
212
|
+
}
|
|
213
|
+
return models;
|
|
214
|
+
} catch {
|
|
215
|
+
return null;
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
export function pollOllama({ env = process.env } = {}) {
|
|
220
|
+
const { models: installed, listable } = listInstalledOllamaModels({ env });
|
|
221
|
+
if (!listable || !installed) return null;
|
|
222
|
+
const models = [];
|
|
223
|
+
for (const native of installed) {
|
|
224
|
+
models.push({
|
|
225
|
+
id: `ollama/${native}`,
|
|
226
|
+
label: native,
|
|
227
|
+
provider: 'ollama',
|
|
228
|
+
free: true,
|
|
229
|
+
pricing: { input: 0, output: 0 },
|
|
230
|
+
context: null,
|
|
231
|
+
reasoning: false,
|
|
232
|
+
tools: false,
|
|
233
|
+
toolsKnown: false,
|
|
234
|
+
vision: false,
|
|
235
|
+
source: 'live',
|
|
236
|
+
});
|
|
237
|
+
}
|
|
238
|
+
return models;
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
// When a configured provider can't be reached (or exposes no list endpoint), say
|
|
242
|
+
// so plainly instead of inventing a model list. The hint renders as a disabled,
|
|
243
|
+
// unselectable row so the group is never silently empty and never fabricated.
|
|
244
|
+
const UNREACHABLE_HINTS = {
|
|
245
|
+
ollama: 'Ollama server not reachable — start it with `ollama serve`',
|
|
246
|
+
};
|
|
247
|
+
|
|
248
|
+
function unreachableHint(groupId, label) {
|
|
249
|
+
return {
|
|
250
|
+
id: `__unreachable__/${groupId}`,
|
|
251
|
+
label: UNREACHABLE_HINTS[groupId] || `${label} unreachable — check credentials or connection`,
|
|
252
|
+
provider: groupId,
|
|
253
|
+
free: false,
|
|
254
|
+
pricing: null,
|
|
255
|
+
context: null,
|
|
256
|
+
reasoning: false,
|
|
257
|
+
tools: false,
|
|
258
|
+
vision: false,
|
|
259
|
+
disabled: true,
|
|
260
|
+
source: 'hint',
|
|
261
|
+
};
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
function providerGroupId(familyId) {
|
|
265
|
+
return familyId.startsWith('openrouter') ? 'openrouter' : familyId;
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
// Returns the provider's live model list, or null when the provider could not be
|
|
269
|
+
// reached or has no list endpoint (the 'local' runtime). null is distinct from an
|
|
270
|
+
// empty array, which means "reached, but the account exposes no models".
|
|
271
|
+
async function pollLiveFor(groupId, env) {
|
|
272
|
+
if (groupId === 'openrouter') return pollOpenRouter({ env });
|
|
273
|
+
if (groupId === 'openai') return pollOpenAI({ env });
|
|
274
|
+
if (groupId === 'anthropic') return pollAnthropic({ env });
|
|
275
|
+
if (groupId === 'github-copilot') return pollCopilot();
|
|
276
|
+
if (groupId === 'ollama') return pollOllama({ env });
|
|
277
|
+
return null;
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
async function enrichPricing(models, { env }) {
|
|
281
|
+
const needPricing = models.filter((m) => !m.pricing && m.provider !== 'ollama' && m.provider !== 'local').map((m) => m.id);
|
|
282
|
+
if (!needPricing.length) return models;
|
|
283
|
+
let priceMap = {};
|
|
284
|
+
try {
|
|
285
|
+
priceMap = await getPricingForModels(needPricing);
|
|
286
|
+
} catch { /* pricing is best-effort */ }
|
|
287
|
+
return models.map((m) => {
|
|
288
|
+
if (m.pricing || !priceMap[m.id]) return m;
|
|
289
|
+
const hit = priceMap[m.id];
|
|
290
|
+
return { ...m, pricing: { input: hit.input, output: hit.output } };
|
|
291
|
+
});
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
/**
|
|
295
|
+
* Poll every configured provider in parallel and return one group per provider,
|
|
296
|
+
* in render order, each with its live model list. A provider that can't be
|
|
297
|
+
* reached falls back to its last-good cached list (flagged source:'cached'), or
|
|
298
|
+
* to a disabled unreachable hint when there is no cache — never to an invented
|
|
299
|
+
* list. Successful live polls refresh the on-disk cache.
|
|
300
|
+
*/
|
|
301
|
+
export async function pollConfiguredProviders({ env = process.env, cwd = process.cwd(), homeDir = os.homedir() } = {}) {
|
|
302
|
+
const { providers } = getProviderModelCatalog({ env, cwd });
|
|
303
|
+
const configured = providers.filter((p) => p.configured);
|
|
304
|
+
|
|
305
|
+
const groupIds = [];
|
|
306
|
+
const groupForId = new Map();
|
|
307
|
+
for (const provider of configured) {
|
|
308
|
+
const gid = providerGroupId(provider.id);
|
|
309
|
+
if (!groupForId.has(gid)) {
|
|
310
|
+
groupForId.set(gid, { id: gid, label: PROVIDER_LABELS[gid] || provider.label });
|
|
311
|
+
groupIds.push(gid);
|
|
312
|
+
}
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
const cached = readProviderCatalogCache({ homeDir, maxAgeMs: Infinity }) || [];
|
|
316
|
+
const cachedById = new Map(cached.map((g) => [g.id, g]));
|
|
317
|
+
|
|
318
|
+
// A single provider's credential resolution can throw (e.g. an op:// ref on a
|
|
319
|
+
// machine without the 1Password CLI). Isolate each poll so that failure
|
|
320
|
+
// degrades only its own group to cached/unreachable, never the whole picker.
|
|
321
|
+
const polled = await Promise.all(groupIds.map(async (gid) => {
|
|
322
|
+
let live = null;
|
|
323
|
+
try {
|
|
324
|
+
live = await pollLiveFor(gid, env);
|
|
325
|
+
} catch {
|
|
326
|
+
live = null;
|
|
327
|
+
}
|
|
328
|
+
return { gid, live };
|
|
329
|
+
}));
|
|
330
|
+
|
|
331
|
+
const groups = [];
|
|
332
|
+
const liveGroupsToCache = [];
|
|
333
|
+
for (const { gid, live } of polled) {
|
|
334
|
+
const group = groupForId.get(gid);
|
|
335
|
+
if (Array.isArray(live)) {
|
|
336
|
+
const enriched = await enrichPricing(live, { env });
|
|
337
|
+
groups.push({ id: gid, label: group.label, models: enriched, live: true });
|
|
338
|
+
liveGroupsToCache.push({ id: gid, label: group.label, models: enriched });
|
|
339
|
+
continue;
|
|
340
|
+
}
|
|
341
|
+
const cachedGroup = cachedById.get(gid);
|
|
342
|
+
if (cachedGroup?.models?.length) {
|
|
343
|
+
groups.push({
|
|
344
|
+
id: gid,
|
|
345
|
+
label: group.label,
|
|
346
|
+
models: cachedGroup.models.map((m) => ({ ...m, source: 'cached' })),
|
|
347
|
+
live: false,
|
|
348
|
+
});
|
|
349
|
+
liveGroupsToCache.push(cachedGroup);
|
|
350
|
+
continue;
|
|
351
|
+
}
|
|
352
|
+
groups.push({ id: gid, label: group.label, models: [unreachableHint(gid, group.label)], live: false });
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
groups.sort((a, b) => {
|
|
356
|
+
const ai = PROVIDER_ORDER.indexOf(a.id);
|
|
357
|
+
const bi = PROVIDER_ORDER.indexOf(b.id);
|
|
358
|
+
return (ai < 0 ? 99 : ai) - (bi < 0 ? 99 : bi) || a.label.localeCompare(b.label);
|
|
359
|
+
});
|
|
360
|
+
|
|
361
|
+
if (liveGroupsToCache.length) writeProviderCatalogCache(liveGroupsToCache, { homeDir });
|
|
362
|
+
|
|
363
|
+
return groups;
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
export function readProviderCatalogCache({ homeDir = os.homedir(), maxAgeMs = CACHE_TTL_MS, now = Date.now() } = {}) {
|
|
367
|
+
try {
|
|
368
|
+
const parsed = JSON.parse(fs.readFileSync(cachePath(homeDir), 'utf8'));
|
|
369
|
+
if (!parsed?.fetchedAt || !Array.isArray(parsed.groups)) return null;
|
|
370
|
+
if (now - parsed.fetchedAt > maxAgeMs) return null;
|
|
371
|
+
return parsed.groups;
|
|
372
|
+
} catch {
|
|
373
|
+
return null;
|
|
374
|
+
}
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
export function writeProviderCatalogCache(groups, { homeDir = os.homedir(), now = Date.now() } = {}) {
|
|
378
|
+
try {
|
|
379
|
+
const file = cachePath(homeDir);
|
|
380
|
+
fs.mkdirSync(path.dirname(file), { recursive: true });
|
|
381
|
+
fs.writeFileSync(file, JSON.stringify({ fetchedAt: now, groups }, null, 2));
|
|
382
|
+
} catch { /* cache write is best-effort */ }
|
|
383
|
+
}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* lib/npm-spawn-env.mjs — sanitize env before npm/npx child spawns.
|
|
3
|
+
*
|
|
4
|
+
* npm 11.2+ warns on unknown npm_config_* keys. Cursor sandbox injects
|
|
5
|
+
* npm_config_devdir for node-gyp cache routing; strip it before Construct
|
|
6
|
+
* spawns nested npm/npx so build chains do not duplicate the warning.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
export function sanitizeNpmSpawnEnv(baseEnv = process.env) {
|
|
10
|
+
const env = { ...baseEnv };
|
|
11
|
+
for (const key of Object.keys(env)) {
|
|
12
|
+
if (/^npm_config_devdir$/i.test(key) || key === 'NPM_CONFIG_DEVDIR') {
|
|
13
|
+
delete env[key];
|
|
14
|
+
}
|
|
15
|
+
}
|
|
16
|
+
return env;
|
|
17
|
+
}
|
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* lib/ollama/installed-models.mjs — detect which Ollama model tags are installed locally.
|
|
3
|
+
*
|
|
4
|
+
* Construct model ids use the `ollama/<native>` form; Ollama's OpenAI-compatible API
|
|
5
|
+
* expects the native tag (e.g. `llama3.2:3b`). This module lists `/api/tags` (or falls
|
|
6
|
+
* back to `ollama list`) so availability checks and chat errors can distinguish "server
|
|
7
|
+
* up but model not pulled" from auth or routing problems. Results are cached briefly
|
|
8
|
+
* because the picker and router may call this many times per session.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import { spawnSync } from 'node:child_process';
|
|
12
|
+
import { resolveFirstSecret } from '../providers/secret-resolver.mjs';
|
|
13
|
+
|
|
14
|
+
const CACHE_MS = 2500;
|
|
15
|
+
let cache = { at: 0, models: null, listable: false };
|
|
16
|
+
|
|
17
|
+
function ollamaBaseUrl(env = process.env) {
|
|
18
|
+
const fromEnv = resolveFirstSecret(['OLLAMA_BASE_URL', 'OLLAMA_HOST'], { env, allowAmbient: true });
|
|
19
|
+
const base = (fromEnv || 'http://localhost:11434').replace(/\/+$/, '');
|
|
20
|
+
return base.endsWith('/v1') ? base.slice(0, -3) : base;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
function parseTagsResponse(body) {
|
|
24
|
+
const data = typeof body === 'string' ? JSON.parse(body) : body;
|
|
25
|
+
const names = (data?.models || [])
|
|
26
|
+
.map((entry) => entry?.name || entry?.model)
|
|
27
|
+
.filter(Boolean);
|
|
28
|
+
return new Set(names);
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function listViaTagsApi(baseUrl) {
|
|
32
|
+
const url = `${baseUrl}/api/tags`;
|
|
33
|
+
const r = spawnSync('curl', ['-s', '--connect-timeout', '2', url], { encoding: 'utf8', timeout: 4000 });
|
|
34
|
+
if (r.status !== 0 || !r.stdout?.trim()) return null;
|
|
35
|
+
try {
|
|
36
|
+
return parseTagsResponse(r.stdout);
|
|
37
|
+
} catch {
|
|
38
|
+
return null;
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function listViaCli() {
|
|
43
|
+
const r = spawnSync('ollama', ['list'], { encoding: 'utf8', timeout: 5000 });
|
|
44
|
+
if (r.status !== 0) return null;
|
|
45
|
+
const names = r.stdout.trim().split('\n').slice(1)
|
|
46
|
+
.map((line) => line.split(/\s+/).filter(Boolean)[0])
|
|
47
|
+
.filter(Boolean);
|
|
48
|
+
return new Set(names);
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export function toOllamaNativeModelId(modelId) {
|
|
52
|
+
if (!modelId || typeof modelId !== 'string') return null;
|
|
53
|
+
return modelId.replace(/^ollama\//, '');
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export function listInstalledOllamaModels({ env = process.env, now = Date.now(), refresh = false } = {}) {
|
|
57
|
+
if (cache.pinned) {
|
|
58
|
+
return { models: cache.models, listable: cache.listable };
|
|
59
|
+
}
|
|
60
|
+
if (!refresh && cache.models && (now - cache.at) < CACHE_MS) {
|
|
61
|
+
return { models: cache.models, listable: cache.listable };
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
const baseUrl = ollamaBaseUrl(env);
|
|
65
|
+
let models = listViaTagsApi(baseUrl);
|
|
66
|
+
if (!models) models = listViaCli();
|
|
67
|
+
|
|
68
|
+
if (!models) {
|
|
69
|
+
cache = { at: now, models: null, listable: false };
|
|
70
|
+
return { models: null, listable: false };
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
cache = { at: now, models, listable: true };
|
|
74
|
+
return { models, listable: true };
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* @returns {boolean|null} true when installed, false when listable and missing, null when unknown
|
|
79
|
+
*/
|
|
80
|
+
export function isOllamaModelInstalled(modelId, opts = {}) {
|
|
81
|
+
const native = toOllamaNativeModelId(modelId);
|
|
82
|
+
if (!native) return null;
|
|
83
|
+
const { models, listable } = listInstalledOllamaModels(opts);
|
|
84
|
+
if (!listable || !models) return null;
|
|
85
|
+
return models.has(native);
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
export function formatOllamaModelMissingMessage(nativeModel, { constructId = null } = {}) {
|
|
89
|
+
const id = nativeModel || toOllamaNativeModelId(constructId) || 'the model';
|
|
90
|
+
return `Ollama model '${id}' is not installed locally. Pull it with: ollama pull ${id} (or: construct ollama pull ${id})`;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
export function resetInstalledOllamaModelsCacheForTests() {
|
|
94
|
+
cache = { at: 0, models: null, listable: false, pinned: false };
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
// Serializes cache injection across parallel test files so a neighbor's reset/probe
|
|
98
|
+
// cannot stomp a pinned list mid-assertion (ubuntu-latest CI flakes).
|
|
99
|
+
let cacheTestTail = Promise.resolve();
|
|
100
|
+
|
|
101
|
+
export function runWithInstalledOllamaCacheForTests(models, fn) {
|
|
102
|
+
const run = cacheTestTail.then(async () => {
|
|
103
|
+
cache = {
|
|
104
|
+
at: Date.now(),
|
|
105
|
+
models: models instanceof Set ? models : new Set(models),
|
|
106
|
+
listable: true,
|
|
107
|
+
pinned: true,
|
|
108
|
+
};
|
|
109
|
+
try {
|
|
110
|
+
return await fn();
|
|
111
|
+
} finally {
|
|
112
|
+
cache = { at: 0, models: null, listable: false, pinned: false };
|
|
113
|
+
}
|
|
114
|
+
});
|
|
115
|
+
cacheTestTail = run.catch(() => {});
|
|
116
|
+
return run;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
// pinned bypasses the TTL so an injected cache survives slow provider probes that
|
|
120
|
+
// run between injection and read; without it the entry can age out mid-test and
|
|
121
|
+
// trigger a real (server-down) probe, flaking availability assertions.
|
|
122
|
+
export function setInstalledOllamaModelsCacheForTests(models, { listable = true, now = Date.now() } = {}) {
|
|
123
|
+
cache = {
|
|
124
|
+
at: now,
|
|
125
|
+
models: models instanceof Set ? models : new Set(models),
|
|
126
|
+
listable,
|
|
127
|
+
pinned: true,
|
|
128
|
+
};
|
|
129
|
+
}
|