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,46 @@
|
|
|
1
|
+
import { Rpc } from '@opencode/plugin/rpc';
|
|
2
|
+
|
|
3
|
+
const id = { type: 'string', minLength: 1, maxLength: 128 } as const;
|
|
4
|
+
const page = { type: 'integer', minimum: 0, maximum: 1_000_000 } as const;
|
|
5
|
+
const limit = { type: 'integer', minimum: 1, maximum: 100 } as const;
|
|
6
|
+
const item = {
|
|
7
|
+
type: 'object', properties: {
|
|
8
|
+
id, createdAt: {}, messageCount: { type: 'integer', minimum: 0 }, malformedLines: { type: 'array', items: { type: 'object', properties: { line: { type: 'integer', minimum: 1 } }, required: ['line'], additionalProperties: false } }, moreMalformedLines: { type: 'boolean' },
|
|
9
|
+
excerpt: { type: 'string' }, archiveOnly: { type: 'boolean', const: true },
|
|
10
|
+
}, required: ['id', 'messageCount', 'malformedLines', 'moreMalformedLines', 'archiveOnly'], additionalProperties: false,
|
|
11
|
+
} as const;
|
|
12
|
+
const errors = {
|
|
13
|
+
archive_error: {
|
|
14
|
+
type: 'object', properties: {
|
|
15
|
+
code: { type: 'string', enum: ['invalid_request', 'not_found', 'limit_exceeded', 'archive_io'] },
|
|
16
|
+
}, required: ['code'], additionalProperties: false,
|
|
17
|
+
},
|
|
18
|
+
} as const;
|
|
19
|
+
|
|
20
|
+
/** Opaque-ID-only contract. Clients never provide filesystem paths. */
|
|
21
|
+
export const Archive = Rpc.define({
|
|
22
|
+
id: 'carthagent.archive',
|
|
23
|
+
methods: {
|
|
24
|
+
list: {
|
|
25
|
+
errors,
|
|
26
|
+
input: { type: 'object', properties: { offset: page, limit }, additionalProperties: false },
|
|
27
|
+
output: { type: 'object', properties: { items: { type: 'array', items: item }, total: { type: 'integer', minimum: 0 }, offset: page, limit }, required: ['items', 'total', 'offset', 'limit'], additionalProperties: false },
|
|
28
|
+
},
|
|
29
|
+
search: {
|
|
30
|
+
errors,
|
|
31
|
+
input: { type: 'object', properties: { query: { type: 'string', minLength: 1, maxLength: 200 }, offset: page, limit }, required: ['query'], additionalProperties: false },
|
|
32
|
+
output: { type: 'object', properties: { items: { type: 'array', items: item }, total: { type: 'integer', minimum: 0 }, offset: page, limit }, required: ['items', 'total', 'offset', 'limit'], additionalProperties: false },
|
|
33
|
+
},
|
|
34
|
+
read: {
|
|
35
|
+
errors,
|
|
36
|
+
input: { type: 'object', properties: { id }, required: ['id'], additionalProperties: false },
|
|
37
|
+
output: { type: 'object', properties: { id, records: { type: 'array', items: {} }, malformedLines: item.properties.malformedLines, moreMalformedLines: { type: 'boolean' }, archiveOnly: { type: 'boolean', const: true } }, required: ['id', 'records', 'malformedLines', 'moreMalformedLines', 'archiveOnly'], additionalProperties: false },
|
|
38
|
+
},
|
|
39
|
+
export: {
|
|
40
|
+
errors,
|
|
41
|
+
input: { type: 'object', properties: { id, format: { type: 'string', enum: ['json', 'jsonl'] } }, required: ['id'], additionalProperties: false },
|
|
42
|
+
output: { type: 'object', properties: { id, format: { type: 'string', enum: ['json', 'jsonl'] }, content: { type: 'string', maxLength: 5_000_000 }, archiveOnly: { type: 'boolean', const: true } }, required: ['id', 'format', 'content', 'archiveOnly'], additionalProperties: false },
|
|
43
|
+
},
|
|
44
|
+
},
|
|
45
|
+
events: {},
|
|
46
|
+
});
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import { Plugin } from '@opencode/plugin/tui';
|
|
2
|
+
import { Carthagent } from './rpc';
|
|
3
|
+
|
|
4
|
+
/** Foundation diagnostic; replaced by the full UI entrypoint at integration. */
|
|
5
|
+
export default Plugin.define({
|
|
6
|
+
id: 'carthagent.tui',
|
|
7
|
+
setup(context) {
|
|
8
|
+
context.keymap.layer(() => ({
|
|
9
|
+
mode: 'global',
|
|
10
|
+
commands: [{
|
|
11
|
+
id: 'carthagent.health',
|
|
12
|
+
title: 'Carthagent runtime status',
|
|
13
|
+
palette: true,
|
|
14
|
+
slash: { name: 'ctg-status' },
|
|
15
|
+
async run() {
|
|
16
|
+
try {
|
|
17
|
+
const result = await context.client.rpc(Carthagent).health({}, {
|
|
18
|
+
location: context.location ?? context.data.location.default(),
|
|
19
|
+
signal: AbortSignal.timeout(10_000),
|
|
20
|
+
});
|
|
21
|
+
const health = result as { hostVersion: string; directory: string };
|
|
22
|
+
context.ui.toast.show({
|
|
23
|
+
title: 'Carthagent',
|
|
24
|
+
message: `OpenCode ${health.hostVersion} · ${context.ui.format.path(health.directory)}`,
|
|
25
|
+
variant: 'success',
|
|
26
|
+
});
|
|
27
|
+
} catch (error) {
|
|
28
|
+
context.ui.toast.show({
|
|
29
|
+
title: 'Carthagent runtime unavailable',
|
|
30
|
+
message: error instanceof Error ? error.message : 'Could not read runtime status',
|
|
31
|
+
variant: 'error',
|
|
32
|
+
});
|
|
33
|
+
}
|
|
34
|
+
},
|
|
35
|
+
}],
|
|
36
|
+
}));
|
|
37
|
+
},
|
|
38
|
+
});
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
import { Plugin } from '@opencode/plugin';
|
|
2
|
+
import { Carthagent } from './rpc';
|
|
3
|
+
import { registerProviders } from './server/providers';
|
|
4
|
+
import { createDeviceAuthController } from './server/device-auth.ts';
|
|
5
|
+
import { shipAgentDir } from '../../../lib/ship-auth.mjs';
|
|
6
|
+
import { registerSessionControls } from './server/session-controls';
|
|
7
|
+
import { registerNativeDelivery } from './server/native-delivery';
|
|
8
|
+
|
|
9
|
+
async function cleanup(resources: Array<() => Promise<void>>, originalError?: unknown) {
|
|
10
|
+
const failures: unknown[] = [];
|
|
11
|
+
for (const dispose of resources.reverse()) {
|
|
12
|
+
try { await dispose(); }
|
|
13
|
+
catch (error) { failures.push(error); }
|
|
14
|
+
}
|
|
15
|
+
if (originalError !== undefined && failures.length) {
|
|
16
|
+
throw new AggregateError([originalError, ...failures], 'Plugin setup failed and one or more completed registrations could not be disposed.');
|
|
17
|
+
}
|
|
18
|
+
if (originalError !== undefined) throw originalError;
|
|
19
|
+
if (failures.length) throw new AggregateError(failures, 'One or more plugin registrations could not be disposed.');
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export async function setupCarthagent(context: Parameters<typeof registerSessionControls>[0], options: { delivery?: boolean; deliveryOptions?: Parameters<typeof registerNativeDelivery>[1] } = {}) {
|
|
23
|
+
const cleanups: Array<() => Promise<void>> = [];
|
|
24
|
+
try {
|
|
25
|
+
cleanups.push(await registerSessionControls(context));
|
|
26
|
+
const deviceAuth = createDeviceAuthController({ env: process.env, agentDir: shipAgentDir() });
|
|
27
|
+
cleanups.push(() => deviceAuth.cleanup());
|
|
28
|
+
const rpcRegistration = await context.rpc.register(Carthagent, {
|
|
29
|
+
health: async () => ({
|
|
30
|
+
protocol: 1,
|
|
31
|
+
hostVersion: context.app.version,
|
|
32
|
+
directory: context.location.directory,
|
|
33
|
+
}),
|
|
34
|
+
echo: async input => ({ text: (input as { text: string }).text }),
|
|
35
|
+
authStart: async (_input, call) => {
|
|
36
|
+
call.signal.throwIfAborted();
|
|
37
|
+
const flow = await deviceAuth.start(call.signal);
|
|
38
|
+
const cancel = () => deviceAuth.cancel(flow.flowId);
|
|
39
|
+
if (call.signal.aborted) cancel();
|
|
40
|
+
else call.signal.addEventListener('abort', cancel, { once: true });
|
|
41
|
+
return {
|
|
42
|
+
flowId: flow.flowId,
|
|
43
|
+
userCode: flow.authorization.userCode,
|
|
44
|
+
verificationUri: flow.authorization.verificationUriComplete || flow.authorization.verificationUri,
|
|
45
|
+
expiresAt: flow.expiresAt,
|
|
46
|
+
};
|
|
47
|
+
},
|
|
48
|
+
authCancel: async (input, call) => {
|
|
49
|
+
call.signal.throwIfAborted();
|
|
50
|
+
return { cancelled: deviceAuth.cancel((input as { flowId: string }).flowId) };
|
|
51
|
+
},
|
|
52
|
+
});
|
|
53
|
+
cleanups.push(() => rpcRegistration.dispose());
|
|
54
|
+
cleanups.push(await registerProviders(context, { deviceAuth }));
|
|
55
|
+
if (options.delivery) cleanups.push(await registerNativeDelivery(context, options.deliveryOptions));
|
|
56
|
+
} catch (error) {
|
|
57
|
+
await cleanup(cleanups, error);
|
|
58
|
+
}
|
|
59
|
+
return async () => cleanup(cleanups);
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export default Plugin.define({ id: 'carthagent', setup: setupCarthagent });
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
import { Rpc } from '@opencode/plugin/rpc';
|
|
2
|
+
|
|
3
|
+
const id = { type: 'string', minLength: 1, maxLength: 128 } as const;
|
|
4
|
+
const ref = { type: 'string', minLength: 1, maxLength: 255 } as const;
|
|
5
|
+
const hash = { type: 'string', minLength: 40, maxLength: 64 } as const;
|
|
6
|
+
const url = { type: 'string', minLength: 1, maxLength: 2048 } as const;
|
|
7
|
+
const errors = {
|
|
8
|
+
pull_request_error: {
|
|
9
|
+
type: 'object', properties: {
|
|
10
|
+
code: { type: 'string', enum: ['invalid_request', 'not_owned', 'not_associated', 'github_unavailable', 'github_invalid', 'cache_invalid', 'cache_limit', 'cache_busy', 'worktree_unavailable', 'canceled', 'internal'] },
|
|
11
|
+
}, required: ['code'], additionalProperties: false,
|
|
12
|
+
},
|
|
13
|
+
} as const;
|
|
14
|
+
const input = {
|
|
15
|
+
type: 'object', properties: { sessionID: id, taskID: id, number: { type: 'integer', minimum: 1, maximum: 2147483647 } },
|
|
16
|
+
required: ['sessionID', 'taskID'], additionalProperties: false,
|
|
17
|
+
} as const;
|
|
18
|
+
const pullRequest = {
|
|
19
|
+
type: 'object', properties: {
|
|
20
|
+
taskID: id, number: { type: 'integer', minimum: 1, maximum: 2147483647 }, url, state: { type: 'string', enum: ['open', 'closed'] }, draft: { type: 'boolean' },
|
|
21
|
+
head: hash, headRef: ref, headRepository: { type: 'string', minLength: 3, maxLength: 511 }, base: hash, baseRef: ref,
|
|
22
|
+
checks: { type: 'string', enum: ['passed', 'failed', 'pending', 'partial', 'none'] },
|
|
23
|
+
review: { type: 'string', enum: ['approved-unverified', 'changes-requested', 'unverified', 'unreviewed'] },
|
|
24
|
+
source: { type: 'string', enum: ['current', 'stale-source'] }, sourceSnapshot: hash, updatedAt: { type: 'string', minLength: 20, maxLength: 40 },
|
|
25
|
+
}, required: ['taskID', 'number', 'url', 'state', 'draft', 'head', 'headRef', 'headRepository', 'base', 'baseRef', 'checks', 'review', 'source', 'sourceSnapshot', 'updatedAt'], additionalProperties: false,
|
|
26
|
+
} as const;
|
|
27
|
+
|
|
28
|
+
/** Separate native host contract so workspace RPC composition remains owned by its host. */
|
|
29
|
+
export const PullRequests = Rpc.define({
|
|
30
|
+
id: 'carthagent.pull-requests',
|
|
31
|
+
methods: {
|
|
32
|
+
list: {
|
|
33
|
+
errors,
|
|
34
|
+
input: { type: 'object', properties: { sessionID: id, taskID: id }, required: ['sessionID', 'taskID'], additionalProperties: false },
|
|
35
|
+
output: { type: 'object', properties: { pullRequests: { type: 'array', maxItems: 100, items: pullRequest } }, required: ['pullRequests'], additionalProperties: false },
|
|
36
|
+
},
|
|
37
|
+
associate: {
|
|
38
|
+
errors,
|
|
39
|
+
input,
|
|
40
|
+
output: { type: 'object', properties: { pullRequest }, required: ['pullRequest'], additionalProperties: false },
|
|
41
|
+
},
|
|
42
|
+
refresh: {
|
|
43
|
+
errors,
|
|
44
|
+
input,
|
|
45
|
+
output: { type: 'object', properties: { pullRequest }, required: ['pullRequest'], additionalProperties: false },
|
|
46
|
+
},
|
|
47
|
+
},
|
|
48
|
+
events: {},
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
export type PullRequestRecord = {
|
|
52
|
+
taskID: string; number: number; url: string; state: string; draft: boolean; head: string; headRef: string; headRepository: string; base: string; baseRef: string;
|
|
53
|
+
checks: string; review: string; source: string; sourceSnapshot: string; updatedAt: string;
|
|
54
|
+
};
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
import { Rpc } from '@opencode/plugin/rpc';
|
|
2
|
+
|
|
3
|
+
/** Public CTG protocol. Clients use snapshots after reconnect, never event replay. */
|
|
4
|
+
export const Carthagent = Rpc.define({
|
|
5
|
+
id: 'carthagent',
|
|
6
|
+
methods: {
|
|
7
|
+
health: {
|
|
8
|
+
input: { type: 'object', additionalProperties: false },
|
|
9
|
+
output: {
|
|
10
|
+
type: 'object',
|
|
11
|
+
properties: {
|
|
12
|
+
protocol: { type: 'integer', const: 1 },
|
|
13
|
+
hostVersion: { type: 'string' },
|
|
14
|
+
directory: { type: 'string' },
|
|
15
|
+
},
|
|
16
|
+
required: ['protocol', 'hostVersion', 'directory'],
|
|
17
|
+
additionalProperties: false,
|
|
18
|
+
},
|
|
19
|
+
},
|
|
20
|
+
echo: {
|
|
21
|
+
input: {
|
|
22
|
+
type: 'object',
|
|
23
|
+
properties: { text: { type: 'string' } },
|
|
24
|
+
required: ['text'],
|
|
25
|
+
additionalProperties: false,
|
|
26
|
+
},
|
|
27
|
+
output: {
|
|
28
|
+
type: 'object',
|
|
29
|
+
properties: { text: { type: 'string' } },
|
|
30
|
+
required: ['text'],
|
|
31
|
+
additionalProperties: false,
|
|
32
|
+
},
|
|
33
|
+
},
|
|
34
|
+
authStart: {
|
|
35
|
+
input: { type: 'object', additionalProperties: false },
|
|
36
|
+
output: {
|
|
37
|
+
type: 'object',
|
|
38
|
+
properties: {
|
|
39
|
+
flowId: { type: 'string' },
|
|
40
|
+
userCode: { type: 'string' },
|
|
41
|
+
verificationUri: { type: 'string' },
|
|
42
|
+
expiresAt: { type: 'integer' },
|
|
43
|
+
},
|
|
44
|
+
required: ['flowId', 'userCode', 'verificationUri', 'expiresAt'],
|
|
45
|
+
additionalProperties: false,
|
|
46
|
+
},
|
|
47
|
+
},
|
|
48
|
+
authCancel: {
|
|
49
|
+
input: {
|
|
50
|
+
type: 'object',
|
|
51
|
+
properties: { flowId: { type: 'string' } },
|
|
52
|
+
required: ['flowId'],
|
|
53
|
+
additionalProperties: false,
|
|
54
|
+
},
|
|
55
|
+
output: {
|
|
56
|
+
type: 'object',
|
|
57
|
+
properties: { cancelled: { type: 'boolean' } },
|
|
58
|
+
required: ['cancelled'],
|
|
59
|
+
additionalProperties: false,
|
|
60
|
+
},
|
|
61
|
+
},
|
|
62
|
+
},
|
|
63
|
+
events: {
|
|
64
|
+
changed: {
|
|
65
|
+
schema: {
|
|
66
|
+
type: 'object',
|
|
67
|
+
properties: {
|
|
68
|
+
sessionID: { type: 'string' },
|
|
69
|
+
domain: { type: 'string' },
|
|
70
|
+
},
|
|
71
|
+
required: ['domain'],
|
|
72
|
+
additionalProperties: false,
|
|
73
|
+
},
|
|
74
|
+
},
|
|
75
|
+
},
|
|
76
|
+
});
|
|
@@ -0,0 +1,327 @@
|
|
|
1
|
+
import { createHash } from 'node:crypto';
|
|
2
|
+
import { constants } from 'node:fs';
|
|
3
|
+
import { open, opendir, realpath, stat } from 'node:fs/promises';
|
|
4
|
+
import { homedir } from 'node:os';
|
|
5
|
+
import { isAbsolute, join, relative, resolve, sep } from 'node:path';
|
|
6
|
+
import type { Context } from '@opencode/plugin/promise/plugin';
|
|
7
|
+
import { Archive } from '../archive-rpc';
|
|
8
|
+
|
|
9
|
+
const MAX_FILE_BYTES = 4_000_000;
|
|
10
|
+
const MAX_FILES = 2_000;
|
|
11
|
+
const MAX_ENTRIES = 10_000;
|
|
12
|
+
const MAX_TOTAL_BYTES = 50_000_000;
|
|
13
|
+
const MAX_PAGE = 100;
|
|
14
|
+
const MAX_DIAGNOSTICS = 1000;
|
|
15
|
+
const MAX_RECORDS = 10_000;
|
|
16
|
+
const MAX_SCAN_RECORDS = 100_000;
|
|
17
|
+
const READ_CHUNK = 64 * 1024;
|
|
18
|
+
|
|
19
|
+
type Options = { archiveRoots?: string[]; archiveDir?: string };
|
|
20
|
+
type FileInfo = { path: string; root: string; id: string; size: number };
|
|
21
|
+
type Row = { type?: string; timestamp?: unknown; message?: unknown; [key: string]: unknown };
|
|
22
|
+
type Parsed = { records: Row[]; malformedLines: Array<{ line: number }>; moreMalformedLines: boolean; recordsBytes: number; recordCount: number };
|
|
23
|
+
type ArchiveItem = { path: string; id: string; createdAt: unknown; messageCount: number; malformedLines: Array<{ line: number }>; moreMalformedLines: boolean; searchText: string; excerpt?: string };
|
|
24
|
+
type FailureCode = 'invalid_request' | 'not_found' | 'limit_exceeded' | 'archive_io';
|
|
25
|
+
class ArchiveFailure extends Error {
|
|
26
|
+
constructor(readonly code: FailureCode, message: string) { super(message); }
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function inside(root: string, path: string) {
|
|
30
|
+
const fromRoot = relative(root, path);
|
|
31
|
+
return fromRoot === '' || (fromRoot !== '..' && !fromRoot.startsWith(`..${sep}`) && !isAbsolute(fromRoot));
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
async function rootsFrom(options: Options): Promise<string[]> {
|
|
35
|
+
const configured = options.archiveRoots ?? (options.archiveDir ? [options.archiveDir] : undefined);
|
|
36
|
+
const roots = configured ?? [join(homedir(), '.carthagent', 'agent', 'sessions')];
|
|
37
|
+
if (!Array.isArray(roots) || roots.some(root => typeof root !== 'string' || !isAbsolute(root) || resolve(root) !== root)) {
|
|
38
|
+
throw new ArchiveFailure('invalid_request', 'Archive roots must be normalized absolute paths configured by the host');
|
|
39
|
+
}
|
|
40
|
+
const canonicalRoots: string[] = [];
|
|
41
|
+
for (const root of roots) {
|
|
42
|
+
try { canonicalRoots.push(await realpath(root)); }
|
|
43
|
+
catch (error) {
|
|
44
|
+
if ((error as NodeJS.ErrnoException).code === 'ENOENT') canonicalRoots.push(root);
|
|
45
|
+
else throw new ArchiveFailure('archive_io', 'Unable to resolve a configured archive root');
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
return [...new Set(canonicalRoots)];
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function aborted(signal: AbortSignal) {
|
|
52
|
+
if (signal.aborted) throw signal.reason ?? new DOMException('Operation aborted', 'AbortError');
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function sameFile(
|
|
56
|
+
left: NonNullable<Awaited<ReturnType<import('node:fs/promises').FileHandle['stat']>>>,
|
|
57
|
+
right: NonNullable<Awaited<ReturnType<import('node:fs/promises').FileHandle['stat']>>>,
|
|
58
|
+
) {
|
|
59
|
+
return left.dev === right.dev && left.ino === right.ino;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/** Bind path containment and opened descriptor identity before any bytes are consumed. */
|
|
63
|
+
export async function verifyOpenedTarget(file: Pick<FileInfo, 'path' | 'root'>, handle: Awaited<ReturnType<typeof open>>) {
|
|
64
|
+
let canonical: string;
|
|
65
|
+
let pathStat;
|
|
66
|
+
let descriptorStat;
|
|
67
|
+
try {
|
|
68
|
+
[canonical, pathStat, descriptorStat] = await Promise.all([realpath(file.path), stat(file.path), handle.stat()]);
|
|
69
|
+
} catch {
|
|
70
|
+
throw new ArchiveFailure('not_found', 'Archive transcript changed while opening');
|
|
71
|
+
}
|
|
72
|
+
if (!inside(file.root, canonical) || canonical !== file.path || !pathStat.isFile() || !descriptorStat.isFile() || !sameFile(pathStat, descriptorStat)) {
|
|
73
|
+
throw new ArchiveFailure('not_found', 'Archive transcript target changed outside its configured root');
|
|
74
|
+
}
|
|
75
|
+
return descriptorStat;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
async function enumerate(roots: string[], signal: AbortSignal): Promise<FileInfo[]> {
|
|
79
|
+
let entriesSeen = 0;
|
|
80
|
+
let totalBytes = 0;
|
|
81
|
+
const found: FileInfo[] = [];
|
|
82
|
+
for (const root of roots) {
|
|
83
|
+
aborted(signal);
|
|
84
|
+
let metadata;
|
|
85
|
+
try { metadata = await stat(root); }
|
|
86
|
+
catch (error) {
|
|
87
|
+
if ((error as NodeJS.ErrnoException).code === 'ENOENT') continue;
|
|
88
|
+
throw new ArchiveFailure('archive_io', 'Unable to inspect a configured archive root');
|
|
89
|
+
}
|
|
90
|
+
if (!metadata.isDirectory()) continue;
|
|
91
|
+
const pending = [root];
|
|
92
|
+
while (pending.length) {
|
|
93
|
+
aborted(signal);
|
|
94
|
+
const directoryPath = pending.pop()!;
|
|
95
|
+
let canonicalDirectory: string;
|
|
96
|
+
try { canonicalDirectory = await realpath(directoryPath); }
|
|
97
|
+
catch (error) {
|
|
98
|
+
if ((error as NodeJS.ErrnoException).code === 'ENOENT') continue;
|
|
99
|
+
throw new ArchiveFailure('archive_io', 'Unable to resolve an archive directory');
|
|
100
|
+
}
|
|
101
|
+
if (!inside(root, canonicalDirectory)) continue;
|
|
102
|
+
let directory;
|
|
103
|
+
try { directory = await opendir(directoryPath); }
|
|
104
|
+
catch (error) {
|
|
105
|
+
if ((error as NodeJS.ErrnoException).code === 'ENOENT') continue;
|
|
106
|
+
throw new ArchiveFailure('archive_io', 'Unable to enumerate a configured archive root');
|
|
107
|
+
}
|
|
108
|
+
for await (const entry of directory) {
|
|
109
|
+
aborted(signal);
|
|
110
|
+
entriesSeen++;
|
|
111
|
+
if (entriesSeen > MAX_ENTRIES) throw new ArchiveFailure('limit_exceeded', `Archive exceeds the ${MAX_ENTRIES} entry scan limit`);
|
|
112
|
+
if (entry.isSymbolicLink()) continue;
|
|
113
|
+
const path = join(directoryPath, entry.name);
|
|
114
|
+
if (entry.isDirectory()) { pending.push(path); continue; }
|
|
115
|
+
if (!entry.isFile() || !entry.name.endsWith('.jsonl')) continue;
|
|
116
|
+
if (found.length >= MAX_FILES) throw new ArchiveFailure('limit_exceeded', `Archive exceeds the ${MAX_FILES} transcript scan limit`);
|
|
117
|
+
let canonical;
|
|
118
|
+
try { canonical = await realpath(path); }
|
|
119
|
+
catch (error) {
|
|
120
|
+
if ((error as NodeJS.ErrnoException).code === 'ENOENT') continue;
|
|
121
|
+
throw new ArchiveFailure('archive_io', 'Unable to resolve an archive transcript');
|
|
122
|
+
}
|
|
123
|
+
if (!inside(root, canonical)) continue;
|
|
124
|
+
const id = createHash('sha256').update(root).update('\0').update(canonical).digest('hex');
|
|
125
|
+
// Do not trust Dirent or pathname stat sizes. The descriptor is fstat'd
|
|
126
|
+
// immediately before reading and the reader independently enforces cap.
|
|
127
|
+
found.push({ path: canonical, root, id, size: -1 });
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
found.sort((a, b) => a.id.localeCompare(b.id));
|
|
132
|
+
// Cheap bounded admission accounting uses descriptor metadata, not stat(path).
|
|
133
|
+
for (const file of found) {
|
|
134
|
+
aborted(signal);
|
|
135
|
+
const handle = await openReadHandle(file.path);
|
|
136
|
+
try {
|
|
137
|
+
const info = await verifyOpenedTarget(file, handle);
|
|
138
|
+
if (!info.isFile()) continue;
|
|
139
|
+
if (info.size > MAX_FILE_BYTES) throw new ArchiveFailure('limit_exceeded', `Archive transcript exceeds the ${MAX_FILE_BYTES} byte limit`);
|
|
140
|
+
totalBytes += info.size;
|
|
141
|
+
if (totalBytes > MAX_TOTAL_BYTES) throw new ArchiveFailure('limit_exceeded', `Archive exceeds the ${MAX_TOTAL_BYTES} byte scan limit`);
|
|
142
|
+
file.size = info.size;
|
|
143
|
+
} finally { await handle.close(); }
|
|
144
|
+
}
|
|
145
|
+
return found.filter(file => file.size >= 0);
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
async function openReadHandle(path: string) {
|
|
149
|
+
try { return await open(path, constants.O_RDONLY | (constants.O_NOFOLLOW ?? 0)); }
|
|
150
|
+
catch (error) { throw new ArchiveFailure('archive_io', (error as NodeJS.ErrnoException).code === 'ELOOP' ? 'Archive transcript changed to a symbolic link' : 'Unable to open an archive transcript'); }
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
async function readBounded(file: FileInfo, signal: AbortSignal, maxBytes = MAX_FILE_BYTES): Promise<Buffer> {
|
|
154
|
+
aborted(signal);
|
|
155
|
+
const handle = await openReadHandle(file.path);
|
|
156
|
+
try {
|
|
157
|
+
const info = await verifyOpenedTarget(file, handle);
|
|
158
|
+
if (info.size > MAX_FILE_BYTES) throw new ArchiveFailure('limit_exceeded', `Archive transcript exceeds the ${MAX_FILE_BYTES} byte limit`);
|
|
159
|
+
if (info.size > maxBytes) throw new ArchiveFailure('limit_exceeded', `Archive exceeds the ${MAX_TOTAL_BYTES} byte scan limit`);
|
|
160
|
+
totalLimitCheck(info.size);
|
|
161
|
+
const parts: Buffer[] = [];
|
|
162
|
+
let position = 0;
|
|
163
|
+
while (position < info.size) {
|
|
164
|
+
aborted(signal);
|
|
165
|
+
const buffer = Buffer.allocUnsafe(Math.min(READ_CHUNK, info.size - position));
|
|
166
|
+
const { bytesRead } = await handle.read(buffer, 0, buffer.length, position);
|
|
167
|
+
if (bytesRead === 0) break;
|
|
168
|
+
parts.push(bytesRead === buffer.length ? buffer : buffer.subarray(0, bytesRead));
|
|
169
|
+
position += bytesRead;
|
|
170
|
+
if (position > MAX_FILE_BYTES) throw new ArchiveFailure('limit_exceeded', `Archive transcript exceeds the ${MAX_FILE_BYTES} byte limit`);
|
|
171
|
+
}
|
|
172
|
+
aborted(signal);
|
|
173
|
+
const after = await handle.stat();
|
|
174
|
+
if (after.size > MAX_FILE_BYTES || after.size !== info.size || position !== info.size) {
|
|
175
|
+
throw new ArchiveFailure('limit_exceeded', `Archive transcript changed or exceeds the ${MAX_FILE_BYTES} byte limit`);
|
|
176
|
+
}
|
|
177
|
+
return Buffer.concat(parts, position);
|
|
178
|
+
} finally { await handle.close(); }
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
function totalLimitCheck(size: number) {
|
|
182
|
+
if (size > MAX_FILE_BYTES) throw new ArchiveFailure('limit_exceeded', `Archive transcript exceeds the ${MAX_FILE_BYTES} byte limit`);
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
function parse(bytes: Buffer, maxRecords = MAX_RECORDS): Parsed {
|
|
186
|
+
const records: Row[] = [];
|
|
187
|
+
const malformedLines: Array<{ line: number }> = [];
|
|
188
|
+
let lineStart = 0;
|
|
189
|
+
let lineNumber = 0;
|
|
190
|
+
let moreMalformedLines = false;
|
|
191
|
+
let encodedRecordsBytes = 0;
|
|
192
|
+
for (let end = 0; end <= bytes.length; end++) {
|
|
193
|
+
if (end !== bytes.length && bytes[end] !== 0x0a) continue;
|
|
194
|
+
lineNumber++;
|
|
195
|
+
let lineEnd = end;
|
|
196
|
+
if (lineEnd > lineStart && bytes[lineEnd - 1] === 0x0d) lineEnd--;
|
|
197
|
+
const line = bytes.toString('utf8', lineStart, lineEnd);
|
|
198
|
+
lineStart = end + 1;
|
|
199
|
+
if (!line.trim()) continue;
|
|
200
|
+
try {
|
|
201
|
+
const record = JSON.parse(line) as Row;
|
|
202
|
+
if (records.length >= maxRecords) throw new ArchiveFailure('limit_exceeded', `Archive transcript exceeds the ${maxRecords} record processing limit`);
|
|
203
|
+
// Account before retaining parsed objects; no full transcript stringify.
|
|
204
|
+
encodedRecordsBytes += Buffer.byteLength(JSON.stringify(record));
|
|
205
|
+
if (encodedRecordsBytes > MAX_FILE_BYTES) throw new ArchiveFailure('limit_exceeded', 'Archive transcript response exceeds the 4,000,000 character limit');
|
|
206
|
+
records.push(record);
|
|
207
|
+
} catch (error) {
|
|
208
|
+
if (error instanceof ArchiveFailure) throw error;
|
|
209
|
+
if (malformedLines.length < MAX_DIAGNOSTICS) malformedLines.push({ line: lineNumber });
|
|
210
|
+
else moreMalformedLines = true;
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
return { records, malformedLines, moreMalformedLines, recordsBytes: encodedRecordsBytes, recordCount: records.length };
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
async function itemFor(file: FileInfo, signal: AbortSignal, maxBytes: number, maxRecords: number): Promise<{ item: ArchiveItem; bytes: number; records: number }> {
|
|
217
|
+
const bytes = await readBounded(file, signal, maxBytes);
|
|
218
|
+
const transcript = parse(bytes, maxRecords);
|
|
219
|
+
const header = transcript.records.find(row => row?.type === 'session') ?? transcript.records[0] ?? {};
|
|
220
|
+
const messages = transcript.records.filter(row => row?.type === 'message');
|
|
221
|
+
const searchText = messages.map(row => JSON.stringify(row.message ?? row)).join('\n');
|
|
222
|
+
return { item: {
|
|
223
|
+
path: file.path, id: file.id, createdAt: header.timestamp ?? null,
|
|
224
|
+
messageCount: messages.length, malformedLines: transcript.malformedLines.slice(0, MAX_DIAGNOSTICS),
|
|
225
|
+
moreMalformedLines: transcript.moreMalformedLines, searchText,
|
|
226
|
+
}, bytes: bytes.length, records: transcript.recordCount };
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
function pageArgs(input: { offset?: number; limit?: number }) {
|
|
230
|
+
const offset = input.offset ?? 0;
|
|
231
|
+
const limit = input.limit ?? MAX_PAGE;
|
|
232
|
+
if (!Number.isSafeInteger(offset) || offset < 0 || offset > 1_000_000) throw new ArchiveFailure('invalid_request', 'Archive offset must be between 0 and 1000000');
|
|
233
|
+
if (!Number.isSafeInteger(limit) || limit < 1 || limit > MAX_PAGE) throw new ArchiveFailure('invalid_request', `Archive page limit must be between 1 and ${MAX_PAGE}`);
|
|
234
|
+
return { offset, limit };
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
function publicItems(items: ArchiveItem[], offset: number, limit: number, total: number) {
|
|
238
|
+
return {
|
|
239
|
+
items: items.slice(offset, offset + limit).map(item => ({
|
|
240
|
+
id: item.id, createdAt: item.createdAt, messageCount: item.messageCount,
|
|
241
|
+
malformedLines: item.malformedLines, moreMalformedLines: item.moreMalformedLines,
|
|
242
|
+
...(item.excerpt ? { excerpt: item.excerpt.slice(0, 400) } : {}), archiveOnly: true,
|
|
243
|
+
})), total, offset, limit,
|
|
244
|
+
};
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
async function allItems(roots: string[], signal: AbortSignal) {
|
|
248
|
+
const indexed = await enumerate(roots, signal);
|
|
249
|
+
const items: ArchiveItem[] = [];
|
|
250
|
+
let bytesRead = 0;
|
|
251
|
+
let recordsRead = 0;
|
|
252
|
+
for (const file of indexed) {
|
|
253
|
+
aborted(signal);
|
|
254
|
+
const result = await itemFor(file, signal, MAX_TOTAL_BYTES - bytesRead, MAX_SCAN_RECORDS - recordsRead);
|
|
255
|
+
bytesRead += result.bytes;
|
|
256
|
+
recordsRead += result.records;
|
|
257
|
+
items.push(result.item);
|
|
258
|
+
}
|
|
259
|
+
return { indexed, items };
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
function failure(call: { error: (type: 'archive_error', message: string, data: { code: FailureCode }) => unknown }, error: unknown) {
|
|
263
|
+
const failure = error instanceof ArchiveFailure ? error : new ArchiveFailure('archive_io', 'Unable to read the configured archive');
|
|
264
|
+
return call.error('archive_error', failure.message, { code: failure.code });
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
/** Registers a read-only view of host-configured legacy transcript roots. */
|
|
268
|
+
export async function registerArchive(ctx: Context, options: Options = {}) {
|
|
269
|
+
const roots = await rootsFrom(options);
|
|
270
|
+
return ctx.rpc.register(Archive, {
|
|
271
|
+
list: async (input, call) => {
|
|
272
|
+
try {
|
|
273
|
+
aborted(call.signal);
|
|
274
|
+
const { offset, limit } = pageArgs(input as { offset?: number; limit?: number });
|
|
275
|
+
const { items } = await allItems(roots, call.signal);
|
|
276
|
+
aborted(call.signal);
|
|
277
|
+
return publicItems(items, offset, limit, items.length);
|
|
278
|
+
} catch (error) { if (call.signal.aborted) throw error; return failure(call, error); }
|
|
279
|
+
},
|
|
280
|
+
search: async (input, call) => {
|
|
281
|
+
try {
|
|
282
|
+
aborted(call.signal);
|
|
283
|
+
const { query, ...paging } = input as { query: string; offset?: number; limit?: number };
|
|
284
|
+
if (!query.trim() || query.length > 200) throw new ArchiveFailure('invalid_request', 'Archive search query must contain 1 to 200 characters');
|
|
285
|
+
const { offset, limit } = pageArgs(paging);
|
|
286
|
+
const { items } = await allItems(roots, call.signal);
|
|
287
|
+
const needle = query.toLocaleLowerCase();
|
|
288
|
+
const matches = items.flatMap(item => {
|
|
289
|
+
const index = item.searchText.toLocaleLowerCase().indexOf(needle);
|
|
290
|
+
return index < 0 ? [] : [{ ...item, excerpt: item.searchText.slice(Math.max(0, index - 120), index + needle.length + 180) }];
|
|
291
|
+
});
|
|
292
|
+
aborted(call.signal);
|
|
293
|
+
return publicItems(matches, offset, limit, matches.length);
|
|
294
|
+
} catch (error) { if (call.signal.aborted) throw error; return failure(call, error); }
|
|
295
|
+
},
|
|
296
|
+
read: async (input, call) => {
|
|
297
|
+
try {
|
|
298
|
+
aborted(call.signal);
|
|
299
|
+
const { id } = input as { id: string };
|
|
300
|
+
const file = (await enumerate(roots, call.signal)).find(candidate => candidate.id === id);
|
|
301
|
+
if (!file) throw new ArchiveFailure('not_found', 'Archive transcript ID was not found in configured roots');
|
|
302
|
+
const transcript = parse(await readBounded(file, call.signal));
|
|
303
|
+
if (transcript.recordsBytes > MAX_FILE_BYTES) throw new ArchiveFailure('limit_exceeded', 'Archive transcript response exceeds the 4,000,000 character limit');
|
|
304
|
+
aborted(call.signal);
|
|
305
|
+
return { id, records: transcript.records, malformedLines: transcript.malformedLines, moreMalformedLines: transcript.moreMalformedLines, archiveOnly: true };
|
|
306
|
+
} catch (error) { if (call.signal.aborted) throw error; return failure(call, error); }
|
|
307
|
+
},
|
|
308
|
+
export: async (input, call) => {
|
|
309
|
+
try {
|
|
310
|
+
aborted(call.signal);
|
|
311
|
+
const { id, format = 'json' } = input as { id: string; format?: 'json' | 'jsonl' };
|
|
312
|
+
if (format !== 'json' && format !== 'jsonl') throw new ArchiveFailure('invalid_request', 'Export format must be json or jsonl');
|
|
313
|
+
const file = (await enumerate(roots, call.signal)).find(candidate => candidate.id === id);
|
|
314
|
+
if (!file) throw new ArchiveFailure('not_found', 'Archive transcript ID was not found in configured roots');
|
|
315
|
+
const bytes = await readBounded(file, call.signal);
|
|
316
|
+
const parsed = format === 'json' ? parse(bytes) : undefined;
|
|
317
|
+
if (parsed && parsed.recordsBytes + parsed.records.length + 2 > MAX_FILE_BYTES) {
|
|
318
|
+
throw new ArchiveFailure('limit_exceeded', 'Archive JSON export exceeds the 4,000,000 byte response limit');
|
|
319
|
+
}
|
|
320
|
+
const content = format === 'jsonl' ? bytes.toString('utf8') : `${JSON.stringify(parsed!.records)}\n`;
|
|
321
|
+
if (Buffer.byteLength(content) > MAX_FILE_BYTES) throw new ArchiveFailure('limit_exceeded', 'Archive export exceeds the 4,000,000 byte response limit');
|
|
322
|
+
aborted(call.signal);
|
|
323
|
+
return { id, format, content, archiveOnly: true };
|
|
324
|
+
} catch (error) { if (call.signal.aborted) throw error; return failure(call, error); }
|
|
325
|
+
},
|
|
326
|
+
});
|
|
327
|
+
}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import { budgetLimits, newBudget, budgetReason, budgetSnapshot } from "../../../../lib/budget.mjs"
|
|
2
|
+
|
|
3
|
+
export type Budget = ReturnType<typeof newBudget>
|
|
4
|
+
|
|
5
|
+
export function readBudgetLimits(options: Record<string, unknown>) {
|
|
6
|
+
return budgetLimits((name: string) => options[name] ?? "0")
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
export function budgetForSession(saved: Budget | undefined, limits: ReturnType<typeof budgetLimits>) {
|
|
10
|
+
return saved ? structuredClone(saved) : newBudget(limits)
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export function checkBudget(budget: Budget, action?: "tool" | "repair", now = Date.now()) {
|
|
14
|
+
const reason = budgetReason(budget, action, now)
|
|
15
|
+
if (reason) return reason
|
|
16
|
+
if (action === "tool") budget.tools++
|
|
17
|
+
if (action === "repair") budget.repairs++
|
|
18
|
+
return null
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export { budgetSnapshot }
|