carthagent 0.0.0-stage → 1.0.7
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/LICENSE +21 -0
- package/README.md +219 -2
- package/bin/ctg.mjs +736 -0
- package/docs/budgets.md +72 -0
- package/docs/compaction.md +34 -0
- package/docs/context-efficiency-plan.md +24 -0
- package/docs/context-efficiency-review.md +27 -0
- package/docs/context-efficiency.md +49 -0
- package/docs/deepseek-jev-benchmark-plan.md +70 -0
- package/docs/deepseek-jev-benchmark-repair-plan.md +20 -0
- package/docs/deepseek-jev-benchmark-restart.md +9 -0
- package/docs/deepseek-jev-benchmark-results.md +77 -0
- package/docs/deepseek-jev-benchmark-running.md +60 -0
- package/docs/deepseek-jev-benchmark-v3-results.md +41 -0
- package/docs/deepseek-jev-benchmark-v4-protocol.md +15 -0
- package/docs/deepseek-jev-benchmark-v4-results.md +119 -0
- package/docs/deepseek-jev-http400-diagnosis.md +45 -0
- package/docs/delivery-review-evidence.md +53 -0
- package/docs/delivery-timeout-repair-plan.md +51 -0
- package/docs/evaluation-readiness.md +31 -0
- package/docs/evaluation.md +70 -0
- package/docs/extending.md +354 -0
- package/docs/glm-pilot-execution.md +9 -0
- package/docs/glm-pilot-plan.md +15 -0
- package/docs/glm-pilot-results.md +71 -0
- package/docs/graph-context-optimization.md +15 -0
- package/docs/jev-repository-pilot-plan.md +25 -0
- package/docs/jev-repository-pilot-protocol.md +39 -0
- package/docs/jev-repository-pilot-restart.md +7 -0
- package/docs/jev-repository-pilot-results.md +79 -0
- package/docs/jev.md +32 -0
- package/docs/model-registry.md +25 -0
- package/docs/native-compatibility.md +117 -0
- package/docs/opencode-archive.md +9 -0
- package/docs/opencode-migration.md +115 -0
- package/docs/opencode-runtime.md +94 -0
- package/docs/opencode-workspace.md +31 -0
- package/docs/owned-runtime-workflow-registration.md +17 -0
- package/docs/plan-advisor-evaluation.md +38 -0
- package/docs/planning-startup-fix-plan.md +23 -0
- package/docs/planning-startup-fix-review.md +24 -0
- package/docs/planning.md +118 -0
- package/docs/production-merge-review.md +23 -0
- package/docs/quality.md +35 -0
- package/docs/repository-benchmark-design.md +111 -0
- package/docs/review-assurance.md +9 -0
- package/docs/review-experiment-results.md +59 -0
- package/docs/tested-planning-roadmap.md +58 -0
- package/docs/tested-planning.md +58 -0
- package/docs/tui-pickers.md +9 -0
- package/docs/webdriver.md +83 -0
- package/docs/workflow-modes.md +26 -0
- package/docs/workflow.d2 +17 -0
- package/docs/workflow.svg +149 -0
- package/docs/workspace-coordination.md +9 -0
- package/evaluate.mjs +249 -0
- package/extensions/codegraph.ts +36 -0
- package/extensions/delivery.ts +819 -0
- package/extensions/experiential.ts +21 -0
- package/extensions/subscriptions.ts +21 -0
- package/guidance/profiles/authenticated-web.json +24 -0
- package/guidance/profiles/data-visualization.json +25 -0
- package/guidance/profiles/database-change.json +24 -0
- package/guidance/profiles/external-api.json +24 -0
- package/guidance/profiles/transactional-data.json +25 -0
- package/guidance/profiles/web-application.json +26 -0
- package/lib/acceptance-review.mjs +4 -0
- package/lib/browser-bin.mjs +160 -0
- package/lib/browser-errors.mjs +54 -0
- package/lib/browser.mjs +219 -0
- package/lib/budget.mjs +37 -0
- package/lib/cdp.mjs +221 -0
- package/lib/cloud/client.mjs +275 -0
- package/lib/cloud/control-plane.mjs +350 -0
- package/lib/cloud/gateway.mjs +380 -0
- package/lib/cloud/http.mjs +135 -0
- package/lib/cloud/ledger.mjs +215 -0
- package/lib/codegraph-query.mjs +60 -0
- package/lib/codegraph.mjs +378 -0
- package/lib/context-archive.mjs +18 -0
- package/lib/delivery-context.mjs +132 -0
- package/lib/delivery-guidance.mjs +9 -0
- package/lib/delivery-review.mjs +94 -0
- package/lib/delivery.mjs +610 -0
- package/lib/engine.mjs +137 -0
- package/lib/guidance.mjs +71 -0
- package/lib/jev-advisor.mjs +47 -0
- package/lib/opencode/archive.mjs +122 -0
- package/lib/opencode/compatibility.mjs +19 -0
- package/lib/opencode/credentials.mjs +205 -0
- package/lib/opencode/evaluation-budget-plugin/index.ts +147 -0
- package/lib/opencode/evaluation.mjs +242 -0
- package/lib/opencode/file-lock.mjs +86 -0
- package/lib/opencode/headless.mjs +200 -0
- package/lib/opencode/review.mjs +213 -0
- package/lib/opencode/runtime.mjs +246 -0
- package/lib/opencode/service.mjs +249 -0
- package/lib/opencode/session-options.mjs +60 -0
- package/lib/opencode/workspace.mjs +133 -0
- package/lib/openrouter-jev.mjs +37 -0
- package/lib/outcome-contract.mjs +21 -0
- package/lib/plan-advisor.mjs +38 -0
- package/lib/planning-access.mjs +7 -0
- package/lib/planning-advice.mjs +28 -0
- package/lib/planning-discovery.mjs +14 -0
- package/lib/planning-guide.mjs +34 -0
- package/lib/planning-view.mjs +57 -0
- package/lib/planning.mjs +155 -0
- package/lib/probe-diagnostics.mjs +13 -0
- package/lib/providers/experiential.mjs +292 -0
- package/lib/providers/model-catalog.json +700 -0
- package/lib/providers/model-catalog.mjs +7 -0
- package/lib/providers/names.mjs +13 -0
- package/lib/providers/subscription-models.mjs +483 -0
- package/lib/reports.mjs +80 -0
- package/lib/review-state.mjs +49 -0
- package/lib/review.mjs +114 -0
- package/lib/scenarios.mjs +99 -0
- package/lib/ship-auth.mjs +44 -0
- package/lib/ship-jev.mjs +59 -0
- package/lib/ship-telemetry.mjs +183 -0
- package/lib/tui/ansi.mjs +255 -0
- package/lib/tui/app.mjs +2445 -0
- package/lib/tui/auth.mjs +249 -0
- package/lib/tui/carthage.mjs +109 -0
- package/lib/tui/clipboard.mjs +122 -0
- package/lib/tui/d2.mjs +431 -0
- package/lib/tui/feed.mjs +467 -0
- package/lib/tui/framing.mjs +80 -0
- package/lib/tui/glyphs.mjs +172 -0
- package/lib/tui/hash-worker.mjs +11 -0
- package/lib/tui/mock-engine.mjs +222 -0
- package/lib/tui/models.mjs +73 -0
- package/lib/tui/picker.mjs +18 -0
- package/lib/tui/theme.mjs +663 -0
- package/lib/tui/title.mjs +7 -0
- package/lib/update-channel.mjs +62 -0
- package/lib/update.mjs +230 -0
- package/lib/webdriver.mjs +82 -0
- package/lib/workflow.mjs +88 -0
- package/lib/workspace.mjs +39 -0
- package/metadata.json +8 -0
- package/package.json +94 -4
- package/plugins/carthagent/index.ts +3 -0
- package/plugins/carthagent/package.json +28 -0
- package/plugins/carthagent/src/archive-rpc.ts +46 -0
- package/plugins/carthagent/src/bootstrap.ts +38 -0
- package/plugins/carthagent/src/index.ts +62 -0
- package/plugins/carthagent/src/pull-request-rpc.ts +54 -0
- package/plugins/carthagent/src/rpc.ts +76 -0
- package/plugins/carthagent/src/server/archive-rpc.ts +327 -0
- package/plugins/carthagent/src/server/budgets.ts +21 -0
- package/plugins/carthagent/src/server/codegraph.ts +46 -0
- package/plugins/carthagent/src/server/delivery.ts +488 -0
- package/plugins/carthagent/src/server/device-auth.ts +91 -0
- package/plugins/carthagent/src/server/native-delivery.ts +26 -0
- package/plugins/carthagent/src/server/orchestration.ts +56 -0
- package/plugins/carthagent/src/server/policy.ts +24 -0
- package/plugins/carthagent/src/server/providers.ts +424 -0
- package/plugins/carthagent/src/server/pull-request-rpc.ts +387 -0
- package/plugins/carthagent/src/server/session-controls.ts +82 -0
- package/plugins/carthagent/src/server/telemetry.ts +92 -0
- package/plugins/carthagent/src/server/workspace-rpc.ts +349 -0
- package/plugins/carthagent/src/session-rpc.ts +31 -0
- package/plugins/carthagent/src/testing.ts +85 -0
- package/plugins/carthagent/src/workspace-rpc.ts +133 -0
- package/plugins/carthagent/test-plugin/index.ts +4 -0
- package/plugins/carthagent/test-plugin/package.json +1 -0
- package/plugins/carthagent/tsconfig.json +19 -0
- package/plugins/carthagent/tui.ts +1 -0
- package/prompts/delivery.md +7 -0
- package/public/index.html +983 -0
- package/scenarios/cli/grade_cli.py +69 -0
- package/scenarios/cli/scenario.json +21 -0
- package/scenarios/game/baseline/minecraft.py +193 -0
- package/scenarios/game/context.md +1 -0
- package/scenarios/game/grade_minecraft_clone.py +603 -0
- package/scenarios/game/requirements.txt +2 -0
- package/scenarios/game/scenario.json +33 -0
- package/server.mjs +341 -0
- package/skills/game-development/SKILL.md +36 -0
- package/skills/game-development/assets/ursina_starter.py +98 -0
- package/skills/game-development/scripts/verify_ursina.py +80 -0
- package/skills/software-delivery/SKILL.md +50 -0
- package/skills/typescript-delivery/SKILL.md +69 -0
- package/tests/acceptance-review.test.mjs +13 -0
- package/tests/auth.test.mjs +234 -0
- package/tests/bounded-summary.test.mjs +68 -0
- package/tests/browser-bin.test.mjs +91 -0
- package/tests/browser.test.mjs +96 -0
- package/tests/budget-engine.test.mjs +110 -0
- package/tests/budget.test.mjs +23 -0
- package/tests/cli.test.mjs +245 -0
- package/tests/clipboard.test.mjs +79 -0
- package/tests/cloud-client.test.mjs +129 -0
- package/tests/cloud-control-plane.test.mjs +144 -0
- package/tests/cloud-gateway.test.mjs +147 -0
- package/tests/cloud-http.test.mjs +102 -0
- package/tests/cloud-ledger.test.mjs +75 -0
- package/tests/codegraph-query.test.mjs +66 -0
- package/tests/codegraph.test.mjs +142 -0
- package/tests/delivery-context.test.mjs +66 -0
- package/tests/delivery-review.test.mjs +139 -0
- package/tests/delivery.test.mjs +343 -0
- package/tests/engine-api.mjs +24 -0
- package/tests/experiential-extension.test.mjs +37 -0
- package/tests/experiential.test.mjs +300 -0
- package/tests/extension.test.mjs +921 -0
- package/tests/fixtures/archive-plugin/index.ts +10 -0
- package/tests/guidance.test.mjs +78 -0
- package/tests/model-registry.test.mjs +68 -0
- package/tests/models.test.mjs +70 -0
- package/tests/openai-idempotency.test.mjs +99 -0
- package/tests/opencode-archive-rpc.test.mjs +82 -0
- package/tests/opencode-delivery.test.mjs +231 -0
- package/tests/opencode-providers.test.mjs +414 -0
- package/tests/opencode-runtime.test.mjs +455 -0
- package/tests/opencode-session-controls.test.mjs +115 -0
- package/tests/opencode-session-options.test.mjs +26 -0
- package/tests/opencode-workspace.test.mjs +339 -0
- package/tests/outcome-contract.test.mjs +17 -0
- package/tests/picker.test.mjs +34 -0
- package/tests/plan-advisor.test.mjs +88 -0
- package/tests/plan-review-loop.test.mjs +53 -0
- package/tests/planning-advice.test.mjs +27 -0
- package/tests/planning-contract.test.mjs +65 -0
- package/tests/planning-startup.test.mjs +38 -0
- package/tests/planning-view.test.mjs +33 -0
- package/tests/planning.test.mjs +223 -0
- package/tests/probe-diagnostics.test.mjs +21 -0
- package/tests/pull-request-rpc.test.mjs +366 -0
- package/tests/review-state.test.mjs +32 -0
- package/tests/review.test.mjs +127 -0
- package/tests/server.test.mjs +252 -0
- package/tests/ship-jev.test.mjs +77 -0
- package/tests/ship-telemetry.test.mjs +333 -0
- package/tests/standard-workflow.test.mjs +28 -0
- package/tests/subscription-models.test.mjs +416 -0
- package/tests/subscriptions-extension.test.mjs +27 -0
- package/tests/terminal-title.test.mjs +13 -0
- package/tests/test_grade_minecraft_clone.py +199 -0
- package/tests/test_starter.py +78 -0
- package/tests/theme.test.mjs +175 -0
- package/tests/tui-copy-paste.test.mjs +54 -0
- package/tests/tui.test.mjs +808 -0
- package/tests/update-channels.test.mjs +129 -0
- package/tests/update.test.mjs +175 -0
- package/tests/webdriver.test.mjs +31 -0
- package/tests/workflow.test.mjs +47 -0
- package/tests/workspace-rpc.test.mjs +236 -0
- package/tests/workspace.test.mjs +24 -0
- package/tools/benchmark/advisor.test.mjs +7 -0
- package/tools/benchmark/advisor.ts +27 -0
- package/tools/benchmark/campaign.mjs +45 -0
- package/tools/benchmark/diagnose-request.mjs +40 -0
- package/tools/benchmark/eval-common.mjs +32 -0
- package/tools/benchmark/eval-ledger.mjs +22 -0
- package/tools/benchmark/eval-shop.mjs +26 -0
- package/tools/benchmark/eval-voxel.mjs +20 -0
- package/tools/benchmark/evaluate.mjs +24 -0
- package/tools/benchmark/evaluators.test.mjs +13 -0
- package/tools/benchmark/fixtures/shop/server.mjs +18 -0
- package/tools/benchmark/fixtures/shop/ui.html +8 -0
- package/tools/benchmark/freeze.mjs +30 -0
- package/tools/benchmark/gateway.mjs +91 -0
- package/tools/benchmark/gateway.test.mjs +38 -0
- package/tools/benchmark/generator.mjs +11 -0
- package/tools/benchmark/generator.test.mjs +26 -0
- package/tools/benchmark/guard.test.mjs +133 -0
- package/tools/benchmark/guard.ts +136 -0
- package/tools/benchmark/native-plugin/index.ts +24 -0
- package/tools/benchmark/planning-startup.test.mjs +47 -0
- package/tools/benchmark/preflight-live.mjs +31 -0
- package/tools/benchmark/preflight.mjs +22 -0
- package/tools/benchmark/replay-diagnostic.mjs +23 -0
- package/tools/benchmark/runner.mjs +141 -0
- package/tools/benchmark/runner.test.mjs +93 -0
- package/tools/benchmark/runtime.mjs +23 -0
- package/tools/benchmark/selftest.mjs +47 -0
- package/tools/benchmark/smoke-jev.mjs +5 -0
- package/tools/benchmark/tasks.mjs +20 -0
- package/tools/benchmark/transport.mjs +65 -0
- package/tools/benchmark/transport.test.mjs +44 -0
- package/tools/benchmark/upstream-error.mjs +18 -0
- package/tools/benchmark/upstream-error.test.mjs +10 -0
- package/tools/browser-check.mjs +109 -0
- package/tools/check-syntax.mjs +27 -0
- package/tools/evaluate-plan-advisor.mjs +20 -0
- package/tools/native-fixture.mjs +194 -0
- package/tools/native-pty-smoke.py +108 -0
- package/tools/native-report-plugin/index.ts +50 -0
- package/tools/native-report-plugin/package.json +7 -0
- package/tools/patch-compaction.mjs +24 -0
- package/tools/patch-model-catalog.mjs +12 -0
- package/tools/patch-print-shutdown.mjs +39 -0
- package/tools/repo-pilot/evaluator.cjs +32 -0
- package/tools/repo-pilot/reference.mjs +43 -0
- package/tools/repo-pilot/runner.mjs +85 -0
- package/tools/repo-pilot/sandbox-entry.mjs +12 -0
- package/tools/repo-pilot/tasks.mjs +20 -0
- package/tools/repo-pilot/type-evaluator.cjs +6 -0
- package/tools/test-native-controls.mjs +84 -0
- package/tools/test-native-delivery-host.mjs +307 -0
- package/tools/test-native-evaluation.mjs +46 -0
- package/tools/test-native-host.mjs +385 -0
- package/tools/test-native-outcomes.mjs +73 -0
- package/tools/test-native-package.mjs +70 -0
- package/tools/test-native-pty.mjs +20 -0
- package/tools/test-native-report.mjs +88 -0
- package/tools/test-native-swe-host.mjs +132 -0
- package/tools/test-native-swe.mjs +122 -0
- package/tools/test-picker-pty.py +45 -0
- package/tools/test-python.mjs +29 -0
- package/tools/test-webdriver.mjs +54 -0
- package/tools/verify-release-version.mjs +37 -0
- package/tools/webdriver-check.mjs +52 -0
- package/vendor/agent/bounded-summary.mjs +88 -0
- package/vendor/agent/chunks/anthropic-messages-VWZZOSJQ.js +6 -0
- package/vendor/agent/chunks/anthropic.js +75 -0
- package/vendor/agent/chunks/azure-openai-responses-BJLAM6CD.js +2 -0
- package/vendor/agent/chunks/bedrock-converse-stream.js +74 -0
- package/vendor/agent/chunks/chord-context.js +96 -0
- package/vendor/agent/chunks/chunk-4L3WN2XY.js +2 -0
- package/vendor/agent/chunks/chunk-6CYCI3EJ.js +12 -0
- package/vendor/agent/chunks/chunk-6R7UI4IY.js +2 -0
- package/vendor/agent/chunks/chunk-7ZC4UXSL.js +2 -0
- package/vendor/agent/chunks/chunk-AXIIZGTV.js +2 -0
- package/vendor/agent/chunks/chunk-CLNPYIDP.js +42 -0
- package/vendor/agent/chunks/chunk-DRQDKNXA.js +2 -0
- package/vendor/agent/chunks/chunk-GMWCTUPB.js +2 -0
- package/vendor/agent/chunks/chunk-IDDQWTHI.js +2 -0
- package/vendor/agent/chunks/chunk-JVUZSMYM.js +1548 -0
- package/vendor/agent/chunks/chunk-L2ZPNLYD.js +2 -0
- package/vendor/agent/chunks/chunk-NUHFSC37.js +25 -0
- package/vendor/agent/chunks/chunk-OUXLLA64.js +11 -0
- package/vendor/agent/chunks/chunk-PGXDEOVV.js +2 -0
- package/vendor/agent/chunks/chunk-PJACZCIR.js +2 -0
- package/vendor/agent/chunks/chunk-PRRNXB7S.js +2 -0
- package/vendor/agent/chunks/chunk-TEPMHNKQ.js +2 -0
- package/vendor/agent/chunks/chunk-TRDNDS6A.js +4 -0
- package/vendor/agent/chunks/chunk-U6ZMQKGD.js +50 -0
- package/vendor/agent/chunks/chunk-UAQELI3K.js +2 -0
- package/vendor/agent/chunks/chunk-USJZMAIR.js +2 -0
- package/vendor/agent/chunks/chunk-XDUWOHPL.js +6 -0
- package/vendor/agent/chunks/chunk-XNGRGP62.js +2 -0
- package/vendor/agent/chunks/github-copilot.js +2 -0
- package/vendor/agent/chunks/google-generative-ai-Q2K7FBDI.js +2 -0
- package/vendor/agent/chunks/google-vertex-7UJBF4S7.js +2 -0
- package/vendor/agent/chunks/https-proxy-agent-2VXB7436.js +13 -0
- package/vendor/agent/chunks/image-resize-worker.js +2 -0
- package/vendor/agent/chunks/kimi-coding.js +2 -0
- package/vendor/agent/chunks/lib-HGQMNYVT.js +9 -0
- package/vendor/agent/chunks/mistral-conversations-4M6BZBX6.js +5 -0
- package/vendor/agent/chunks/multipart-parser-SQ6GDJL7.js +3 -0
- package/vendor/agent/chunks/node-KG362ECQ.js +16 -0
- package/vendor/agent/chunks/node-TWVVAGPO.js +2 -0
- package/vendor/agent/chunks/openai-codex-responses-R6VYZTVY.js +10 -0
- package/vendor/agent/chunks/openai-codex.js +75 -0
- package/vendor/agent/chunks/openai-completions-EKZT2IH2.js +7 -0
- package/vendor/agent/chunks/openai-responses-TFDINO6W.js +2 -0
- package/vendor/agent/chunks/openrouter-images-N4XVVFSD.js +2 -0
- package/vendor/agent/chunks/openrouter.js +75 -0
- package/vendor/agent/chunks/pi-messages-PML4SPVJ.js +8 -0
- package/vendor/agent/chunks/radius.js +75 -0
- package/vendor/agent/chunks/src-UK77SYZE.js +4 -0
- package/vendor/agent/chunks/xai.js +2 -0
- package/vendor/agent/cli.js +3 -0
- package/vendor/agent/dist/modes/interactive/theme/dark.json +91 -0
- package/vendor/agent/dist/modes/interactive/theme/light.json +90 -0
- package/vendor/agent/dist/modes/interactive/theme/theme-schema.json +356 -0
- package/vendor/agent/index.js +2 -0
- package/vendor/agent/modes/interactive/theme/dark.json +91 -0
- package/vendor/agent/modes/interactive/theme/light.json +90 -0
- package/vendor/agent/modes/interactive/theme/theme-schema.json +356 -0
- package/vendor/agent/package.json +14 -0
- package/vendor/agent/rpc-entry.js +3 -0
- package/vendor/agent/theme/dark.json +91 -0
- package/vendor/agent/theme/light.json +90 -0
- package/vendor/agent/theme/theme-schema.json +356 -0
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
export const CARTHAGENT_SHIP_PROVIDER_ID = 'experiential-labs';
|
|
2
|
+
export const CARTHAGENT_SHIP_PROVIDER_NAME = 'Carthagent Ship';
|
|
3
|
+
|
|
4
|
+
export function providerDisplayName(providerId) {
|
|
5
|
+
return providerId === CARTHAGENT_SHIP_PROVIDER_ID ? CARTHAGENT_SHIP_PROVIDER_NAME : String(providerId || '');
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
export function modelDisplayIdentity(value) {
|
|
9
|
+
const identity = String(value || '');
|
|
10
|
+
const slash = identity.indexOf('/');
|
|
11
|
+
if (slash <= 0) return identity;
|
|
12
|
+
return `${providerDisplayName(identity.slice(0, slash))}/${identity.slice(slash + 1)}`;
|
|
13
|
+
}
|
|
@@ -0,0 +1,483 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Subscription model discovery for the bundled engine's first-party OAuth
|
|
3
|
+
* providers: `anthropic` (Claude Pro/Max) and `openai-codex` (ChatGPT
|
|
4
|
+
* Plus/Pro). Each provider is overlaid with a `refreshModels` hook that asks
|
|
5
|
+
* the subscription's own model endpoint what the account can actually use, so
|
|
6
|
+
* newly released models appear in the picker without a CLI update.
|
|
7
|
+
*
|
|
8
|
+
* The engine's `ModelsImpl.refresh` runs every provider through two phases:
|
|
9
|
+
* an offline phase carrying the stored credential, then — when the refresh
|
|
10
|
+
* allows network — an online phase with the resolved credential. The overlay
|
|
11
|
+
* replaces the whole visible catalog when it returns an array, so each phase
|
|
12
|
+
* rebuilds the discovered list the same way:
|
|
13
|
+
*
|
|
14
|
+
* online — fetch the endpoint, merge discovered ids over the stored
|
|
15
|
+
* catalog, and persist the discovery to a credential-keyed cache.
|
|
16
|
+
* offline — restore the last cached discovery for the same credential so
|
|
17
|
+
* previously discovered models do not vanish between refreshes.
|
|
18
|
+
* A credential that changed since the last discovery (a rotated
|
|
19
|
+
* OAuth refresh token) keeps the cached discovery usable for a
|
|
20
|
+
* bounded migration window.
|
|
21
|
+
*
|
|
22
|
+
* Returning `undefined` leaves the builtin/remote catalog untouched.
|
|
23
|
+
*/
|
|
24
|
+
import { createHash } from 'node:crypto';
|
|
25
|
+
import { mkdir, readFile, rename, rm, writeFile } from 'node:fs/promises';
|
|
26
|
+
import { dirname, join, resolve } from 'node:path';
|
|
27
|
+
import { catalogModels } from './model-catalog.mjs';
|
|
28
|
+
|
|
29
|
+
export const ANTHROPIC_PROVIDER_ID = 'anthropic';
|
|
30
|
+
export const OPENAI_CODEX_PROVIDER_ID = 'openai-codex';
|
|
31
|
+
export const SUBSCRIPTION_PROVIDER_IDS = [ANTHROPIC_PROVIDER_ID, OPENAI_CODEX_PROVIDER_ID];
|
|
32
|
+
|
|
33
|
+
const ANTHROPIC_BASE_URL = 'https://api.anthropic.com';
|
|
34
|
+
const ANTHROPIC_MODELS_PATH = '/v1/models';
|
|
35
|
+
const ANTHROPIC_VERSION = '2023-06-01';
|
|
36
|
+
// Same beta flag the engine sends on OAuth requests; required for
|
|
37
|
+
// subscription tokens on the platform endpoints.
|
|
38
|
+
const ANTHROPIC_OAUTH_BETA = 'oauth-2025-04-20';
|
|
39
|
+
const ANTHROPIC_MAX_PAGES = 5;
|
|
40
|
+
|
|
41
|
+
const CODEX_BASE_URL = 'https://chatgpt.com/backend-api';
|
|
42
|
+
const CODEX_MODELS_PATH = '/codex/models';
|
|
43
|
+
const CODEX_JWT_CLAIM_PATH = 'https://api.openai.com/auth';
|
|
44
|
+
// Matches the engine's codex-responses requests.
|
|
45
|
+
const CODEX_ORIGINATOR = 'pi';
|
|
46
|
+
|
|
47
|
+
const DISCOVERY_TIMEOUT_MS = 10_000;
|
|
48
|
+
const CACHE_FILE_NAME = 'subscription-models.json';
|
|
49
|
+
// How long a discovery cached under a previous credential stays usable after
|
|
50
|
+
// the stored credential changed (rotated OAuth refresh token). Bounds how
|
|
51
|
+
// long an unrelated credential can inherit the previous credential's
|
|
52
|
+
// discovery before the cache is rejected again.
|
|
53
|
+
const CACHE_MIGRATION_GRACE_MS = 24 * 60 * 60 * 1000;
|
|
54
|
+
|
|
55
|
+
function discoverySignal(signal, timeoutMs) {
|
|
56
|
+
const timeout = AbortSignal.timeout(timeoutMs ?? DISCOVERY_TIMEOUT_MS);
|
|
57
|
+
return signal ? AbortSignal.any([signal, timeout]) : timeout;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
async function readJson(response, label) {
|
|
61
|
+
if (!response.ok) {
|
|
62
|
+
const detail = String(await response.text()).trim().slice(0, 240);
|
|
63
|
+
throw new Error(`${label} model discovery failed (${response.status})${detail ? `: ${detail}` : ''}`);
|
|
64
|
+
}
|
|
65
|
+
return response.json();
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function sha256(value) {
|
|
69
|
+
return createHash('sha256').update(String(value)).digest('hex');
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* Stable fingerprint of the credential the discovery belongs to. The cache is
|
|
74
|
+
* only restored for the same account/key, so models from another subscription
|
|
75
|
+
* are never shown. OAuth prefers `accountId`, then the refresh token (stable
|
|
76
|
+
* across access-token rotation); API keys hash the key itself.
|
|
77
|
+
*
|
|
78
|
+
* Rotation of the refresh token changes the key; `matchCachedDiscovery`
|
|
79
|
+
* keeps the previous discovery usable for a bounded migration window so the
|
|
80
|
+
* rotation does not strand it during offline refreshes.
|
|
81
|
+
*/
|
|
82
|
+
export function credentialKey(credential) {
|
|
83
|
+
if (credential?.type === 'oauth') {
|
|
84
|
+
if (typeof credential.accountId === 'string' && credential.accountId) return `oauth:${credential.accountId}`;
|
|
85
|
+
if (typeof credential.refresh === 'string' && credential.refresh) return `oauth:rt:${sha256(credential.refresh)}`;
|
|
86
|
+
if (typeof credential.access === 'string' && credential.access) return `oauth:at:${sha256(credential.access)}`;
|
|
87
|
+
return null;
|
|
88
|
+
}
|
|
89
|
+
if (credential?.type === 'api_key' && typeof credential.key === 'string' && credential.key) {
|
|
90
|
+
return `key:${sha256(credential.key)}`;
|
|
91
|
+
}
|
|
92
|
+
return null;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/** ChatGPT account id is a JWT claim; the stored codex credential also carries it. */
|
|
96
|
+
export function decodeChatGptAccountId(token) {
|
|
97
|
+
try {
|
|
98
|
+
const claims = JSON.parse(Buffer.from(String(token || '').split('.')[1] || '', 'base64url').toString('utf8'));
|
|
99
|
+
const accountId = claims?.[CODEX_JWT_CLAIM_PATH]?.chatgpt_account_id;
|
|
100
|
+
return typeof accountId === 'string' && accountId ? accountId : null;
|
|
101
|
+
} catch {
|
|
102
|
+
return null;
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* GET api.anthropic.com/v1/models — works for both API keys (x-api-key) and
|
|
108
|
+
* Claude subscription OAuth tokens (Bearer + oauth beta).
|
|
109
|
+
*/
|
|
110
|
+
export async function fetchAnthropicSubscriptionModels(credential, { baseUrl = ANTHROPIC_BASE_URL, fetchImpl = globalThis.fetch, signal } = {}) {
|
|
111
|
+
if (typeof fetchImpl !== 'function') throw new Error('Anthropic model discovery requires fetch support.');
|
|
112
|
+
const headers = { accept: 'application/json', 'anthropic-version': ANTHROPIC_VERSION };
|
|
113
|
+
if (credential?.type === 'oauth') {
|
|
114
|
+
headers.authorization = `Bearer ${credential.access}`;
|
|
115
|
+
headers['anthropic-beta'] = ANTHROPIC_OAUTH_BETA;
|
|
116
|
+
} else if (credential?.type === 'api_key') {
|
|
117
|
+
headers['x-api-key'] = credential.key;
|
|
118
|
+
} else {
|
|
119
|
+
return { models: [], hiddenIds: new Set() };
|
|
120
|
+
}
|
|
121
|
+
const models = [];
|
|
122
|
+
let afterId = '';
|
|
123
|
+
for (let page = 0; page < ANTHROPIC_MAX_PAGES; page++) {
|
|
124
|
+
const url = new URL(`${String(baseUrl).replace(/\/+$/, '')}${ANTHROPIC_MODELS_PATH}`);
|
|
125
|
+
url.searchParams.set('limit', '1000');
|
|
126
|
+
if (afterId) url.searchParams.set('after_id', afterId);
|
|
127
|
+
const payload = await readJson(await fetchImpl(url, { headers, signal }), 'Anthropic');
|
|
128
|
+
models.push(...parseAnthropicModels(payload));
|
|
129
|
+
if (!payload?.has_more || typeof payload?.last_id !== 'string' || !payload.last_id) break;
|
|
130
|
+
afterId = payload.last_id;
|
|
131
|
+
}
|
|
132
|
+
return { models, hiddenIds: new Set() };
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
export function parseAnthropicModels(payload) {
|
|
136
|
+
const data = Array.isArray(payload?.data) ? payload.data : [];
|
|
137
|
+
const seen = new Set();
|
|
138
|
+
const models = [];
|
|
139
|
+
for (const entry of data) {
|
|
140
|
+
const id = typeof entry?.id === 'string' ? entry.id.trim() : '';
|
|
141
|
+
if (!id || seen.has(id) || (entry.type !== undefined && entry.type !== 'model')) continue;
|
|
142
|
+
seen.add(id);
|
|
143
|
+
const name = typeof entry.display_name === 'string' && entry.display_name.trim() ? entry.display_name.trim() : id;
|
|
144
|
+
models.push({ id, name });
|
|
145
|
+
}
|
|
146
|
+
return models;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
/**
|
|
150
|
+
* GET chatgpt.com/backend-api/codex/models — the same endpoint the Codex CLI
|
|
151
|
+
* reads for the account's model list. OAuth only.
|
|
152
|
+
*/
|
|
153
|
+
export async function fetchCodexSubscriptionModels(credential, { baseUrl = CODEX_BASE_URL, fetchImpl = globalThis.fetch, signal, clientVersion } = {}) {
|
|
154
|
+
if (credential?.type !== 'oauth' || typeof credential.access !== 'string' || !credential.access) {
|
|
155
|
+
return { models: [], hiddenIds: new Set() };
|
|
156
|
+
}
|
|
157
|
+
if (typeof fetchImpl !== 'function') throw new Error('OpenAI model discovery requires fetch support.');
|
|
158
|
+
const headers = {
|
|
159
|
+
accept: 'application/json',
|
|
160
|
+
authorization: `Bearer ${credential.access}`,
|
|
161
|
+
originator: CODEX_ORIGINATOR,
|
|
162
|
+
};
|
|
163
|
+
const accountId = (typeof credential.accountId === 'string' && credential.accountId) || decodeChatGptAccountId(credential.access);
|
|
164
|
+
if (accountId) headers['chatgpt-account-id'] = accountId;
|
|
165
|
+
const url = new URL(`${String(baseUrl).replace(/\/+$/, '')}${CODEX_MODELS_PATH}`);
|
|
166
|
+
if (clientVersion) url.searchParams.set('client_version', clientVersion);
|
|
167
|
+
const payload = await readJson(await fetchImpl(url, { headers, signal }), 'OpenAI');
|
|
168
|
+
return parseCodexModels(payload);
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
export function parseCodexModels(payload) {
|
|
172
|
+
const entries = Array.isArray(payload?.models) ? payload.models
|
|
173
|
+
: Array.isArray(payload?.data) ? payload.data : [];
|
|
174
|
+
const seen = new Set();
|
|
175
|
+
const hiddenIds = new Set();
|
|
176
|
+
const models = [];
|
|
177
|
+
for (const entry of entries) {
|
|
178
|
+
const rawId = typeof entry === 'string' ? entry : entry?.slug ?? entry?.id;
|
|
179
|
+
const id = typeof rawId === 'string' ? rawId.trim() : '';
|
|
180
|
+
if (!id || seen.has(id)) continue;
|
|
181
|
+
seen.add(id);
|
|
182
|
+
if (entry && typeof entry === 'object' && entry.visibility === 'hide') {
|
|
183
|
+
hiddenIds.add(id);
|
|
184
|
+
continue;
|
|
185
|
+
}
|
|
186
|
+
const display = typeof entry === 'object' ? entry.display_name ?? entry.name : undefined;
|
|
187
|
+
const levels = Array.isArray(entry?.supported_reasoning_levels)
|
|
188
|
+
? entry.supported_reasoning_levels.filter(level => typeof level === 'string')
|
|
189
|
+
: undefined;
|
|
190
|
+
models.push({ id, name: typeof display === 'string' && display.trim() ? display.trim() : id, reasoningLevels: levels });
|
|
191
|
+
}
|
|
192
|
+
return { models, hiddenIds };
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
/** Engine thinking levels derived from the codex endpoint's supported_reasoning_levels. */
|
|
196
|
+
export function codexThinkingLevelMap(levels) {
|
|
197
|
+
if (!Array.isArray(levels) || !levels.length) return undefined;
|
|
198
|
+
const supported = new Set(levels);
|
|
199
|
+
const map = { off: supported.has('none') || supported.has('off') ? 'none' : null };
|
|
200
|
+
for (const level of ['minimal', 'low', 'medium', 'high', 'xhigh', 'max']) {
|
|
201
|
+
map[level] = supported.has(level) ? level : null;
|
|
202
|
+
}
|
|
203
|
+
return map;
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
/**
|
|
207
|
+
* Full model definition for a discovered id. `base` is the richest definition
|
|
208
|
+
* Carthagent knows for this id (owned catalog first, then persisted metadata),
|
|
209
|
+
* so a discovered model keeps its real cost,
|
|
210
|
+
* context window, and thinking levels instead of the conservative fallbacks.
|
|
211
|
+
*/
|
|
212
|
+
export function anthropicModelDefinition(model, base) {
|
|
213
|
+
return {
|
|
214
|
+
api: 'anthropic-messages',
|
|
215
|
+
baseUrl: ANTHROPIC_BASE_URL,
|
|
216
|
+
reasoning: true,
|
|
217
|
+
input: ['text', 'image'],
|
|
218
|
+
contextWindow: 200_000,
|
|
219
|
+
maxTokens: 64_000,
|
|
220
|
+
compat: { supportsStrictTools: true },
|
|
221
|
+
...base,
|
|
222
|
+
id: model.id,
|
|
223
|
+
name: base?.name ?? model.name ?? model.id,
|
|
224
|
+
};
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
export function codexModelDefinition(model, base) {
|
|
228
|
+
const definition = {
|
|
229
|
+
api: 'openai-codex-responses',
|
|
230
|
+
baseUrl: CODEX_BASE_URL,
|
|
231
|
+
reasoning: true,
|
|
232
|
+
input: ['text', 'image'],
|
|
233
|
+
contextWindow: 272_000,
|
|
234
|
+
maxTokens: 128_000,
|
|
235
|
+
compat: { supportsOpenAIGrammarTools: true },
|
|
236
|
+
thinkingLevelMap: codexThinkingLevelMap(model.reasoningLevels),
|
|
237
|
+
...base,
|
|
238
|
+
id: model.id,
|
|
239
|
+
name: base?.name ?? model.name ?? model.id,
|
|
240
|
+
};
|
|
241
|
+
if (!definition.thinkingLevelMap) delete definition.thinkingLevelMap;
|
|
242
|
+
return definition;
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
/**
|
|
246
|
+
* Discovered models first (the subscription's authoritative order), then any
|
|
247
|
+
* stored catalog entries the endpoint did not return so aliases and remote
|
|
248
|
+
* catalog extras stay visible. `excludeIds` drops stored entries the endpoint
|
|
249
|
+
* now reports hidden.
|
|
250
|
+
*/
|
|
251
|
+
export function mergeDiscoveredModels(storedModels, discovered, { excludeIds } = {}) {
|
|
252
|
+
const merged = [...discovered];
|
|
253
|
+
const seen = new Set(discovered.map(model => model.id));
|
|
254
|
+
for (const model of Array.isArray(storedModels) ? storedModels : []) {
|
|
255
|
+
if (model && typeof model.id === 'string' && !seen.has(model.id) && !excludeIds?.has(model.id)) {
|
|
256
|
+
seen.add(model.id);
|
|
257
|
+
merged.push(model);
|
|
258
|
+
}
|
|
259
|
+
}
|
|
260
|
+
return merged;
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
/**
|
|
264
|
+
* One discovery epoch: the credential it was made under, when, and what the
|
|
265
|
+
* endpoint returned. Epochs with a changed credential may still be served
|
|
266
|
+
* within the migration window (see `matchCachedDiscovery`).
|
|
267
|
+
*/
|
|
268
|
+
function asDiscoveryEpoch(value) {
|
|
269
|
+
if (!value || typeof value !== 'object' || Array.isArray(value)) return null;
|
|
270
|
+
if (typeof value.credential !== 'string' || !value.credential) return null;
|
|
271
|
+
if (!Array.isArray(value.discovered) || !value.discovered.length) return null;
|
|
272
|
+
return value;
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
function epochIsFresh(epoch, now, graceMs) {
|
|
276
|
+
return graceMs > 0
|
|
277
|
+
&& typeof epoch.checkedAt === 'number'
|
|
278
|
+
&& Number.isFinite(epoch.checkedAt)
|
|
279
|
+
&& now - epoch.checkedAt <= graceMs;
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
/**
|
|
283
|
+
* Resolve the discovery epoch a credential may reuse from a cache entry.
|
|
284
|
+
*
|
|
285
|
+
* An exact credential match always wins, regardless of age. Otherwise the
|
|
286
|
+
* stored credential changed without an intervening successful discovery — a
|
|
287
|
+
* rotated refresh token is the common cause — and the last discovery stays
|
|
288
|
+
* usable for a bounded migration window: either because a later discovery
|
|
289
|
+
* under the rotated credential demoted it to `previous`, or via the primary
|
|
290
|
+
* epoch itself. The window bounds how long an unrelated credential (a
|
|
291
|
+
* different account signed in before its own online discovery) can inherit
|
|
292
|
+
* the cached models; once it lapses the cache is rejected and the catalog
|
|
293
|
+
* falls back to stored/builtin entries only.
|
|
294
|
+
*/
|
|
295
|
+
export function matchCachedDiscovery(entry, key, { now = Date.now(), graceMs = CACHE_MIGRATION_GRACE_MS } = {}) {
|
|
296
|
+
const primary = asDiscoveryEpoch(entry);
|
|
297
|
+
const previous = asDiscoveryEpoch(entry?.previous);
|
|
298
|
+
if (previous && previous.credential === key && epochIsFresh(previous, now, graceMs)) return previous;
|
|
299
|
+
if (primary && entry.credential === key) return primary;
|
|
300
|
+
if (primary && epochIsFresh(primary, now, graceMs)) return primary;
|
|
301
|
+
return null;
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
/**
|
|
305
|
+
* Lineage carried into the next cache write. When the credential changed
|
|
306
|
+
* since the last successful discovery (rotated refresh token), the current
|
|
307
|
+
* epoch is demoted to `previous` so `matchCachedDiscovery` can still serve it
|
|
308
|
+
* after the rotation; a same-credential re-discovery keeps any existing
|
|
309
|
+
* lineage. Lineage is one generation deep.
|
|
310
|
+
*/
|
|
311
|
+
function previousDiscoveryEpoch(current, key) {
|
|
312
|
+
const epoch = asDiscoveryEpoch(current);
|
|
313
|
+
if (!epoch) return undefined;
|
|
314
|
+
if (epoch.credential === key) return asDiscoveryEpoch(epoch.previous) ?? undefined;
|
|
315
|
+
return {
|
|
316
|
+
credential: epoch.credential,
|
|
317
|
+
checkedAt: epoch.checkedAt,
|
|
318
|
+
discovered: epoch.discovered,
|
|
319
|
+
hiddenIds: epoch.hiddenIds,
|
|
320
|
+
};
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
// Read-modify-write sections per cache file, shared by every cache instance
|
|
324
|
+
// in this process. The engine refreshes provider overlays concurrently, and
|
|
325
|
+
// each write replaces the whole file: without serialization two refreshes can
|
|
326
|
+
// both read the same snapshot and the later write erases the earlier
|
|
327
|
+
// provider's entry. Writes go through a temp file + rename, so readers — and
|
|
328
|
+
// other processes sharing the agent directory — never observe a torn file.
|
|
329
|
+
const cacheWriteQueues = new Map();
|
|
330
|
+
|
|
331
|
+
function serializeCacheWrite(file, operation) {
|
|
332
|
+
const queueKey = resolve(file);
|
|
333
|
+
const previous = cacheWriteQueues.get(queueKey) ?? Promise.resolve();
|
|
334
|
+
const next = previous.then(operation, operation);
|
|
335
|
+
cacheWriteQueues.set(queueKey, next.catch(() => {}));
|
|
336
|
+
return next;
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
async function writeJsonAtomically(file, contents) {
|
|
340
|
+
await mkdir(dirname(file), { recursive: true });
|
|
341
|
+
const temp = `${file}.${process.pid}-${Date.now()}.tmp`;
|
|
342
|
+
try {
|
|
343
|
+
await writeFile(temp, contents);
|
|
344
|
+
await rename(temp, file);
|
|
345
|
+
} catch (error) {
|
|
346
|
+
await rm(temp, { force: true }).catch(() => {});
|
|
347
|
+
throw error;
|
|
348
|
+
}
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
/** File-backed discovery cache, one entry per provider keyed by credential. */
|
|
352
|
+
export function fileSubscriptionCache(agentDir) {
|
|
353
|
+
if (!agentDir) return undefined;
|
|
354
|
+
const file = join(agentDir, CACHE_FILE_NAME);
|
|
355
|
+
const readAll = async () => {
|
|
356
|
+
try {
|
|
357
|
+
const parsed = JSON.parse(await readFile(file, 'utf8'));
|
|
358
|
+
return parsed && typeof parsed === 'object' && !Array.isArray(parsed) ? parsed : {};
|
|
359
|
+
} catch {
|
|
360
|
+
return {};
|
|
361
|
+
}
|
|
362
|
+
};
|
|
363
|
+
return {
|
|
364
|
+
file,
|
|
365
|
+
async read(providerId) {
|
|
366
|
+
const entry = (await readAll())[providerId];
|
|
367
|
+
return entry && typeof entry === 'object' ? entry : null;
|
|
368
|
+
},
|
|
369
|
+
async write(providerId, entry) {
|
|
370
|
+
return serializeCacheWrite(file, async () => {
|
|
371
|
+
const all = await readAll();
|
|
372
|
+
all[providerId] = entry;
|
|
373
|
+
await writeJsonAtomically(file, JSON.stringify(all, null, 2));
|
|
374
|
+
});
|
|
375
|
+
},
|
|
376
|
+
};
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
async function knownModelsById(providerId, knownModels) {
|
|
380
|
+
const loader = knownModels ?? catalogModels;
|
|
381
|
+
const entries = await loader(providerId);
|
|
382
|
+
return new Map((Array.isArray(entries) ? entries : []).filter(model => model?.id).map(model => [model.id, model]));
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
function envApiKeyCredential(name) {
|
|
386
|
+
const key = process.env[name];
|
|
387
|
+
return typeof key === 'string' && key.trim() ? { type: 'api_key', key: key.trim() } : undefined;
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
async function subscriptionModelRefresh(context, { providerId, fetchModels, toDefinition, knownModels, cache, cacheGraceMs, offlineCredential }) {
|
|
391
|
+
if (context?.signal?.aborted) return undefined;
|
|
392
|
+
// Offline refreshes only carry the stored credential; env API keys reach the
|
|
393
|
+
// hook through `offlineCredential` so the cache can still be keyed to them.
|
|
394
|
+
const credential = context.credential ?? (context.allowNetwork ? undefined : offlineCredential?.());
|
|
395
|
+
const key = credentialKey(credential);
|
|
396
|
+
if (!key) return undefined;
|
|
397
|
+
const stored = Array.isArray(context.stored?.models) ? context.stored.models : [];
|
|
398
|
+
const storedById = new Map(stored.filter(model => model?.id).map(model => [model.id, model]));
|
|
399
|
+
|
|
400
|
+
if (!context.allowNetwork) {
|
|
401
|
+
const cached = await cache?.read(providerId).catch(() => null);
|
|
402
|
+
const epoch = matchCachedDiscovery(cached, key, { graceMs: cacheGraceMs });
|
|
403
|
+
if (!epoch) return undefined;
|
|
404
|
+
const knownById = await knownModelsById(providerId, knownModels);
|
|
405
|
+
const defs = epoch.discovered.map(model => toDefinition(model, knownById.get(model.id) ?? storedById.get(model.id)));
|
|
406
|
+
return mergeDiscoveredModels(stored, defs, { excludeIds: new Set(epoch.hiddenIds ?? []) });
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
const result = await fetchModels(credential);
|
|
410
|
+
const discovered = Array.isArray(result?.models) ? result.models : [];
|
|
411
|
+
if (context.signal?.aborted || !discovered.length) return undefined;
|
|
412
|
+
const hiddenIds = result.hiddenIds instanceof Set ? result.hiddenIds : new Set(result.hiddenIds ?? []);
|
|
413
|
+
const knownById = await knownModelsById(providerId, knownModels);
|
|
414
|
+
const defs = discovered.map(model => toDefinition(model, knownById.get(model.id) ?? storedById.get(model.id)));
|
|
415
|
+
const previous = previousDiscoveryEpoch(await cache?.read(providerId).catch(() => null), key);
|
|
416
|
+
await cache?.write(providerId, {
|
|
417
|
+
credential: key,
|
|
418
|
+
checkedAt: Date.now(),
|
|
419
|
+
discovered,
|
|
420
|
+
hiddenIds: [...hiddenIds],
|
|
421
|
+
previous,
|
|
422
|
+
}).catch(() => {});
|
|
423
|
+
return mergeDiscoveredModels(stored, defs, { excludeIds: hiddenIds });
|
|
424
|
+
}
|
|
425
|
+
|
|
426
|
+
/** Overlay config for the builtin `anthropic` provider (subscription + API key). */
|
|
427
|
+
export function anthropicDiscoveryProviderConfig({ baseUrl = ANTHROPIC_BASE_URL, fetchImpl = globalThis.fetch, timeoutMs, knownModels, cache, cacheGraceMs } = {}) {
|
|
428
|
+
return {
|
|
429
|
+
api: 'anthropic-messages',
|
|
430
|
+
baseUrl,
|
|
431
|
+
models: catalogModels(ANTHROPIC_PROVIDER_ID),
|
|
432
|
+
refreshModels: context => subscriptionModelRefresh(context, {
|
|
433
|
+
providerId: ANTHROPIC_PROVIDER_ID,
|
|
434
|
+
fetchModels: credential => fetchAnthropicSubscriptionModels(credential, {
|
|
435
|
+
baseUrl,
|
|
436
|
+
fetchImpl,
|
|
437
|
+
signal: discoverySignal(context.signal, timeoutMs),
|
|
438
|
+
}),
|
|
439
|
+
toDefinition: anthropicModelDefinition,
|
|
440
|
+
knownModels,
|
|
441
|
+
cache,
|
|
442
|
+
cacheGraceMs,
|
|
443
|
+
offlineCredential: () => envApiKeyCredential('ANTHROPIC_API_KEY'),
|
|
444
|
+
}),
|
|
445
|
+
};
|
|
446
|
+
}
|
|
447
|
+
|
|
448
|
+
/** Overlay config for the builtin `openai-codex` provider (ChatGPT subscription). */
|
|
449
|
+
export function codexDiscoveryProviderConfig({ baseUrl = CODEX_BASE_URL, fetchImpl = globalThis.fetch, timeoutMs, clientVersion, knownModels, cache, cacheGraceMs } = {}) {
|
|
450
|
+
return {
|
|
451
|
+
api: 'openai-codex-responses',
|
|
452
|
+
baseUrl,
|
|
453
|
+
models: catalogModels(OPENAI_CODEX_PROVIDER_ID),
|
|
454
|
+
refreshModels: context => subscriptionModelRefresh(context, {
|
|
455
|
+
providerId: OPENAI_CODEX_PROVIDER_ID,
|
|
456
|
+
fetchModels: credential => fetchCodexSubscriptionModels(credential, {
|
|
457
|
+
baseUrl,
|
|
458
|
+
fetchImpl,
|
|
459
|
+
clientVersion,
|
|
460
|
+
signal: discoverySignal(context.signal, timeoutMs),
|
|
461
|
+
}),
|
|
462
|
+
toDefinition: codexModelDefinition,
|
|
463
|
+
knownModels,
|
|
464
|
+
cache,
|
|
465
|
+
cacheGraceMs,
|
|
466
|
+
}),
|
|
467
|
+
};
|
|
468
|
+
}
|
|
469
|
+
|
|
470
|
+
/**
|
|
471
|
+
* Register both overlays on a ModelRuntime (or the extension API, which
|
|
472
|
+
* exposes the same registerProvider signature). `agentDir` enables the
|
|
473
|
+
* credential-keyed on-disk cache used to restore discovered models during
|
|
474
|
+
* offline refreshes; `cacheGraceMs` bounds how long a discovery stays usable
|
|
475
|
+
* after its credential changed (defaults to one day).
|
|
476
|
+
*/
|
|
477
|
+
export function registerSubscriptionProviders(runtime, options = {}) {
|
|
478
|
+
if (!runtime || typeof runtime.registerProvider !== 'function') throw new TypeError('A ModelRuntime is required.');
|
|
479
|
+
const shared = { ...options, cache: options.cache ?? fileSubscriptionCache(options.agentDir) };
|
|
480
|
+
runtime.registerProvider(ANTHROPIC_PROVIDER_ID, anthropicDiscoveryProviderConfig(shared));
|
|
481
|
+
runtime.registerProvider(OPENAI_CODEX_PROVIDER_ID, codexDiscoveryProviderConfig(shared));
|
|
482
|
+
return runtime;
|
|
483
|
+
}
|
package/lib/reports.mjs
ADDED
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
import { existsSync, mkdirSync, readdirSync, readFileSync, statSync, writeFileSync, openSync, closeSync, unlinkSync } from 'node:fs';
|
|
2
|
+
import { dirname, join } from 'node:path';
|
|
3
|
+
import { randomUUID } from 'node:crypto';
|
|
4
|
+
import { atomicJson, planD2WithProgress } from './delivery.mjs';
|
|
5
|
+
|
|
6
|
+
import { activeReportPath } from './workspace.mjs';
|
|
7
|
+
|
|
8
|
+
export function latestReport(cwd) {
|
|
9
|
+
const selected = activeReportPath(cwd);
|
|
10
|
+
if (selected) return { path: selected, dir: dirname(selected), mtimeMs: statSync(selected).mtimeMs, state: JSON.parse(readFileSync(selected, 'utf8')) };
|
|
11
|
+
const harness = join(cwd, '.harness');
|
|
12
|
+
if (!existsSync(harness)) return null;
|
|
13
|
+
let latest = null;
|
|
14
|
+
for (const session of safeDirectories(harness)) {
|
|
15
|
+
for (const run of safeDirectories(join(harness, session))) {
|
|
16
|
+
const path = join(harness, session, run, 'report.json');
|
|
17
|
+
try {
|
|
18
|
+
const mtimeMs = statSync(path).mtimeMs;
|
|
19
|
+
if (!latest || mtimeMs >= latest.mtimeMs) latest = { path, dir: dirname(path), mtimeMs };
|
|
20
|
+
} catch { }
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
if (!latest) return null;
|
|
24
|
+
try { return { ...latest, state: JSON.parse(readFileSync(latest.path, 'utf8')) }; }
|
|
25
|
+
catch { return null; }
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export function createReport(cwd, state, owner = 'web') {
|
|
29
|
+
const runId = state.runId || randomUUID();
|
|
30
|
+
const dir = join(cwd, '.harness', owner, runId);
|
|
31
|
+
mkdirSync(dir, { recursive: true });
|
|
32
|
+
const report = { ...state, runId };
|
|
33
|
+
const path = join(dir, 'report.json');
|
|
34
|
+
saveReport(path, report);
|
|
35
|
+
return { path, dir, state: report, mtimeMs: statSync(path).mtimeMs };
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
// Reconcile only a restored snapshot of this exact run. Never select by mtime.
|
|
39
|
+
export function reconcileReport(path, state) {
|
|
40
|
+
let current;
|
|
41
|
+
try { current = JSON.parse(readFileSync(path, 'utf8')); }
|
|
42
|
+
catch (error) { if (error.code === 'ENOENT') return state; throw error; }
|
|
43
|
+
if (!state?.runId || current.runId !== state.runId) throw Error(`Report identity mismatch: ${path}`);
|
|
44
|
+
const saved = current.storageVersion ?? 0;
|
|
45
|
+
const restored = state.storageVersion ?? 0;
|
|
46
|
+
if (!Number.isSafeInteger(saved) || saved < 0 || !Number.isSafeInteger(restored) || restored < 0) throw Error(`Invalid report storage version: ${path}`);
|
|
47
|
+
if (saved < restored) throw Error(`Report storage regressed: ${path}`);
|
|
48
|
+
return saved > restored ? current : state;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export function saveReport(path, state) {
|
|
52
|
+
mkdirSync(dirname(path), { recursive: true });
|
|
53
|
+
const lock = `${path}.lock`;
|
|
54
|
+
let fd;
|
|
55
|
+
try { fd = openSync(lock, 'wx'); }
|
|
56
|
+
catch (error) {
|
|
57
|
+
if (error.code === 'EEXIST') throw Error(`Report is locked: ${path}. Retry after the writer finishes; remove the lock only after confirming no writer is running.`);
|
|
58
|
+
throw error;
|
|
59
|
+
}
|
|
60
|
+
try {
|
|
61
|
+
const current = existsSync(path) ? JSON.parse(readFileSync(path, 'utf8')) : null;
|
|
62
|
+
const expected = state.storageVersion ?? 0;
|
|
63
|
+
if ((current?.storageVersion ?? 0) !== expected) {
|
|
64
|
+
throw Error(`Report write conflict: ${path}. Reload the current report before retrying.`);
|
|
65
|
+
}
|
|
66
|
+
const next = { ...state, storageVersion: expected + 1 };
|
|
67
|
+
const d2 = state.plan ? planD2WithProgress(state.plan, state.stepStatus || {}) : null;
|
|
68
|
+
atomicJson(path, next);
|
|
69
|
+
state.storageVersion = next.storageVersion;
|
|
70
|
+
if (d2 !== null) writeFileSync(join(dirname(path), 'plan.d2'), d2);
|
|
71
|
+
} finally {
|
|
72
|
+
closeSync(fd);
|
|
73
|
+
unlinkSync(lock);
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
function safeDirectories(path) {
|
|
78
|
+
try { return readdirSync(path, { withFileTypes: true }).filter(entry => entry.isDirectory()).map(entry => entry.name); }
|
|
79
|
+
catch { return []; }
|
|
80
|
+
}
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
import { mkdirSync, openSync, closeSync, unlinkSync, readFileSync } from 'node:fs';
|
|
2
|
+
import { join } from 'node:path';
|
|
3
|
+
import { createHash } from 'node:crypto';
|
|
4
|
+
import { atomicJson } from './delivery.mjs';
|
|
5
|
+
|
|
6
|
+
// Local cooperative orchestration, not protection against edits to .harness.
|
|
7
|
+
export function beginReview(cwd, identity, snapshot, head) {
|
|
8
|
+
const dir = join(cwd, '.harness', 'reviews');
|
|
9
|
+
mkdirSync(dir, { recursive: true });
|
|
10
|
+
const path = join(dir, `${createHash('sha256').update(identity).digest('hex')}.json`);
|
|
11
|
+
const lock = `${path}.lock`;
|
|
12
|
+
let fd;
|
|
13
|
+
try { fd = openSync(lock, 'wx'); }
|
|
14
|
+
catch (e) { if (e.code === 'EEXIST') throw Error('Review already running or interrupted; inspect the review lock before manual recovery'); throw e; }
|
|
15
|
+
const release = () => { closeSync(fd); unlinkSync(lock); };
|
|
16
|
+
try {
|
|
17
|
+
let state;
|
|
18
|
+
try { state = JSON.parse(readFileSync(path, 'utf8')); }
|
|
19
|
+
catch (e) { if (e.code !== 'ENOENT') throw e; }
|
|
20
|
+
if (state && (state.version !== 1 || state.identity !== identity || !Number.isInteger(state.rounds) || state.rounds < 1 || state.rounds > 3 || !Array.isArray(state.history) || !['running', 'approved', 'changes-requested', 'inconclusive', 'escalated'].includes(state.status))) throw Error('Invalid persisted review state');
|
|
21
|
+
if (state?.status === 'running') throw Error('Interrupted review requires manual recovery; no automatic retry');
|
|
22
|
+
if (state?.status === 'approved' && state.snapshot === snapshot && state.head === head) throw Error('Snapshot already approved; no reviewer launched');
|
|
23
|
+
if ((state?.rounds || 0) >= 3) throw Error('Review round limit reached; escalate remaining findings to the user');
|
|
24
|
+
state = { version: 1, identity, rounds: (state?.rounds || 0) + 1, status: 'running', snapshot, head, history: state?.history || [] };
|
|
25
|
+
atomicJson(path, state);
|
|
26
|
+
return {
|
|
27
|
+
finish(status, details) {
|
|
28
|
+
state.status = state.rounds === 3 && status !== 'approved' ? 'escalated' : status;
|
|
29
|
+
state.history.push({ round: state.rounds, snapshot, head, status: state.status, details });
|
|
30
|
+
atomicJson(path, state);
|
|
31
|
+
},
|
|
32
|
+
release,
|
|
33
|
+
};
|
|
34
|
+
} catch (e) { release(); throw e; }
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export function parseFindings(output, snapshot, head, verdict) {
|
|
38
|
+
const lines = String(output).trim().split(/\r?\n/);
|
|
39
|
+
const line = lines.at(-2);
|
|
40
|
+
if (!line?.startsWith('REVIEW_JSON: ')) throw Error('Missing structured REVIEW_JSON findings');
|
|
41
|
+
let data;
|
|
42
|
+
try { data = JSON.parse(line.slice(13)); } catch { throw Error('Invalid REVIEW_JSON'); }
|
|
43
|
+
if (!data || data.version !== 1 || data.snapshot !== snapshot || data.head !== head || !Array.isArray(data.findings) || data.findings.length > 100) throw Error('Invalid or stale structured review identity');
|
|
44
|
+
for (const f of data.findings) {
|
|
45
|
+
if (!f || typeof f.file !== 'string' || !f.file.trim() || !Number.isInteger(f.line) || f.line < 1 || !['blocking', 'nonblocking'].includes(f.severity) || typeof f.issue !== 'string' || !f.issue.trim() || typeof f.fix !== 'string' || !f.fix.trim()) throw Error('Malformed structured finding');
|
|
46
|
+
}
|
|
47
|
+
if ((verdict === 'CHANGES-REQUESTED') !== data.findings.some(f => f.severity === 'blocking')) throw Error('Verdict contradicts structured findings');
|
|
48
|
+
return data;
|
|
49
|
+
}
|