diff --git a/.env.defaults b/.env.defaults index 1c6b539..798fdba 100644 --- a/.env.defaults +++ b/.env.defaults @@ -22,5 +22,6 @@ ROOTCELL_SPY_ENABLED=false # ROOTCELL_SPY_MAX_BYTES=6442450944 # ROOTCELL_SPY_SPOOL_MAX_BYTES=1073741824 # ROOTCELL_SPY_STORE_RAW=false +# ROOTCELL_SPY_TOKEN_COUNT_MODE=provider # ROOTCELL_SPY_BIND=127.0.0.1 # ROOTCELL_SPY_PORT=6174 diff --git a/SPY_PLAN.md b/SPY_PLAN.md index bee1bc8..d7f5cb2 100644 --- a/SPY_PLAN.md +++ b/SPY_PLAN.md @@ -52,7 +52,7 @@ multi-conversation support straightforward, but those are not v1 scope. - Desktop-only UI. Do not spend v1 scope on mobile support. - No keyboard shortcut requirement. Design the browser UX on its own terms, not as a TUI clone. -- Token counting for highlighted text and per-block token estimates are v1.5, +- Token counting for highlighted text and per-block provider counts are v1.5, not v1. - Automated compaction detection is v1.5, not v1. - Broader charts/visual regression screenshots are v1.5 or later. @@ -158,13 +158,23 @@ The first screen should be a live conversation-analysis surface: Performance requirements: - Virtualize the live timeline. +- Virtualize every large or repeated content surface by viewport, not just the + timeline. The inspector, block lists, raw payloads, stream payloads, diffs, + and any future conversation/context views must remain responsive when a single + provider request contains a one million token context window. +- Do not silently truncate normalized request/response bodies as a performance + strategy. If a body is too large to mount eagerly, render a virtualized or + lazily mounted full-content view with explicit preview/expand affordances. + Token counts, byte counts, hashes, diffs, and search must always be based on + the full captured content, not the preview. - Fetch summaries first and details on demand. - Paginate historical queries. - Keep stream events and raw payload details collapsed and loaded only on request. - Use semantic highlighting instead of editor-style highlighting as the primary visual language. -- Avoid rendering giant JSON/code blocks into the DOM. +- Avoid rendering giant JSON/code blocks into the DOM outside a viewport-bounded + virtualized/lazy surface. Semantic highlighting should distinguish: @@ -194,6 +204,7 @@ ROOTCELL_SPY_ENABLED=false # ROOTCELL_SPY_MAX_BYTES=6442450944 # ROOTCELL_SPY_SPOOL_MAX_BYTES=1073741824 # ROOTCELL_SPY_STORE_RAW=false +# ROOTCELL_SPY_TOKEN_COUNT_MODE=provider # ROOTCELL_SPY_BIND=127.0.0.1 # ROOTCELL_SPY_PORT=6174 ``` @@ -205,6 +216,7 @@ Defaults: - Total spy store budget: 6 GiB. - Spool budget: 1 GiB. - Raw exact payload storage disabled. +- Token count mode defaults to Bedrock CountTokens provider counting. - Firewall service binds `127.0.0.1:6174`. `./rootcell spy` should choose host-local port `6174` when available and fall @@ -671,6 +683,89 @@ V1 excludes: `./rootcell spy --no-open` refuses to launch while disabled, the `rootcell-spy.service` is inactive, the SQLite store is preserved, the spool is empty, and a disabled-state Pi/Bedrock call writes no spool files. +- Completed the V1.5 provider-only token accounting foundation: + - Added shared token-count API contracts for `call`, `section`, `block`, and + transient/cached `selection` subjects with `provider_reported`, + `provider_counted`, and `unavailable` provenance only. + - Removed local token estimates and the `estimated` provenance path. All + displayed token counts now come from provider-reported usage, Bedrock + CountTokens, or an explicit unavailable record. + - Added provider-routed `POST /api/token-count`; the browser asks the + firewall-hosted spy service, and the browser never calls Bedrock directly. + - Added Bedrock Runtime CountTokens support with model-id normalization for + Anthropic inference-profile ids such as `us.` and `global.` prefixed Haiku + model ids. + - Extended `GET /api/calls/:id` so call details backfill missing request, + section, and block counts through the provider and return cached/stored + token records. + - Added SQLite schema v4 and token-count cache persistence keyed by subject + identity, source hash, and model id, with cascade deletion through retention + and clear-data. + - Updated the inspector with token/provenance chips on block rows, token + columns in request composition, explicit provider-count controls, and + highlighted selection counting that stores provider results. + - Updated generated spy env defaults and docs so + `ROOTCELL_SPY_TOKEN_COUNT_MODE=provider` is the only supported mode, and + forwarded Bedrock credential env when token counting is enabled. + - Fixed live Bedrock CountTokens payload issues found during validation: + Anthropic inference-profile ids are converted to base model ids for + CountTokens, and isolated system-context block text uses a valid Converse + message wrapper instead of a system-only payload that Bedrock rejects. + - Rebuilt and provisioned the default firewall/agent VMs with the corrected + spy service and UI assets. + - Verified `bun run typecheck`, `bun run lint`, `bun run test:spy`, + `bun run test:spy-ui:unit`, `bun run test:spy-ui:e2e`, + `bun run build:spy`, and `git diff --check`; service tests that bind local + ports were run with localhost permissions when required. +- Completed V1.5 automated compaction candidate detection: + - Added shared compaction assessment API contracts with candidate source, + confidence, reasons, and request-transition evidence. + - Added a pure request-context compaction detector with Pi-specific request + profile signals and generic structural fallback heuristics. + - Wired call detail responses to compare each request against the previous + comparable request and return computed compaction assessment data without a + new persistence migration. + - Added a browser inspector summary label for candidate calls, distinguishing + Pi-pattern candidates from lower-confidence heuristic candidates. + - Added fixture-backed and synthetic coverage for Pi candidates, generic + candidates, and false positives where existing Pi/Bedrock fixtures should + not be flagged. + - Validated against the default Lima agent/firewall/spy setup with + `ROOTCELL_SPY_ENABLED=true`: provisioned the updated service/UI, launched + `./rootcell spy --no-open`, ran a real Pi/Bedrock session, triggered manual + `/compact`, sent a post-compaction prompt, and inspected the live spy API + and browser UI. + - Confirmed raw storage was not required for inspection. The live + post-compaction call was labeled `Pi compaction candidate` with low + confidence because Pi emitted a summary-like history block while still + carrying the earlier large history in the assembled request. + - Verified `bun run typecheck`, `bun run lint`, `bun run test:spy`, + `bun run test:spy-ui:unit`, `bun run build:spy`, + `bun run test:spy-ui:e2e`, and `git diff --check`; localhost-bound tests + and live VM work were run outside the sandbox where required. +- Completed V1.5 P0 large-content handling: + - Virtualized large request and response block lists in the inspector so + repeated block rows do not create runaway DOM size. + - Replaced silent block body clipping with an explicit preview/full-text flow. + Large block previews are labeled, and expanded block bodies mount the full + captured text in a bounded readonly textarea. + - Preserved long-range text selection inside expanded block bodies so the + existing selected-text provider token counter can measure large spans such + as the first half of a compaction request. + - Kept full captured content as the source of truth for token counts, byte + counts, hashes, search, and diffing. No service API, SQLite schema, + provider adapter, or persistence changes were needed. + - Made raw payload and stream payload bodies collapsed by default, with + bounded preview/full-text expansion instead of silent truncation. + - Added Playwright coverage for large synthetic request blocks, bounded DOM + row counts, exact selected-substring submission to `POST /api/token-count`, + and large raw/stream payload expansion. + - Fixed the expanded full-text control styling so preview and full text use + the same monospace font, size, line height, and letter spacing. + - Verified `bun run typecheck`, `bun run lint`, `bun run test:spy-ui:unit`, + `bun run test:spy-ui:e2e`, `bun run build:spy`, and `git diff --check`; + localhost-bound Playwright tests were run outside the sandbox where + required. ### V1 @@ -759,18 +854,48 @@ notes, and follow-up verification baseline were moved to Add analysis depth: -- Exact/estimated token counting for highlighted text, blocks, sections, and +- [x] P0 viewport virtualization for all large content surfaces. + - The UI must handle a provider request with a one million token context + window without freezing, scroll jumps, runaway DOM size, or silent body + truncation. + - Timeline virtualization is not sufficient. Inspector block lists, block + bodies, raw payload bodies, stream payload previews, diff views, and future + conversation/context views must render only the visible viewport or + intentionally expanded local content. + - Full captured content remains the source of truth for token counts, byte + counts, hashes, search, and diffing. Preview text is only a presentation + optimization and must be labeled or expandable when it is not the full body. + - Replace `clipped(...)` body rendering with explicit viewport/lazy rendering + behavior and add regression coverage using very large synthetic blocks. + - Large block bodies are not line-virtualized because selected-text token + counting must support selecting large spans of text. Expanded blocks use a + bounded readonly textarea that mounts the full block text and preserves + native selection behavior. +- [x] Shared token-count contracts for `call`, `section`, `block`, and + `selection` subjects. +- [x] Provider-only token-count mode. Local estimates and `estimated` + provenance were removed. +- [x] Provider-reported request totals are used when available. +- [x] Provider-counted token counting for highlighted text, blocks, sections, and whole requests. -- Provider-routed token-count backend; browser never calls LLM providers +- [x] Provider-routed token-count backend; browser never calls LLM providers directly. -- Per-block token provenance: `provider_reported`, `provider_counted`, - `estimated`, or `unavailable`. -- Automated compaction candidate detection: +- [x] Bedrock CountTokens support through the firewall spy service, including + Anthropic inference-profile model-id normalization. +- [x] SQLite cache for provider-counted call, section, block, and selection + results with retention and clear-data deletion. +- [x] Per-block token provenance: `provider_reported`, `provider_counted`, or + `unavailable`. +- [x] Request composition token columns and block-row token/provenance chips. +- [x] Explicit provider-count and highlighted-selection count UI actions. +- [x] Automated compaction candidate detection: - Pi-specific request patterns from fixtures. - Generic fallback heuristics. - Labels that distinguish Pi-specific candidates from heuristic candidates. -- Dedicated compaction investigation view. -- Visual regression/screenshot checks. +- [x] Dedicated compaction investigation view closed as not needed for V1.5: + the existing summary label plus visible request/response blocks make real + harness compaction calls obvious enough for the current operator workflow. +- [x] Visual regression/screenshot checks for stable desktop UI states. ### V2 diff --git a/bun.lock b/bun.lock index a71fbbd..f902ef8 100644 --- a/bun.lock +++ b/bun.lock @@ -5,6 +5,7 @@ "": { "name": "rootcell", "dependencies": { + "@aws-sdk/client-bedrock-runtime": "^3.1050.0", "@aws-sdk/client-ec2": "^3.1050.0", "@aws-sdk/client-s3": "^3.1050.0", "@aws-sdk/client-secrets-manager": "^3.1050.0", @@ -52,6 +53,8 @@ "@aws-crypto/util": ["@aws-crypto/util@5.2.0", "", { "dependencies": { "@aws-sdk/types": "^3.222.0", "@smithy/util-utf8": "^2.0.0", "tslib": "^2.6.2" } }, "sha512-4RkU9EsI6ZpBve5fseQlGNUWKMa1RLPQ1dnjnQoe07ldfIzcsGb5hC5W0Dm7u423KWzawlrpbjXBrXCEv9zazQ=="], + "@aws-sdk/client-bedrock-runtime": ["@aws-sdk/client-bedrock-runtime@3.1053.0", "", { "dependencies": { "@aws-crypto/sha256-browser": "5.2.0", "@aws-crypto/sha256-js": "5.2.0", "@aws-sdk/core": "^3.974.13", "@aws-sdk/credential-provider-node": "^3.972.44", "@aws-sdk/eventstream-handler-node": "^3.972.17", "@aws-sdk/middleware-eventstream": "^3.972.13", "@aws-sdk/middleware-websocket": "^3.972.21", "@aws-sdk/token-providers": "3.1053.0", "@aws-sdk/types": "^3.973.9", "@smithy/core": "^3.24.3", "@smithy/fetch-http-handler": "^5.4.3", "@smithy/node-http-handler": "^4.7.3", "@smithy/types": "^4.14.2", "tslib": "^2.6.2" } }, "sha512-I5dua8y1logE+Mx6r5kvI1tjM+XyC3H42KDCpEqmhrJfanor/x/AdOavyv3HnS4sBqUxx2IrjLP3ouEumjeTzA=="], + "@aws-sdk/client-cognito-identity": ["@aws-sdk/client-cognito-identity@3.1050.0", "", { "dependencies": { "@aws-crypto/sha256-browser": "5.2.0", "@aws-crypto/sha256-js": "5.2.0", "@aws-sdk/core": "^3.974.12", "@aws-sdk/credential-provider-node": "^3.972.43", "@aws-sdk/types": "^3.973.8", "@smithy/core": "^3.24.2", "@smithy/fetch-http-handler": "^5.4.2", "@smithy/node-http-handler": "^4.7.2", "@smithy/types": "^4.14.1", "tslib": "^2.6.2" } }, "sha512-VBbHeLRUoprJE57zc8WEbqC+bLUbHGlMI8S7NUuvEZ3svsYp0SGzkw81xuq2W7zbnlNFJL1jMIINjNMGo0Rn1Q=="], "@aws-sdk/client-ec2": ["@aws-sdk/client-ec2@3.1050.0", "", { "dependencies": { "@aws-crypto/sha256-browser": "5.2.0", "@aws-crypto/sha256-js": "5.2.0", "@aws-sdk/core": "^3.974.12", "@aws-sdk/credential-provider-node": "^3.972.43", "@aws-sdk/middleware-sdk-ec2": "^3.972.26", "@aws-sdk/types": "^3.973.8", "@smithy/core": "^3.24.2", "@smithy/fetch-http-handler": "^5.4.2", "@smithy/node-http-handler": "^4.7.2", "@smithy/types": "^4.14.1", "tslib": "^2.6.2" } }, "sha512-pJrYSya1WQs+2Wvvz8oC9bezZmbdOCFhRG39e1NIxyhAoMsHk2GQ7Yp0sKqjjn7IXKvaGAekIhdh+s3NTfK9Qg=="], @@ -62,7 +65,7 @@ "@aws-sdk/client-sts": ["@aws-sdk/client-sts@3.1050.0", "", { "dependencies": { "@aws-crypto/sha256-browser": "5.2.0", "@aws-crypto/sha256-js": "5.2.0", "@aws-sdk/core": "^3.974.12", "@aws-sdk/credential-provider-node": "^3.972.43", "@aws-sdk/signature-v4-multi-region": "^3.996.27", "@aws-sdk/types": "^3.973.8", "@smithy/core": "^3.24.2", "@smithy/fetch-http-handler": "^5.4.2", "@smithy/node-http-handler": "^4.7.2", "@smithy/types": "^4.14.1", "tslib": "^2.6.2" } }, "sha512-CaQcTKGwxUEmU661gu3OxV9Ep7/jX39C83jfd/HnW/o7O/W9/bb+Jn1aMS05cdpst6wGBQ2kzgsCXGaBZGuLxQ=="], - "@aws-sdk/core": ["@aws-sdk/core@3.974.12", "", { "dependencies": { "@aws-sdk/types": "^3.973.8", "@aws-sdk/xml-builder": "^3.972.24", "@aws/lambda-invoke-store": "^0.2.2", "@smithy/core": "^3.24.2", "@smithy/signature-v4": "^5.4.2", "@smithy/types": "^4.14.1", "bowser": "^2.11.0", "tslib": "^2.6.2" } }, "sha512-qrqgioqYFjwR6LatVNS1L2Vk++EwRIxqSQXPKNv5Ofux2D8UNgqMQ1znnMyEImXquVPTtbf71fc128pvmU6y9A=="], + "@aws-sdk/core": ["@aws-sdk/core@3.974.13", "", { "dependencies": { "@aws-sdk/types": "^3.973.9", "@aws-sdk/xml-builder": "^3.972.25", "@aws/lambda-invoke-store": "^0.2.2", "@smithy/core": "^3.24.3", "@smithy/signature-v4": "^5.4.2", "@smithy/types": "^4.14.2", "bowser": "^2.11.0", "tslib": "^2.6.2" } }, "sha512-+Y5/4tHki0uYgyx8eun146DegRVQBpdKGK5RbV0FTKJPpaKTchvqVxrrRFK6Wk0JksO4iAZKw3eqxGEIwtO98w=="], "@aws-sdk/crc64-nvme": ["@aws-sdk/crc64-nvme@3.972.8", "", { "dependencies": { "@smithy/types": "^4.14.1", "tslib": "^2.6.2" } }, "sha512-fVfUCL/Xh2zINYMPZvj+iBn6XWouQf0DAnjaWCI9MkmqXzL2Iy5FoQB8O7syFe6gN6AH1ecDDU58T51Ou0kFkA=="], @@ -76,7 +79,7 @@ "@aws-sdk/credential-provider-login": ["@aws-sdk/credential-provider-login@3.972.42", "", { "dependencies": { "@aws-sdk/core": "^3.974.12", "@aws-sdk/nested-clients": "^3.997.10", "@aws-sdk/types": "^3.973.8", "@smithy/core": "^3.24.2", "@smithy/types": "^4.14.1", "tslib": "^2.6.2" } }, "sha512-O6WkZga3kf0yqyJYd1dbeJqVhEgJx/x1UaLgtbR+XuL/YP+K5y6QTxQKL7ka9z3jnQASESKGAPnRyt4D5hQrxA=="], - "@aws-sdk/credential-provider-node": ["@aws-sdk/credential-provider-node@3.972.43", "", { "dependencies": { "@aws-sdk/credential-provider-env": "^3.972.38", "@aws-sdk/credential-provider-http": "^3.972.40", "@aws-sdk/credential-provider-ini": "^3.972.42", "@aws-sdk/credential-provider-process": "^3.972.38", "@aws-sdk/credential-provider-sso": "^3.972.42", "@aws-sdk/credential-provider-web-identity": "^3.972.42", "@aws-sdk/types": "^3.973.8", "@smithy/core": "^3.24.2", "@smithy/credential-provider-imds": "^4.3.2", "@smithy/types": "^4.14.1", "tslib": "^2.6.2" } }, "sha512-D/DJmbrWRP5BXEO3FH+ar4el+2n6OlGofiud7dQun2jES+AQEJjczenp1jBb4MBN7CpGpS8nsWGQLtuzc9tQbA=="], + "@aws-sdk/credential-provider-node": ["@aws-sdk/credential-provider-node@3.972.44", "", { "dependencies": { "@aws-sdk/credential-provider-env": "^3.972.39", "@aws-sdk/credential-provider-http": "^3.972.41", "@aws-sdk/credential-provider-ini": "^3.972.43", "@aws-sdk/credential-provider-process": "^3.972.39", "@aws-sdk/credential-provider-sso": "^3.972.43", "@aws-sdk/credential-provider-web-identity": "^3.972.43", "@aws-sdk/types": "^3.973.9", "@smithy/core": "^3.24.3", "@smithy/credential-provider-imds": "^4.3.2", "@smithy/types": "^4.14.2", "tslib": "^2.6.2" } }, "sha512-sDaBIT0yrNNIPfvlsiTCmANm07zKju+ipWODjEXgZlsjMeIJR3LVp7RDyAOzUoAsTbDfYKDWp+i5WrFiQP6rmQ=="], "@aws-sdk/credential-provider-process": ["@aws-sdk/credential-provider-process@3.972.38", "", { "dependencies": { "@aws-sdk/core": "^3.974.12", "@aws-sdk/types": "^3.973.8", "@smithy/core": "^3.24.2", "@smithy/types": "^4.14.1", "tslib": "^2.6.2" } }, "sha512-EnbYVajGgbkb24s0K1eo4VNAPV5mHIET7LSvirTaFCwkfrfaOJxtSE+wY/tJdKDS21cEYkZs2ruCaAm+W4iblg=="], @@ -86,8 +89,12 @@ "@aws-sdk/credential-providers": ["@aws-sdk/credential-providers@3.1050.0", "", { "dependencies": { "@aws-sdk/client-cognito-identity": "3.1050.0", "@aws-sdk/core": "^3.974.12", "@aws-sdk/credential-provider-cognito-identity": "^3.972.35", "@aws-sdk/credential-provider-env": "^3.972.38", "@aws-sdk/credential-provider-http": "^3.972.40", "@aws-sdk/credential-provider-ini": "^3.972.42", "@aws-sdk/credential-provider-login": "^3.972.42", "@aws-sdk/credential-provider-node": "^3.972.43", "@aws-sdk/credential-provider-process": "^3.972.38", "@aws-sdk/credential-provider-sso": "^3.972.42", "@aws-sdk/credential-provider-web-identity": "^3.972.42", "@aws-sdk/nested-clients": "^3.997.10", "@aws-sdk/types": "^3.973.8", "@smithy/core": "^3.24.2", "@smithy/credential-provider-imds": "^4.3.2", "@smithy/types": "^4.14.1", "tslib": "^2.6.2" } }, "sha512-IvGKl6+Vwf/x/wzgfWHjUwKu+0x5BeB7uwSO3QGH/9ssuAdpZR+maBUDP+HYbBgG2mU+CufN/eTelVwnWh10FQ=="], + "@aws-sdk/eventstream-handler-node": ["@aws-sdk/eventstream-handler-node@3.972.17", "", { "dependencies": { "@aws-sdk/types": "^3.973.9", "@smithy/core": "^3.24.3", "@smithy/types": "^4.14.2", "tslib": "^2.6.2" } }, "sha512-WFwdNcjchKZr7jKYgGimUZO8sSKQF/le7GGqgeCzz/lHozInE6b0gFJ1YMr8NaIeAoWJwgtrF7RE4/qMgosAdQ=="], + "@aws-sdk/middleware-bucket-endpoint": ["@aws-sdk/middleware-bucket-endpoint@3.972.14", "", { "dependencies": { "@aws-sdk/core": "^3.974.12", "@aws-sdk/types": "^3.973.8", "@smithy/core": "^3.24.2", "@smithy/types": "^4.14.1", "tslib": "^2.6.2" } }, "sha512-Aaj0d+xbo1jJquBWJP0/9V/XZRYukO3LWIRp3dOLHmoFrYKb4YZ0aLefgVHfGcNOVBS2ZTq7L/n5JcrE7DaC+Q=="], + "@aws-sdk/middleware-eventstream": ["@aws-sdk/middleware-eventstream@3.972.13", "", { "dependencies": { "@aws-sdk/types": "^3.973.9", "@smithy/core": "^3.24.3", "@smithy/types": "^4.14.2", "tslib": "^2.6.2" } }, "sha512-ECfsw7mf6G/sxNbKbGE3/h1xeIArY/yRI1IjDGYkLgDIankh+aDOtDRSr40LVlIHGL9+jEH1cVuxmbJ8NLL/1A=="], + "@aws-sdk/middleware-expect-continue": ["@aws-sdk/middleware-expect-continue@3.972.12", "", { "dependencies": { "@aws-sdk/types": "^3.973.8", "@smithy/core": "^3.24.2", "@smithy/types": "^4.14.1", "tslib": "^2.6.2" } }, "sha512-dA5pKTom/Ls9mgeyeaRBNQrRIVOLVjv4AmKOB0/e4yaiXEUy0gSz2d3liP8JHtYoCAEWySU1jWnyzwLOREN+4g=="], "@aws-sdk/middleware-flexible-checksums": ["@aws-sdk/middleware-flexible-checksums@3.974.20", "", { "dependencies": { "@aws-crypto/crc32": "5.2.0", "@aws-crypto/crc32c": "5.2.0", "@aws-crypto/util": "5.2.0", "@aws-sdk/core": "^3.974.12", "@aws-sdk/crc64-nvme": "^3.972.8", "@aws-sdk/types": "^3.973.8", "@smithy/core": "^3.24.2", "@smithy/types": "^4.14.1", "tslib": "^2.6.2" } }, "sha512-NdnMVQCR1YjIcqFAiNLdBiOwr2DyQDB2IiXQrBhzolKOv32ae4d4Ll7IzLMi04eMHiq/o/Y/GjFuVjF9HuG0QA=="], @@ -100,17 +107,19 @@ "@aws-sdk/middleware-ssec": ["@aws-sdk/middleware-ssec@3.972.10", "", { "dependencies": { "@aws-sdk/types": "^3.973.8", "@smithy/types": "^4.14.1", "tslib": "^2.6.2" } }, "sha512-Gli9A0u8EVVb+5bFDGS/QbSVg28w/wpEidg1ggVcSj65BDTdGR6punsOcVjqdiu1i42WHWo51MCvARPIIz9juw=="], + "@aws-sdk/middleware-websocket": ["@aws-sdk/middleware-websocket@3.972.21", "", { "dependencies": { "@aws-sdk/core": "^3.974.13", "@aws-sdk/types": "^3.973.9", "@smithy/core": "^3.24.3", "@smithy/fetch-http-handler": "^5.4.3", "@smithy/signature-v4": "^5.4.2", "@smithy/types": "^4.14.2", "tslib": "^2.6.2" } }, "sha512-yr+5+C7v9R55sAJ89A55Wrm7wIKPVn5cm6J3Hztnd5s/iwEUKxyJqCnIxJu4fVXgG9XBQD1Jc4rsWC1ozahJjA=="], + "@aws-sdk/nested-clients": ["@aws-sdk/nested-clients@3.997.10", "", { "dependencies": { "@aws-crypto/sha256-browser": "5.2.0", "@aws-crypto/sha256-js": "5.2.0", "@aws-sdk/core": "^3.974.12", "@aws-sdk/signature-v4-multi-region": "^3.996.27", "@aws-sdk/types": "^3.973.8", "@smithy/core": "^3.24.2", "@smithy/fetch-http-handler": "^5.4.2", "@smithy/node-http-handler": "^4.7.2", "@smithy/types": "^4.14.1", "tslib": "^2.6.2" } }, "sha512-FtQ/Bt327peZJuyo4WZSOLVUTw9ujRxntepiC7L65FxA2P82Xlq0g14T22BuqBUeMjDoxa9nvwiMHjLIfP3eUg=="], "@aws-sdk/signature-v4-multi-region": ["@aws-sdk/signature-v4-multi-region@3.996.27", "", { "dependencies": { "@aws-sdk/types": "^3.973.8", "@smithy/core": "^3.24.2", "@smithy/signature-v4": "^5.4.2", "@smithy/types": "^4.14.1", "tslib": "^2.6.2" } }, "sha512-0Phbz4t6HI3D3skxvG2uI+VWU034/nSIw1T8d+FPzzQG9EQTrw94o9mOKO2Gv3n3Oc8P7JD7RAUxkoneLWv5Eg=="], - "@aws-sdk/token-providers": ["@aws-sdk/token-providers@3.1049.0", "", { "dependencies": { "@aws-sdk/core": "^3.974.12", "@aws-sdk/nested-clients": "^3.997.10", "@aws-sdk/types": "^3.973.8", "@smithy/core": "^3.24.2", "@smithy/types": "^4.14.1", "tslib": "^2.6.2" } }, "sha512-r7+d0lQMTHKypkmaF5jRTBYLYHCUHzt3gaVoN9SidLhQeWhCmHk3AKrboDTpPF5b7Pt7vKu3+oeMjznM2Eu1ow=="], + "@aws-sdk/token-providers": ["@aws-sdk/token-providers@3.1053.0", "", { "dependencies": { "@aws-sdk/core": "^3.974.13", "@aws-sdk/nested-clients": "^3.997.11", "@aws-sdk/types": "^3.973.9", "@smithy/core": "^3.24.3", "@smithy/types": "^4.14.2", "tslib": "^2.6.2" } }, "sha512-laSwHLYMMrXQRl2mFDXszF43m/F4pKWyGr7hCLfJmV8rn8c6CnI/hp/bf/Gn7gLcjz0SY4evd7SBpqtnIhzA/A=="], - "@aws-sdk/types": ["@aws-sdk/types@3.973.8", "", { "dependencies": { "@smithy/types": "^4.14.1", "tslib": "^2.6.2" } }, "sha512-gjlAdtHMbtR9X5iIhVUvbVcy55KnznpC6bkDUWW9z915bi0ckdUr5cjf16Kp6xq0bP5HBD2xzgbL9F9Quv5vUw=="], + "@aws-sdk/types": ["@aws-sdk/types@3.973.9", "", { "dependencies": { "@smithy/types": "^4.14.2", "tslib": "^2.6.2" } }, "sha512-kuBfgQVdcz5Bmapc4A13YbpVw/pXkesfhetcFYwbntqas8sF41OHyd4o28+/TG2ZQdHBsv90Lsu5y6oitvYCdg=="], "@aws-sdk/util-locate-window": ["@aws-sdk/util-locate-window@3.965.5", "", { "dependencies": { "tslib": "^2.6.2" } }, "sha512-WhlJNNINQB+9qtLtZJcpQdgZw3SCDCpXdUJP7cToGwHbCWCnRckGlc6Bx/OhWwIYFNAn+FIydY8SZ0QmVu3xTQ=="], - "@aws-sdk/xml-builder": ["@aws-sdk/xml-builder@3.972.24", "", { "dependencies": { "@nodable/entities": "2.1.0", "@smithy/types": "^4.14.1", "fast-xml-parser": "5.7.3", "tslib": "^2.6.2" } }, "sha512-V8z5YcDPfsvzrBlj0xR1vhRtocblhYbqdreCJB/voGd4Sr5zjNAeWxexbnqVtskTJe0vFb5KMqbSL++ePl+zRw=="], + "@aws-sdk/xml-builder": ["@aws-sdk/xml-builder@3.972.25", "", { "dependencies": { "@nodable/entities": "2.1.0", "@smithy/types": "^4.14.2", "fast-xml-parser": "5.7.3", "tslib": "^2.6.2" } }, "sha512-GH+Kjz4nPKWKHnsiQpnhP1MJdTGIcK4rAka6tzakgjjUkVgNsmPeEbbRAf09SzS1hjGu6duGHCBsxYke0BhHjQ=="], "@aws/lambda-invoke-store": ["@aws/lambda-invoke-store@0.2.4", "", {}, "sha512-iY8yvjE0y651BixKNPgmv1WrQc+GZ142sb0z4gYnChDDY2YqI4P/jsSopBWrKfAt7LOJAkOXt7rC/hms+WclQQ=="], @@ -578,6 +587,128 @@ "zod": ["zod@4.4.3", "", {}, "sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ=="], + "@aws-crypto/crc32/@aws-sdk/types": ["@aws-sdk/types@3.973.8", "", { "dependencies": { "@smithy/types": "^4.14.1", "tslib": "^2.6.2" } }, "sha512-gjlAdtHMbtR9X5iIhVUvbVcy55KnznpC6bkDUWW9z915bi0ckdUr5cjf16Kp6xq0bP5HBD2xzgbL9F9Quv5vUw=="], + + "@aws-crypto/crc32c/@aws-sdk/types": ["@aws-sdk/types@3.973.8", "", { "dependencies": { "@smithy/types": "^4.14.1", "tslib": "^2.6.2" } }, "sha512-gjlAdtHMbtR9X5iIhVUvbVcy55KnznpC6bkDUWW9z915bi0ckdUr5cjf16Kp6xq0bP5HBD2xzgbL9F9Quv5vUw=="], + + "@aws-crypto/sha1-browser/@aws-sdk/types": ["@aws-sdk/types@3.973.8", "", { "dependencies": { "@smithy/types": "^4.14.1", "tslib": "^2.6.2" } }, "sha512-gjlAdtHMbtR9X5iIhVUvbVcy55KnznpC6bkDUWW9z915bi0ckdUr5cjf16Kp6xq0bP5HBD2xzgbL9F9Quv5vUw=="], + + "@aws-crypto/sha256-browser/@aws-sdk/types": ["@aws-sdk/types@3.973.8", "", { "dependencies": { "@smithy/types": "^4.14.1", "tslib": "^2.6.2" } }, "sha512-gjlAdtHMbtR9X5iIhVUvbVcy55KnznpC6bkDUWW9z915bi0ckdUr5cjf16Kp6xq0bP5HBD2xzgbL9F9Quv5vUw=="], + + "@aws-crypto/sha256-js/@aws-sdk/types": ["@aws-sdk/types@3.973.8", "", { "dependencies": { "@smithy/types": "^4.14.1", "tslib": "^2.6.2" } }, "sha512-gjlAdtHMbtR9X5iIhVUvbVcy55KnznpC6bkDUWW9z915bi0ckdUr5cjf16Kp6xq0bP5HBD2xzgbL9F9Quv5vUw=="], + + "@aws-crypto/util/@aws-sdk/types": ["@aws-sdk/types@3.973.8", "", { "dependencies": { "@smithy/types": "^4.14.1", "tslib": "^2.6.2" } }, "sha512-gjlAdtHMbtR9X5iIhVUvbVcy55KnznpC6bkDUWW9z915bi0ckdUr5cjf16Kp6xq0bP5HBD2xzgbL9F9Quv5vUw=="], + + "@aws-sdk/client-cognito-identity/@aws-sdk/core": ["@aws-sdk/core@3.974.12", "", { "dependencies": { "@aws-sdk/types": "^3.973.8", "@aws-sdk/xml-builder": "^3.972.24", "@aws/lambda-invoke-store": "^0.2.2", "@smithy/core": "^3.24.2", "@smithy/signature-v4": "^5.4.2", "@smithy/types": "^4.14.1", "bowser": "^2.11.0", "tslib": "^2.6.2" } }, "sha512-qrqgioqYFjwR6LatVNS1L2Vk++EwRIxqSQXPKNv5Ofux2D8UNgqMQ1znnMyEImXquVPTtbf71fc128pvmU6y9A=="], + + "@aws-sdk/client-cognito-identity/@aws-sdk/credential-provider-node": ["@aws-sdk/credential-provider-node@3.972.43", "", { "dependencies": { "@aws-sdk/credential-provider-env": "^3.972.38", "@aws-sdk/credential-provider-http": "^3.972.40", "@aws-sdk/credential-provider-ini": "^3.972.42", "@aws-sdk/credential-provider-process": "^3.972.38", "@aws-sdk/credential-provider-sso": "^3.972.42", "@aws-sdk/credential-provider-web-identity": "^3.972.42", "@aws-sdk/types": "^3.973.8", "@smithy/core": "^3.24.2", "@smithy/credential-provider-imds": "^4.3.2", "@smithy/types": "^4.14.1", "tslib": "^2.6.2" } }, "sha512-D/DJmbrWRP5BXEO3FH+ar4el+2n6OlGofiud7dQun2jES+AQEJjczenp1jBb4MBN7CpGpS8nsWGQLtuzc9tQbA=="], + + "@aws-sdk/client-cognito-identity/@aws-sdk/types": ["@aws-sdk/types@3.973.8", "", { "dependencies": { "@smithy/types": "^4.14.1", "tslib": "^2.6.2" } }, "sha512-gjlAdtHMbtR9X5iIhVUvbVcy55KnznpC6bkDUWW9z915bi0ckdUr5cjf16Kp6xq0bP5HBD2xzgbL9F9Quv5vUw=="], + + "@aws-sdk/client-ec2/@aws-sdk/core": ["@aws-sdk/core@3.974.12", "", { "dependencies": { "@aws-sdk/types": "^3.973.8", "@aws-sdk/xml-builder": "^3.972.24", "@aws/lambda-invoke-store": "^0.2.2", "@smithy/core": "^3.24.2", "@smithy/signature-v4": "^5.4.2", "@smithy/types": "^4.14.1", "bowser": "^2.11.0", "tslib": "^2.6.2" } }, "sha512-qrqgioqYFjwR6LatVNS1L2Vk++EwRIxqSQXPKNv5Ofux2D8UNgqMQ1znnMyEImXquVPTtbf71fc128pvmU6y9A=="], + + "@aws-sdk/client-ec2/@aws-sdk/credential-provider-node": ["@aws-sdk/credential-provider-node@3.972.43", "", { "dependencies": { "@aws-sdk/credential-provider-env": "^3.972.38", "@aws-sdk/credential-provider-http": "^3.972.40", "@aws-sdk/credential-provider-ini": "^3.972.42", "@aws-sdk/credential-provider-process": "^3.972.38", "@aws-sdk/credential-provider-sso": "^3.972.42", "@aws-sdk/credential-provider-web-identity": "^3.972.42", "@aws-sdk/types": "^3.973.8", "@smithy/core": "^3.24.2", "@smithy/credential-provider-imds": "^4.3.2", "@smithy/types": "^4.14.1", "tslib": "^2.6.2" } }, "sha512-D/DJmbrWRP5BXEO3FH+ar4el+2n6OlGofiud7dQun2jES+AQEJjczenp1jBb4MBN7CpGpS8nsWGQLtuzc9tQbA=="], + + "@aws-sdk/client-ec2/@aws-sdk/types": ["@aws-sdk/types@3.973.8", "", { "dependencies": { "@smithy/types": "^4.14.1", "tslib": "^2.6.2" } }, "sha512-gjlAdtHMbtR9X5iIhVUvbVcy55KnznpC6bkDUWW9z915bi0ckdUr5cjf16Kp6xq0bP5HBD2xzgbL9F9Quv5vUw=="], + + "@aws-sdk/client-s3/@aws-sdk/core": ["@aws-sdk/core@3.974.12", "", { "dependencies": { "@aws-sdk/types": "^3.973.8", "@aws-sdk/xml-builder": "^3.972.24", "@aws/lambda-invoke-store": "^0.2.2", "@smithy/core": "^3.24.2", "@smithy/signature-v4": "^5.4.2", "@smithy/types": "^4.14.1", "bowser": "^2.11.0", "tslib": "^2.6.2" } }, "sha512-qrqgioqYFjwR6LatVNS1L2Vk++EwRIxqSQXPKNv5Ofux2D8UNgqMQ1znnMyEImXquVPTtbf71fc128pvmU6y9A=="], + + "@aws-sdk/client-s3/@aws-sdk/credential-provider-node": ["@aws-sdk/credential-provider-node@3.972.43", "", { "dependencies": { "@aws-sdk/credential-provider-env": "^3.972.38", "@aws-sdk/credential-provider-http": "^3.972.40", "@aws-sdk/credential-provider-ini": "^3.972.42", "@aws-sdk/credential-provider-process": "^3.972.38", "@aws-sdk/credential-provider-sso": "^3.972.42", "@aws-sdk/credential-provider-web-identity": "^3.972.42", "@aws-sdk/types": "^3.973.8", "@smithy/core": "^3.24.2", "@smithy/credential-provider-imds": "^4.3.2", "@smithy/types": "^4.14.1", "tslib": "^2.6.2" } }, "sha512-D/DJmbrWRP5BXEO3FH+ar4el+2n6OlGofiud7dQun2jES+AQEJjczenp1jBb4MBN7CpGpS8nsWGQLtuzc9tQbA=="], + + "@aws-sdk/client-s3/@aws-sdk/types": ["@aws-sdk/types@3.973.8", "", { "dependencies": { "@smithy/types": "^4.14.1", "tslib": "^2.6.2" } }, "sha512-gjlAdtHMbtR9X5iIhVUvbVcy55KnznpC6bkDUWW9z915bi0ckdUr5cjf16Kp6xq0bP5HBD2xzgbL9F9Quv5vUw=="], + + "@aws-sdk/client-secrets-manager/@aws-sdk/core": ["@aws-sdk/core@3.974.12", "", { "dependencies": { "@aws-sdk/types": "^3.973.8", "@aws-sdk/xml-builder": "^3.972.24", "@aws/lambda-invoke-store": "^0.2.2", "@smithy/core": "^3.24.2", "@smithy/signature-v4": "^5.4.2", "@smithy/types": "^4.14.1", "bowser": "^2.11.0", "tslib": "^2.6.2" } }, "sha512-qrqgioqYFjwR6LatVNS1L2Vk++EwRIxqSQXPKNv5Ofux2D8UNgqMQ1znnMyEImXquVPTtbf71fc128pvmU6y9A=="], + + "@aws-sdk/client-secrets-manager/@aws-sdk/credential-provider-node": ["@aws-sdk/credential-provider-node@3.972.43", "", { "dependencies": { "@aws-sdk/credential-provider-env": "^3.972.38", "@aws-sdk/credential-provider-http": "^3.972.40", "@aws-sdk/credential-provider-ini": "^3.972.42", "@aws-sdk/credential-provider-process": "^3.972.38", "@aws-sdk/credential-provider-sso": "^3.972.42", "@aws-sdk/credential-provider-web-identity": "^3.972.42", "@aws-sdk/types": "^3.973.8", "@smithy/core": "^3.24.2", "@smithy/credential-provider-imds": "^4.3.2", "@smithy/types": "^4.14.1", "tslib": "^2.6.2" } }, "sha512-D/DJmbrWRP5BXEO3FH+ar4el+2n6OlGofiud7dQun2jES+AQEJjczenp1jBb4MBN7CpGpS8nsWGQLtuzc9tQbA=="], + + "@aws-sdk/client-secrets-manager/@aws-sdk/types": ["@aws-sdk/types@3.973.8", "", { "dependencies": { "@smithy/types": "^4.14.1", "tslib": "^2.6.2" } }, "sha512-gjlAdtHMbtR9X5iIhVUvbVcy55KnznpC6bkDUWW9z915bi0ckdUr5cjf16Kp6xq0bP5HBD2xzgbL9F9Quv5vUw=="], + + "@aws-sdk/client-sts/@aws-sdk/core": ["@aws-sdk/core@3.974.12", "", { "dependencies": { "@aws-sdk/types": "^3.973.8", "@aws-sdk/xml-builder": "^3.972.24", "@aws/lambda-invoke-store": "^0.2.2", "@smithy/core": "^3.24.2", "@smithy/signature-v4": "^5.4.2", "@smithy/types": "^4.14.1", "bowser": "^2.11.0", "tslib": "^2.6.2" } }, "sha512-qrqgioqYFjwR6LatVNS1L2Vk++EwRIxqSQXPKNv5Ofux2D8UNgqMQ1znnMyEImXquVPTtbf71fc128pvmU6y9A=="], + + "@aws-sdk/client-sts/@aws-sdk/credential-provider-node": ["@aws-sdk/credential-provider-node@3.972.43", "", { "dependencies": { "@aws-sdk/credential-provider-env": "^3.972.38", "@aws-sdk/credential-provider-http": "^3.972.40", "@aws-sdk/credential-provider-ini": "^3.972.42", "@aws-sdk/credential-provider-process": "^3.972.38", "@aws-sdk/credential-provider-sso": "^3.972.42", "@aws-sdk/credential-provider-web-identity": "^3.972.42", "@aws-sdk/types": "^3.973.8", "@smithy/core": "^3.24.2", "@smithy/credential-provider-imds": "^4.3.2", "@smithy/types": "^4.14.1", "tslib": "^2.6.2" } }, "sha512-D/DJmbrWRP5BXEO3FH+ar4el+2n6OlGofiud7dQun2jES+AQEJjczenp1jBb4MBN7CpGpS8nsWGQLtuzc9tQbA=="], + + "@aws-sdk/client-sts/@aws-sdk/types": ["@aws-sdk/types@3.973.8", "", { "dependencies": { "@smithy/types": "^4.14.1", "tslib": "^2.6.2" } }, "sha512-gjlAdtHMbtR9X5iIhVUvbVcy55KnznpC6bkDUWW9z915bi0ckdUr5cjf16Kp6xq0bP5HBD2xzgbL9F9Quv5vUw=="], + + "@aws-sdk/credential-provider-cognito-identity/@aws-sdk/types": ["@aws-sdk/types@3.973.8", "", { "dependencies": { "@smithy/types": "^4.14.1", "tslib": "^2.6.2" } }, "sha512-gjlAdtHMbtR9X5iIhVUvbVcy55KnznpC6bkDUWW9z915bi0ckdUr5cjf16Kp6xq0bP5HBD2xzgbL9F9Quv5vUw=="], + + "@aws-sdk/credential-provider-env/@aws-sdk/core": ["@aws-sdk/core@3.974.12", "", { "dependencies": { "@aws-sdk/types": "^3.973.8", "@aws-sdk/xml-builder": "^3.972.24", "@aws/lambda-invoke-store": "^0.2.2", "@smithy/core": "^3.24.2", "@smithy/signature-v4": "^5.4.2", "@smithy/types": "^4.14.1", "bowser": "^2.11.0", "tslib": "^2.6.2" } }, "sha512-qrqgioqYFjwR6LatVNS1L2Vk++EwRIxqSQXPKNv5Ofux2D8UNgqMQ1znnMyEImXquVPTtbf71fc128pvmU6y9A=="], + + "@aws-sdk/credential-provider-env/@aws-sdk/types": ["@aws-sdk/types@3.973.8", "", { "dependencies": { "@smithy/types": "^4.14.1", "tslib": "^2.6.2" } }, "sha512-gjlAdtHMbtR9X5iIhVUvbVcy55KnznpC6bkDUWW9z915bi0ckdUr5cjf16Kp6xq0bP5HBD2xzgbL9F9Quv5vUw=="], + + "@aws-sdk/credential-provider-http/@aws-sdk/core": ["@aws-sdk/core@3.974.12", "", { "dependencies": { "@aws-sdk/types": "^3.973.8", "@aws-sdk/xml-builder": "^3.972.24", "@aws/lambda-invoke-store": "^0.2.2", "@smithy/core": "^3.24.2", "@smithy/signature-v4": "^5.4.2", "@smithy/types": "^4.14.1", "bowser": "^2.11.0", "tslib": "^2.6.2" } }, "sha512-qrqgioqYFjwR6LatVNS1L2Vk++EwRIxqSQXPKNv5Ofux2D8UNgqMQ1znnMyEImXquVPTtbf71fc128pvmU6y9A=="], + + "@aws-sdk/credential-provider-http/@aws-sdk/types": ["@aws-sdk/types@3.973.8", "", { "dependencies": { "@smithy/types": "^4.14.1", "tslib": "^2.6.2" } }, "sha512-gjlAdtHMbtR9X5iIhVUvbVcy55KnznpC6bkDUWW9z915bi0ckdUr5cjf16Kp6xq0bP5HBD2xzgbL9F9Quv5vUw=="], + + "@aws-sdk/credential-provider-ini/@aws-sdk/core": ["@aws-sdk/core@3.974.12", "", { "dependencies": { "@aws-sdk/types": "^3.973.8", "@aws-sdk/xml-builder": "^3.972.24", "@aws/lambda-invoke-store": "^0.2.2", "@smithy/core": "^3.24.2", "@smithy/signature-v4": "^5.4.2", "@smithy/types": "^4.14.1", "bowser": "^2.11.0", "tslib": "^2.6.2" } }, "sha512-qrqgioqYFjwR6LatVNS1L2Vk++EwRIxqSQXPKNv5Ofux2D8UNgqMQ1znnMyEImXquVPTtbf71fc128pvmU6y9A=="], + + "@aws-sdk/credential-provider-ini/@aws-sdk/types": ["@aws-sdk/types@3.973.8", "", { "dependencies": { "@smithy/types": "^4.14.1", "tslib": "^2.6.2" } }, "sha512-gjlAdtHMbtR9X5iIhVUvbVcy55KnznpC6bkDUWW9z915bi0ckdUr5cjf16Kp6xq0bP5HBD2xzgbL9F9Quv5vUw=="], + + "@aws-sdk/credential-provider-login/@aws-sdk/core": ["@aws-sdk/core@3.974.12", "", { "dependencies": { "@aws-sdk/types": "^3.973.8", "@aws-sdk/xml-builder": "^3.972.24", "@aws/lambda-invoke-store": "^0.2.2", "@smithy/core": "^3.24.2", "@smithy/signature-v4": "^5.4.2", "@smithy/types": "^4.14.1", "bowser": "^2.11.0", "tslib": "^2.6.2" } }, "sha512-qrqgioqYFjwR6LatVNS1L2Vk++EwRIxqSQXPKNv5Ofux2D8UNgqMQ1znnMyEImXquVPTtbf71fc128pvmU6y9A=="], + + "@aws-sdk/credential-provider-login/@aws-sdk/types": ["@aws-sdk/types@3.973.8", "", { "dependencies": { "@smithy/types": "^4.14.1", "tslib": "^2.6.2" } }, "sha512-gjlAdtHMbtR9X5iIhVUvbVcy55KnznpC6bkDUWW9z915bi0ckdUr5cjf16Kp6xq0bP5HBD2xzgbL9F9Quv5vUw=="], + + "@aws-sdk/credential-provider-node/@aws-sdk/credential-provider-env": ["@aws-sdk/credential-provider-env@3.972.39", "", { "dependencies": { "@aws-sdk/core": "^3.974.13", "@aws-sdk/types": "^3.973.9", "@smithy/core": "^3.24.3", "@smithy/types": "^4.14.2", "tslib": "^2.6.2" } }, "sha512-29wX9zpAvEt1vcj0psha+y6ygBHy2V/S72mp6e7q0KARLWXq+pwE/lR6qGkwknQvruh52lXvlqZIga8Hdxkucw=="], + + "@aws-sdk/credential-provider-node/@aws-sdk/credential-provider-http": ["@aws-sdk/credential-provider-http@3.972.41", "", { "dependencies": { "@aws-sdk/core": "^3.974.13", "@aws-sdk/types": "^3.973.9", "@smithy/core": "^3.24.3", "@smithy/fetch-http-handler": "^5.4.3", "@smithy/node-http-handler": "^4.7.3", "@smithy/types": "^4.14.2", "tslib": "^2.6.2" } }, "sha512-IA3CQTjtJkb6u1H4mE4936c8OPBMa9Jggtwe8U2Mqw/vvb/tZ5Ebd0mcZcX0uKWQhOyYo/+qNIwkV5Xh+FeJJA=="], + + "@aws-sdk/credential-provider-node/@aws-sdk/credential-provider-ini": ["@aws-sdk/credential-provider-ini@3.972.43", "", { "dependencies": { "@aws-sdk/core": "^3.974.13", "@aws-sdk/credential-provider-env": "^3.972.39", "@aws-sdk/credential-provider-http": "^3.972.41", "@aws-sdk/credential-provider-login": "^3.972.43", "@aws-sdk/credential-provider-process": "^3.972.39", "@aws-sdk/credential-provider-sso": "^3.972.43", "@aws-sdk/credential-provider-web-identity": "^3.972.43", "@aws-sdk/nested-clients": "^3.997.11", "@aws-sdk/types": "^3.973.9", "@smithy/core": "^3.24.3", "@smithy/credential-provider-imds": "^4.3.2", "@smithy/types": "^4.14.2", "tslib": "^2.6.2" } }, "sha512-4mzII+3mZEVXXE1xzrLQrCJL7/r62A63bA6SVzZoNL5rqCJghpf+xgGltVrIBBs0n+mOZBKrQl2tRREtvZ5l6A=="], + + "@aws-sdk/credential-provider-node/@aws-sdk/credential-provider-process": ["@aws-sdk/credential-provider-process@3.972.39", "", { "dependencies": { "@aws-sdk/core": "^3.974.13", "@aws-sdk/types": "^3.973.9", "@smithy/core": "^3.24.3", "@smithy/types": "^4.14.2", "tslib": "^2.6.2" } }, "sha512-2k/amBifLd75eXNwgvPw/2lKYSQ3NhvHQgkVKVjfUq13/eJ3JRtHmznuFenn74OK3sSfp4SMy1YB2w+UVXoKqA=="], + + "@aws-sdk/credential-provider-node/@aws-sdk/credential-provider-sso": ["@aws-sdk/credential-provider-sso@3.972.43", "", { "dependencies": { "@aws-sdk/core": "^3.974.13", "@aws-sdk/nested-clients": "^3.997.11", "@aws-sdk/token-providers": "3.1052.0", "@aws-sdk/types": "^3.973.9", "@smithy/core": "^3.24.3", "@smithy/types": "^4.14.2", "tslib": "^2.6.2" } }, "sha512-LPc3+Y4vhH1T4x6CMqwCM6hk5+SRf/Lwmgm8INm95wxTtIRHcMwQUVkDzWu4Iw/RSncxYM2BC01OrYbxOPZvyg=="], + + "@aws-sdk/credential-provider-node/@aws-sdk/credential-provider-web-identity": ["@aws-sdk/credential-provider-web-identity@3.972.43", "", { "dependencies": { "@aws-sdk/core": "^3.974.13", "@aws-sdk/nested-clients": "^3.997.11", "@aws-sdk/types": "^3.973.9", "@smithy/core": "^3.24.3", "@smithy/types": "^4.14.2", "tslib": "^2.6.2" } }, "sha512-wQtL34lUD/09VXjwAUo2T+I3aEXRDxMB3DKmTJL/Zj0Gi6sLDTrVhae1XVt01yzkquOWajI/sZW72JGDZ1ciTw=="], + + "@aws-sdk/credential-provider-process/@aws-sdk/core": ["@aws-sdk/core@3.974.12", "", { "dependencies": { "@aws-sdk/types": "^3.973.8", "@aws-sdk/xml-builder": "^3.972.24", "@aws/lambda-invoke-store": "^0.2.2", "@smithy/core": "^3.24.2", "@smithy/signature-v4": "^5.4.2", "@smithy/types": "^4.14.1", "bowser": "^2.11.0", "tslib": "^2.6.2" } }, "sha512-qrqgioqYFjwR6LatVNS1L2Vk++EwRIxqSQXPKNv5Ofux2D8UNgqMQ1znnMyEImXquVPTtbf71fc128pvmU6y9A=="], + + "@aws-sdk/credential-provider-process/@aws-sdk/types": ["@aws-sdk/types@3.973.8", "", { "dependencies": { "@smithy/types": "^4.14.1", "tslib": "^2.6.2" } }, "sha512-gjlAdtHMbtR9X5iIhVUvbVcy55KnznpC6bkDUWW9z915bi0ckdUr5cjf16Kp6xq0bP5HBD2xzgbL9F9Quv5vUw=="], + + "@aws-sdk/credential-provider-sso/@aws-sdk/core": ["@aws-sdk/core@3.974.12", "", { "dependencies": { "@aws-sdk/types": "^3.973.8", "@aws-sdk/xml-builder": "^3.972.24", "@aws/lambda-invoke-store": "^0.2.2", "@smithy/core": "^3.24.2", "@smithy/signature-v4": "^5.4.2", "@smithy/types": "^4.14.1", "bowser": "^2.11.0", "tslib": "^2.6.2" } }, "sha512-qrqgioqYFjwR6LatVNS1L2Vk++EwRIxqSQXPKNv5Ofux2D8UNgqMQ1znnMyEImXquVPTtbf71fc128pvmU6y9A=="], + + "@aws-sdk/credential-provider-sso/@aws-sdk/token-providers": ["@aws-sdk/token-providers@3.1049.0", "", { "dependencies": { "@aws-sdk/core": "^3.974.12", "@aws-sdk/nested-clients": "^3.997.10", "@aws-sdk/types": "^3.973.8", "@smithy/core": "^3.24.2", "@smithy/types": "^4.14.1", "tslib": "^2.6.2" } }, "sha512-r7+d0lQMTHKypkmaF5jRTBYLYHCUHzt3gaVoN9SidLhQeWhCmHk3AKrboDTpPF5b7Pt7vKu3+oeMjznM2Eu1ow=="], + + "@aws-sdk/credential-provider-sso/@aws-sdk/types": ["@aws-sdk/types@3.973.8", "", { "dependencies": { "@smithy/types": "^4.14.1", "tslib": "^2.6.2" } }, "sha512-gjlAdtHMbtR9X5iIhVUvbVcy55KnznpC6bkDUWW9z915bi0ckdUr5cjf16Kp6xq0bP5HBD2xzgbL9F9Quv5vUw=="], + + "@aws-sdk/credential-provider-web-identity/@aws-sdk/core": ["@aws-sdk/core@3.974.12", "", { "dependencies": { "@aws-sdk/types": "^3.973.8", "@aws-sdk/xml-builder": "^3.972.24", "@aws/lambda-invoke-store": "^0.2.2", "@smithy/core": "^3.24.2", "@smithy/signature-v4": "^5.4.2", "@smithy/types": "^4.14.1", "bowser": "^2.11.0", "tslib": "^2.6.2" } }, "sha512-qrqgioqYFjwR6LatVNS1L2Vk++EwRIxqSQXPKNv5Ofux2D8UNgqMQ1znnMyEImXquVPTtbf71fc128pvmU6y9A=="], + + "@aws-sdk/credential-provider-web-identity/@aws-sdk/types": ["@aws-sdk/types@3.973.8", "", { "dependencies": { "@smithy/types": "^4.14.1", "tslib": "^2.6.2" } }, "sha512-gjlAdtHMbtR9X5iIhVUvbVcy55KnznpC6bkDUWW9z915bi0ckdUr5cjf16Kp6xq0bP5HBD2xzgbL9F9Quv5vUw=="], + + "@aws-sdk/credential-providers/@aws-sdk/core": ["@aws-sdk/core@3.974.12", "", { "dependencies": { "@aws-sdk/types": "^3.973.8", "@aws-sdk/xml-builder": "^3.972.24", "@aws/lambda-invoke-store": "^0.2.2", "@smithy/core": "^3.24.2", "@smithy/signature-v4": "^5.4.2", "@smithy/types": "^4.14.1", "bowser": "^2.11.0", "tslib": "^2.6.2" } }, "sha512-qrqgioqYFjwR6LatVNS1L2Vk++EwRIxqSQXPKNv5Ofux2D8UNgqMQ1znnMyEImXquVPTtbf71fc128pvmU6y9A=="], + + "@aws-sdk/credential-providers/@aws-sdk/credential-provider-node": ["@aws-sdk/credential-provider-node@3.972.43", "", { "dependencies": { "@aws-sdk/credential-provider-env": "^3.972.38", "@aws-sdk/credential-provider-http": "^3.972.40", "@aws-sdk/credential-provider-ini": "^3.972.42", "@aws-sdk/credential-provider-process": "^3.972.38", "@aws-sdk/credential-provider-sso": "^3.972.42", "@aws-sdk/credential-provider-web-identity": "^3.972.42", "@aws-sdk/types": "^3.973.8", "@smithy/core": "^3.24.2", "@smithy/credential-provider-imds": "^4.3.2", "@smithy/types": "^4.14.1", "tslib": "^2.6.2" } }, "sha512-D/DJmbrWRP5BXEO3FH+ar4el+2n6OlGofiud7dQun2jES+AQEJjczenp1jBb4MBN7CpGpS8nsWGQLtuzc9tQbA=="], + + "@aws-sdk/credential-providers/@aws-sdk/types": ["@aws-sdk/types@3.973.8", "", { "dependencies": { "@smithy/types": "^4.14.1", "tslib": "^2.6.2" } }, "sha512-gjlAdtHMbtR9X5iIhVUvbVcy55KnznpC6bkDUWW9z915bi0ckdUr5cjf16Kp6xq0bP5HBD2xzgbL9F9Quv5vUw=="], + + "@aws-sdk/middleware-bucket-endpoint/@aws-sdk/core": ["@aws-sdk/core@3.974.12", "", { "dependencies": { "@aws-sdk/types": "^3.973.8", "@aws-sdk/xml-builder": "^3.972.24", "@aws/lambda-invoke-store": "^0.2.2", "@smithy/core": "^3.24.2", "@smithy/signature-v4": "^5.4.2", "@smithy/types": "^4.14.1", "bowser": "^2.11.0", "tslib": "^2.6.2" } }, "sha512-qrqgioqYFjwR6LatVNS1L2Vk++EwRIxqSQXPKNv5Ofux2D8UNgqMQ1znnMyEImXquVPTtbf71fc128pvmU6y9A=="], + + "@aws-sdk/middleware-bucket-endpoint/@aws-sdk/types": ["@aws-sdk/types@3.973.8", "", { "dependencies": { "@smithy/types": "^4.14.1", "tslib": "^2.6.2" } }, "sha512-gjlAdtHMbtR9X5iIhVUvbVcy55KnznpC6bkDUWW9z915bi0ckdUr5cjf16Kp6xq0bP5HBD2xzgbL9F9Quv5vUw=="], + + "@aws-sdk/middleware-expect-continue/@aws-sdk/types": ["@aws-sdk/types@3.973.8", "", { "dependencies": { "@smithy/types": "^4.14.1", "tslib": "^2.6.2" } }, "sha512-gjlAdtHMbtR9X5iIhVUvbVcy55KnznpC6bkDUWW9z915bi0ckdUr5cjf16Kp6xq0bP5HBD2xzgbL9F9Quv5vUw=="], + + "@aws-sdk/middleware-flexible-checksums/@aws-sdk/core": ["@aws-sdk/core@3.974.12", "", { "dependencies": { "@aws-sdk/types": "^3.973.8", "@aws-sdk/xml-builder": "^3.972.24", "@aws/lambda-invoke-store": "^0.2.2", "@smithy/core": "^3.24.2", "@smithy/signature-v4": "^5.4.2", "@smithy/types": "^4.14.1", "bowser": "^2.11.0", "tslib": "^2.6.2" } }, "sha512-qrqgioqYFjwR6LatVNS1L2Vk++EwRIxqSQXPKNv5Ofux2D8UNgqMQ1znnMyEImXquVPTtbf71fc128pvmU6y9A=="], + + "@aws-sdk/middleware-flexible-checksums/@aws-sdk/types": ["@aws-sdk/types@3.973.8", "", { "dependencies": { "@smithy/types": "^4.14.1", "tslib": "^2.6.2" } }, "sha512-gjlAdtHMbtR9X5iIhVUvbVcy55KnznpC6bkDUWW9z915bi0ckdUr5cjf16Kp6xq0bP5HBD2xzgbL9F9Quv5vUw=="], + + "@aws-sdk/middleware-location-constraint/@aws-sdk/types": ["@aws-sdk/types@3.973.8", "", { "dependencies": { "@smithy/types": "^4.14.1", "tslib": "^2.6.2" } }, "sha512-gjlAdtHMbtR9X5iIhVUvbVcy55KnznpC6bkDUWW9z915bi0ckdUr5cjf16Kp6xq0bP5HBD2xzgbL9F9Quv5vUw=="], + + "@aws-sdk/middleware-sdk-ec2/@aws-sdk/core": ["@aws-sdk/core@3.974.12", "", { "dependencies": { "@aws-sdk/types": "^3.973.8", "@aws-sdk/xml-builder": "^3.972.24", "@aws/lambda-invoke-store": "^0.2.2", "@smithy/core": "^3.24.2", "@smithy/signature-v4": "^5.4.2", "@smithy/types": "^4.14.1", "bowser": "^2.11.0", "tslib": "^2.6.2" } }, "sha512-qrqgioqYFjwR6LatVNS1L2Vk++EwRIxqSQXPKNv5Ofux2D8UNgqMQ1znnMyEImXquVPTtbf71fc128pvmU6y9A=="], + + "@aws-sdk/middleware-sdk-ec2/@aws-sdk/types": ["@aws-sdk/types@3.973.8", "", { "dependencies": { "@smithy/types": "^4.14.1", "tslib": "^2.6.2" } }, "sha512-gjlAdtHMbtR9X5iIhVUvbVcy55KnznpC6bkDUWW9z915bi0ckdUr5cjf16Kp6xq0bP5HBD2xzgbL9F9Quv5vUw=="], + + "@aws-sdk/middleware-sdk-s3/@aws-sdk/core": ["@aws-sdk/core@3.974.12", "", { "dependencies": { "@aws-sdk/types": "^3.973.8", "@aws-sdk/xml-builder": "^3.972.24", "@aws/lambda-invoke-store": "^0.2.2", "@smithy/core": "^3.24.2", "@smithy/signature-v4": "^5.4.2", "@smithy/types": "^4.14.1", "bowser": "^2.11.0", "tslib": "^2.6.2" } }, "sha512-qrqgioqYFjwR6LatVNS1L2Vk++EwRIxqSQXPKNv5Ofux2D8UNgqMQ1znnMyEImXquVPTtbf71fc128pvmU6y9A=="], + + "@aws-sdk/middleware-sdk-s3/@aws-sdk/types": ["@aws-sdk/types@3.973.8", "", { "dependencies": { "@smithy/types": "^4.14.1", "tslib": "^2.6.2" } }, "sha512-gjlAdtHMbtR9X5iIhVUvbVcy55KnznpC6bkDUWW9z915bi0ckdUr5cjf16Kp6xq0bP5HBD2xzgbL9F9Quv5vUw=="], + + "@aws-sdk/middleware-ssec/@aws-sdk/types": ["@aws-sdk/types@3.973.8", "", { "dependencies": { "@smithy/types": "^4.14.1", "tslib": "^2.6.2" } }, "sha512-gjlAdtHMbtR9X5iIhVUvbVcy55KnznpC6bkDUWW9z915bi0ckdUr5cjf16Kp6xq0bP5HBD2xzgbL9F9Quv5vUw=="], + + "@aws-sdk/nested-clients/@aws-sdk/core": ["@aws-sdk/core@3.974.12", "", { "dependencies": { "@aws-sdk/types": "^3.973.8", "@aws-sdk/xml-builder": "^3.972.24", "@aws/lambda-invoke-store": "^0.2.2", "@smithy/core": "^3.24.2", "@smithy/signature-v4": "^5.4.2", "@smithy/types": "^4.14.1", "bowser": "^2.11.0", "tslib": "^2.6.2" } }, "sha512-qrqgioqYFjwR6LatVNS1L2Vk++EwRIxqSQXPKNv5Ofux2D8UNgqMQ1znnMyEImXquVPTtbf71fc128pvmU6y9A=="], + + "@aws-sdk/nested-clients/@aws-sdk/types": ["@aws-sdk/types@3.973.8", "", { "dependencies": { "@smithy/types": "^4.14.1", "tslib": "^2.6.2" } }, "sha512-gjlAdtHMbtR9X5iIhVUvbVcy55KnznpC6bkDUWW9z915bi0ckdUr5cjf16Kp6xq0bP5HBD2xzgbL9F9Quv5vUw=="], + + "@aws-sdk/signature-v4-multi-region/@aws-sdk/types": ["@aws-sdk/types@3.973.8", "", { "dependencies": { "@smithy/types": "^4.14.1", "tslib": "^2.6.2" } }, "sha512-gjlAdtHMbtR9X5iIhVUvbVcy55KnznpC6bkDUWW9z915bi0ckdUr5cjf16Kp6xq0bP5HBD2xzgbL9F9Quv5vUw=="], + + "@aws-sdk/token-providers/@aws-sdk/nested-clients": ["@aws-sdk/nested-clients@3.997.11", "", { "dependencies": { "@aws-crypto/sha256-browser": "5.2.0", "@aws-crypto/sha256-js": "5.2.0", "@aws-sdk/core": "^3.974.13", "@aws-sdk/signature-v4-multi-region": "^3.996.28", "@aws-sdk/types": "^3.973.9", "@smithy/core": "^3.24.3", "@smithy/fetch-http-handler": "^5.4.3", "@smithy/node-http-handler": "^4.7.3", "@smithy/types": "^4.14.2", "tslib": "^2.6.2" } }, "sha512-nWXXJ1r/r8N2Gw1pWolRgED38/A9A8DHR2ETWIv220zh4PZHcybbR4hUVWWktmNXTRHzDJwRluapHn0rZxuoqA=="], + "@eslint-community/eslint-utils/eslint-visitor-keys": ["eslint-visitor-keys@3.4.3", "", {}, "sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag=="], "@tailwindcss/oxide-wasm32-wasi/@emnapi/core": ["@emnapi/core@1.10.0", "", { "dependencies": { "@emnapi/wasi-threads": "1.2.1", "tslib": "^2.4.0" }, "bundled": true }, "sha512-yq6OkJ4p82CAfPl0u9mQebQHKPJkY7WrIuk205cTYnYe+k2Z8YBh11FrbRG/H6ihirqcacOgl2BIO8oyMQLeXw=="], @@ -595,5 +726,59 @@ "@typescript-eslint/eslint-plugin/ignore": ["ignore@7.0.5", "", {}, "sha512-Hs59xBNfUIunMFgWAbGX5cq6893IbWg4KnrjbYwX3tx0ztorVgTDA6B2sxf8ejHJ4wz8BqGUMYlnzNBer5NvGg=="], "vite/fsevents": ["fsevents@2.3.3", "", { "os": "darwin" }, "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw=="], + + "@aws-sdk/client-cognito-identity/@aws-sdk/core/@aws-sdk/xml-builder": ["@aws-sdk/xml-builder@3.972.24", "", { "dependencies": { "@nodable/entities": "2.1.0", "@smithy/types": "^4.14.1", "fast-xml-parser": "5.7.3", "tslib": "^2.6.2" } }, "sha512-V8z5YcDPfsvzrBlj0xR1vhRtocblhYbqdreCJB/voGd4Sr5zjNAeWxexbnqVtskTJe0vFb5KMqbSL++ePl+zRw=="], + + "@aws-sdk/client-ec2/@aws-sdk/core/@aws-sdk/xml-builder": ["@aws-sdk/xml-builder@3.972.24", "", { "dependencies": { "@nodable/entities": "2.1.0", "@smithy/types": "^4.14.1", "fast-xml-parser": "5.7.3", "tslib": "^2.6.2" } }, "sha512-V8z5YcDPfsvzrBlj0xR1vhRtocblhYbqdreCJB/voGd4Sr5zjNAeWxexbnqVtskTJe0vFb5KMqbSL++ePl+zRw=="], + + "@aws-sdk/client-s3/@aws-sdk/core/@aws-sdk/xml-builder": ["@aws-sdk/xml-builder@3.972.24", "", { "dependencies": { "@nodable/entities": "2.1.0", "@smithy/types": "^4.14.1", "fast-xml-parser": "5.7.3", "tslib": "^2.6.2" } }, "sha512-V8z5YcDPfsvzrBlj0xR1vhRtocblhYbqdreCJB/voGd4Sr5zjNAeWxexbnqVtskTJe0vFb5KMqbSL++ePl+zRw=="], + + "@aws-sdk/client-secrets-manager/@aws-sdk/core/@aws-sdk/xml-builder": ["@aws-sdk/xml-builder@3.972.24", "", { "dependencies": { "@nodable/entities": "2.1.0", "@smithy/types": "^4.14.1", "fast-xml-parser": "5.7.3", "tslib": "^2.6.2" } }, "sha512-V8z5YcDPfsvzrBlj0xR1vhRtocblhYbqdreCJB/voGd4Sr5zjNAeWxexbnqVtskTJe0vFb5KMqbSL++ePl+zRw=="], + + "@aws-sdk/client-sts/@aws-sdk/core/@aws-sdk/xml-builder": ["@aws-sdk/xml-builder@3.972.24", "", { "dependencies": { "@nodable/entities": "2.1.0", "@smithy/types": "^4.14.1", "fast-xml-parser": "5.7.3", "tslib": "^2.6.2" } }, "sha512-V8z5YcDPfsvzrBlj0xR1vhRtocblhYbqdreCJB/voGd4Sr5zjNAeWxexbnqVtskTJe0vFb5KMqbSL++ePl+zRw=="], + + "@aws-sdk/credential-provider-env/@aws-sdk/core/@aws-sdk/xml-builder": ["@aws-sdk/xml-builder@3.972.24", "", { "dependencies": { "@nodable/entities": "2.1.0", "@smithy/types": "^4.14.1", "fast-xml-parser": "5.7.3", "tslib": "^2.6.2" } }, "sha512-V8z5YcDPfsvzrBlj0xR1vhRtocblhYbqdreCJB/voGd4Sr5zjNAeWxexbnqVtskTJe0vFb5KMqbSL++ePl+zRw=="], + + "@aws-sdk/credential-provider-http/@aws-sdk/core/@aws-sdk/xml-builder": ["@aws-sdk/xml-builder@3.972.24", "", { "dependencies": { "@nodable/entities": "2.1.0", "@smithy/types": "^4.14.1", "fast-xml-parser": "5.7.3", "tslib": "^2.6.2" } }, "sha512-V8z5YcDPfsvzrBlj0xR1vhRtocblhYbqdreCJB/voGd4Sr5zjNAeWxexbnqVtskTJe0vFb5KMqbSL++ePl+zRw=="], + + "@aws-sdk/credential-provider-ini/@aws-sdk/core/@aws-sdk/xml-builder": ["@aws-sdk/xml-builder@3.972.24", "", { "dependencies": { "@nodable/entities": "2.1.0", "@smithy/types": "^4.14.1", "fast-xml-parser": "5.7.3", "tslib": "^2.6.2" } }, "sha512-V8z5YcDPfsvzrBlj0xR1vhRtocblhYbqdreCJB/voGd4Sr5zjNAeWxexbnqVtskTJe0vFb5KMqbSL++ePl+zRw=="], + + "@aws-sdk/credential-provider-login/@aws-sdk/core/@aws-sdk/xml-builder": ["@aws-sdk/xml-builder@3.972.24", "", { "dependencies": { "@nodable/entities": "2.1.0", "@smithy/types": "^4.14.1", "fast-xml-parser": "5.7.3", "tslib": "^2.6.2" } }, "sha512-V8z5YcDPfsvzrBlj0xR1vhRtocblhYbqdreCJB/voGd4Sr5zjNAeWxexbnqVtskTJe0vFb5KMqbSL++ePl+zRw=="], + + "@aws-sdk/credential-provider-node/@aws-sdk/credential-provider-ini/@aws-sdk/credential-provider-login": ["@aws-sdk/credential-provider-login@3.972.43", "", { "dependencies": { "@aws-sdk/core": "^3.974.13", "@aws-sdk/nested-clients": "^3.997.11", "@aws-sdk/types": "^3.973.9", "@smithy/core": "^3.24.3", "@smithy/types": "^4.14.2", "tslib": "^2.6.2" } }, "sha512-HG7kQCwXtbv3oBV61Ins0oNX8KKyvrMqqRkb6ZiAfQHbMuHaiNaEb2KnpKLPkNpqImSBK82UkVE/kaY6IfWikA=="], + + "@aws-sdk/credential-provider-node/@aws-sdk/credential-provider-ini/@aws-sdk/nested-clients": ["@aws-sdk/nested-clients@3.997.11", "", { "dependencies": { "@aws-crypto/sha256-browser": "5.2.0", "@aws-crypto/sha256-js": "5.2.0", "@aws-sdk/core": "^3.974.13", "@aws-sdk/signature-v4-multi-region": "^3.996.28", "@aws-sdk/types": "^3.973.9", "@smithy/core": "^3.24.3", "@smithy/fetch-http-handler": "^5.4.3", "@smithy/node-http-handler": "^4.7.3", "@smithy/types": "^4.14.2", "tslib": "^2.6.2" } }, "sha512-nWXXJ1r/r8N2Gw1pWolRgED38/A9A8DHR2ETWIv220zh4PZHcybbR4hUVWWktmNXTRHzDJwRluapHn0rZxuoqA=="], + + "@aws-sdk/credential-provider-node/@aws-sdk/credential-provider-sso/@aws-sdk/nested-clients": ["@aws-sdk/nested-clients@3.997.11", "", { "dependencies": { "@aws-crypto/sha256-browser": "5.2.0", "@aws-crypto/sha256-js": "5.2.0", "@aws-sdk/core": "^3.974.13", "@aws-sdk/signature-v4-multi-region": "^3.996.28", "@aws-sdk/types": "^3.973.9", "@smithy/core": "^3.24.3", "@smithy/fetch-http-handler": "^5.4.3", "@smithy/node-http-handler": "^4.7.3", "@smithy/types": "^4.14.2", "tslib": "^2.6.2" } }, "sha512-nWXXJ1r/r8N2Gw1pWolRgED38/A9A8DHR2ETWIv220zh4PZHcybbR4hUVWWktmNXTRHzDJwRluapHn0rZxuoqA=="], + + "@aws-sdk/credential-provider-node/@aws-sdk/credential-provider-sso/@aws-sdk/token-providers": ["@aws-sdk/token-providers@3.1052.0", "", { "dependencies": { "@aws-sdk/core": "^3.974.13", "@aws-sdk/nested-clients": "^3.997.11", "@aws-sdk/types": "^3.973.9", "@smithy/core": "^3.24.3", "@smithy/types": "^4.14.2", "tslib": "^2.6.2" } }, "sha512-QqZNB3so7UIDxZtroc85TQaLVxdZRFm0eWM1CSR2N+b06as9TOrilvrlTZuj3guYlxMs6yLOgGxnklJ5qMYtTw=="], + + "@aws-sdk/credential-provider-node/@aws-sdk/credential-provider-web-identity/@aws-sdk/nested-clients": ["@aws-sdk/nested-clients@3.997.11", "", { "dependencies": { "@aws-crypto/sha256-browser": "5.2.0", "@aws-crypto/sha256-js": "5.2.0", "@aws-sdk/core": "^3.974.13", "@aws-sdk/signature-v4-multi-region": "^3.996.28", "@aws-sdk/types": "^3.973.9", "@smithy/core": "^3.24.3", "@smithy/fetch-http-handler": "^5.4.3", "@smithy/node-http-handler": "^4.7.3", "@smithy/types": "^4.14.2", "tslib": "^2.6.2" } }, "sha512-nWXXJ1r/r8N2Gw1pWolRgED38/A9A8DHR2ETWIv220zh4PZHcybbR4hUVWWktmNXTRHzDJwRluapHn0rZxuoqA=="], + + "@aws-sdk/credential-provider-process/@aws-sdk/core/@aws-sdk/xml-builder": ["@aws-sdk/xml-builder@3.972.24", "", { "dependencies": { "@nodable/entities": "2.1.0", "@smithy/types": "^4.14.1", "fast-xml-parser": "5.7.3", "tslib": "^2.6.2" } }, "sha512-V8z5YcDPfsvzrBlj0xR1vhRtocblhYbqdreCJB/voGd4Sr5zjNAeWxexbnqVtskTJe0vFb5KMqbSL++ePl+zRw=="], + + "@aws-sdk/credential-provider-sso/@aws-sdk/core/@aws-sdk/xml-builder": ["@aws-sdk/xml-builder@3.972.24", "", { "dependencies": { "@nodable/entities": "2.1.0", "@smithy/types": "^4.14.1", "fast-xml-parser": "5.7.3", "tslib": "^2.6.2" } }, "sha512-V8z5YcDPfsvzrBlj0xR1vhRtocblhYbqdreCJB/voGd4Sr5zjNAeWxexbnqVtskTJe0vFb5KMqbSL++ePl+zRw=="], + + "@aws-sdk/credential-provider-web-identity/@aws-sdk/core/@aws-sdk/xml-builder": ["@aws-sdk/xml-builder@3.972.24", "", { "dependencies": { "@nodable/entities": "2.1.0", "@smithy/types": "^4.14.1", "fast-xml-parser": "5.7.3", "tslib": "^2.6.2" } }, "sha512-V8z5YcDPfsvzrBlj0xR1vhRtocblhYbqdreCJB/voGd4Sr5zjNAeWxexbnqVtskTJe0vFb5KMqbSL++ePl+zRw=="], + + "@aws-sdk/credential-providers/@aws-sdk/core/@aws-sdk/xml-builder": ["@aws-sdk/xml-builder@3.972.24", "", { "dependencies": { "@nodable/entities": "2.1.0", "@smithy/types": "^4.14.1", "fast-xml-parser": "5.7.3", "tslib": "^2.6.2" } }, "sha512-V8z5YcDPfsvzrBlj0xR1vhRtocblhYbqdreCJB/voGd4Sr5zjNAeWxexbnqVtskTJe0vFb5KMqbSL++ePl+zRw=="], + + "@aws-sdk/middleware-bucket-endpoint/@aws-sdk/core/@aws-sdk/xml-builder": ["@aws-sdk/xml-builder@3.972.24", "", { "dependencies": { "@nodable/entities": "2.1.0", "@smithy/types": "^4.14.1", "fast-xml-parser": "5.7.3", "tslib": "^2.6.2" } }, "sha512-V8z5YcDPfsvzrBlj0xR1vhRtocblhYbqdreCJB/voGd4Sr5zjNAeWxexbnqVtskTJe0vFb5KMqbSL++ePl+zRw=="], + + "@aws-sdk/middleware-flexible-checksums/@aws-sdk/core/@aws-sdk/xml-builder": ["@aws-sdk/xml-builder@3.972.24", "", { "dependencies": { "@nodable/entities": "2.1.0", "@smithy/types": "^4.14.1", "fast-xml-parser": "5.7.3", "tslib": "^2.6.2" } }, "sha512-V8z5YcDPfsvzrBlj0xR1vhRtocblhYbqdreCJB/voGd4Sr5zjNAeWxexbnqVtskTJe0vFb5KMqbSL++ePl+zRw=="], + + "@aws-sdk/middleware-sdk-ec2/@aws-sdk/core/@aws-sdk/xml-builder": ["@aws-sdk/xml-builder@3.972.24", "", { "dependencies": { "@nodable/entities": "2.1.0", "@smithy/types": "^4.14.1", "fast-xml-parser": "5.7.3", "tslib": "^2.6.2" } }, "sha512-V8z5YcDPfsvzrBlj0xR1vhRtocblhYbqdreCJB/voGd4Sr5zjNAeWxexbnqVtskTJe0vFb5KMqbSL++ePl+zRw=="], + + "@aws-sdk/middleware-sdk-s3/@aws-sdk/core/@aws-sdk/xml-builder": ["@aws-sdk/xml-builder@3.972.24", "", { "dependencies": { "@nodable/entities": "2.1.0", "@smithy/types": "^4.14.1", "fast-xml-parser": "5.7.3", "tslib": "^2.6.2" } }, "sha512-V8z5YcDPfsvzrBlj0xR1vhRtocblhYbqdreCJB/voGd4Sr5zjNAeWxexbnqVtskTJe0vFb5KMqbSL++ePl+zRw=="], + + "@aws-sdk/nested-clients/@aws-sdk/core/@aws-sdk/xml-builder": ["@aws-sdk/xml-builder@3.972.24", "", { "dependencies": { "@nodable/entities": "2.1.0", "@smithy/types": "^4.14.1", "fast-xml-parser": "5.7.3", "tslib": "^2.6.2" } }, "sha512-V8z5YcDPfsvzrBlj0xR1vhRtocblhYbqdreCJB/voGd4Sr5zjNAeWxexbnqVtskTJe0vFb5KMqbSL++ePl+zRw=="], + + "@aws-sdk/token-providers/@aws-sdk/nested-clients/@aws-sdk/signature-v4-multi-region": ["@aws-sdk/signature-v4-multi-region@3.996.28", "", { "dependencies": { "@aws-sdk/types": "^3.973.9", "@smithy/core": "^3.24.3", "@smithy/signature-v4": "^5.4.2", "@smithy/types": "^4.14.2", "tslib": "^2.6.2" } }, "sha512-qs9z5LqXO/CZC2Lg9SGKpoLU8Rhi+m2pFKZqfO9pytX1clc0katqtsDNupJxFy0xT9wsZSPzM2v1y+/H/zfp5Q=="], + + "@aws-sdk/credential-provider-node/@aws-sdk/credential-provider-ini/@aws-sdk/nested-clients/@aws-sdk/signature-v4-multi-region": ["@aws-sdk/signature-v4-multi-region@3.996.28", "", { "dependencies": { "@aws-sdk/types": "^3.973.9", "@smithy/core": "^3.24.3", "@smithy/signature-v4": "^5.4.2", "@smithy/types": "^4.14.2", "tslib": "^2.6.2" } }, "sha512-qs9z5LqXO/CZC2Lg9SGKpoLU8Rhi+m2pFKZqfO9pytX1clc0katqtsDNupJxFy0xT9wsZSPzM2v1y+/H/zfp5Q=="], + + "@aws-sdk/credential-provider-node/@aws-sdk/credential-provider-sso/@aws-sdk/nested-clients/@aws-sdk/signature-v4-multi-region": ["@aws-sdk/signature-v4-multi-region@3.996.28", "", { "dependencies": { "@aws-sdk/types": "^3.973.9", "@smithy/core": "^3.24.3", "@smithy/signature-v4": "^5.4.2", "@smithy/types": "^4.14.2", "tslib": "^2.6.2" } }, "sha512-qs9z5LqXO/CZC2Lg9SGKpoLU8Rhi+m2pFKZqfO9pytX1clc0katqtsDNupJxFy0xT9wsZSPzM2v1y+/H/zfp5Q=="], + + "@aws-sdk/credential-provider-node/@aws-sdk/credential-provider-web-identity/@aws-sdk/nested-clients/@aws-sdk/signature-v4-multi-region": ["@aws-sdk/signature-v4-multi-region@3.996.28", "", { "dependencies": { "@aws-sdk/types": "^3.973.9", "@smithy/core": "^3.24.3", "@smithy/signature-v4": "^5.4.2", "@smithy/types": "^4.14.2", "tslib": "^2.6.2" } }, "sha512-qs9z5LqXO/CZC2Lg9SGKpoLU8Rhi+m2pFKZqfO9pytX1clc0katqtsDNupJxFy0xT9wsZSPzM2v1y+/H/zfp5Q=="], } } diff --git a/package.json b/package.json index 4109de8..7960aa4 100644 --- a/package.json +++ b/package.json @@ -38,6 +38,7 @@ "vitest": "^4.0.0" }, "dependencies": { + "@aws-sdk/client-bedrock-runtime": "^3.1050.0", "@aws-sdk/client-ec2": "^3.1050.0", "@aws-sdk/client-s3": "^3.1050.0", "@aws-sdk/client-secrets-manager": "^3.1050.0", diff --git a/src/rootcell/rootcell.test.ts b/src/rootcell/rootcell.test.ts index 3438968..20e7554 100644 --- a/src/rootcell/rootcell.test.ts +++ b/src/rootcell/rootcell.test.ts @@ -252,6 +252,7 @@ describe("rootcell argument parsing", () => { "ROOTCELL_SPY_MAX_BYTES=6442450944", "ROOTCELL_SPY_SPOOL_MAX_BYTES=1073741824", "ROOTCELL_SPY_STORE_RAW=false", + "ROOTCELL_SPY_TOKEN_COUNT_MODE=provider", "ROOTCELL_SPY_BIND=127.0.0.1", "ROOTCELL_SPY_PORT=6174", "", @@ -259,10 +260,13 @@ describe("rootcell argument parsing", () => { expect(renderSpyEnv({ ROOTCELL_SPY_ENABLED: "yes", ROOTCELL_SPY_RETENTION_DAYS: "14", + ROOTCELL_SPY_TOKEN_COUNT_MODE: "provider", ROOTCELL_SPY_BIND: "127.0.0.1", ROOTCELL_SPY_PORT: "7000", })).toContain("ROOTCELL_SPY_ENABLED=true\n"); + expect(renderSpyEnv({}, ["AWS_BEARER_TOKEN_BEDROCK=secret value"])).toContain("AWS_BEARER_TOKEN_BEDROCK=\"secret value\"\n"); expect(() => renderSpyEnv({ ROOTCELL_SPY_BIND: "bad\nvalue" })).toThrow("must not contain newlines"); + expect(() => renderSpyEnv({}, ["bad-name=value"])).toThrow("invalid spy environment variable name"); }); test("chooses a fallback spy port when the preferred port is occupied", async () => { @@ -1092,6 +1096,14 @@ describe("VM and network providers", () => { expect(firewallModule).not.toContain(legacySpyRunDir); expect(firewallModule).not.toContain("ps.textual"); + const rootcellSource = readFileSync("src/rootcell/rootcell.ts", "utf8"); + expect(rootcellSource).toContain("\"dist/spy-service.js\""); + expect(rootcellSource).toContain("\"dist/spy-ui\""); + expect(rootcellSource).toContain("private guestFlakeRef"); + expect(rootcellSource).toContain("path:${this.config.guestRepoDir}#"); + expect(rootcellSource).toContain("sudo grep -Eq '^ROOTCELL_SPY_ENABLED="); + expect(rootcellSource).not.toContain("private async installFirewallSpyAssets"); + const agentModule = readFileSync("agent-vm.nix", "utf8"); expect(agentModule).toContain('DHCP = "ipv4";'); expect(agentModule).toContain("UseDNS = false;"); diff --git a/src/rootcell/rootcell.ts b/src/rootcell/rootcell.ts index 4fe0ad0..9cbeb79 100644 --- a/src/rootcell/rootcell.ts +++ b/src/rootcell/rootcell.ts @@ -69,10 +69,18 @@ const SPY_ENV_DEFAULTS = { ROOTCELL_SPY_MAX_BYTES: "6442450944", ROOTCELL_SPY_SPOOL_MAX_BYTES: "1073741824", ROOTCELL_SPY_STORE_RAW: "false", + ROOTCELL_SPY_TOKEN_COUNT_MODE: "provider", ROOTCELL_SPY_BIND: SPY_REMOTE_HOST, ROOTCELL_SPY_PORT: String(SPY_DEFAULT_PORT), } as const; const SPY_ENV_KEYS = Object.keys(SPY_ENV_DEFAULTS) as (keyof typeof SPY_ENV_DEFAULTS)[]; +const SPY_BEDROCK_SECRET_ENV_NAMES = new Set([ + "AWS_ACCESS_KEY_ID", + "AWS_SECRET_ACCESS_KEY", + "AWS_SESSION_TOKEN", + "AWS_SECURITY_TOKEN", + "AWS_BEARER_TOKEN_BEDROCK", +]); export interface VmListEntry { readonly instance: string; @@ -560,6 +568,7 @@ exit 1 await this.providers.vm.exec(this.config.firewallVm, [ "sudo", "install", + "-D", "-m", "0600", "-o", @@ -576,6 +585,10 @@ exit 1 return role === "agent" ? "agent-vm" : "firewall-vm"; } + private guestFlakeRef(attribute: string): string { + return `path:${this.config.guestRepoDir}#${attribute}`; + } + private async runSpy(options: SpyOptions): Promise { const readiness = await this.checkFirewallSpyReadiness(); if (readiness !== "ready") { @@ -616,11 +629,11 @@ exit 1 const remotePort = this.spyRemotePort(); const script = ` set -e -if [ ! -f /etc/agent-vm/spy.env ]; then +if ! sudo test -f /etc/agent-vm/spy.env; then echo missing-env exit 10 fi -if ! grep -Eq '^ROOTCELL_SPY_ENABLED=(1|true|yes|on)$' /etc/agent-vm/spy.env; then +if ! sudo grep -Eq '^ROOTCELL_SPY_ENABLED=(1|true|yes|on)$' /etc/agent-vm/spy.env; then echo disabled exit 11 fi @@ -728,6 +741,10 @@ fi return envBoolean(process.env.ROOTCELL_SPY_ENABLED, false); } + private isSpyProviderTokenCountingEnabled(): boolean { + return true; + } + private buildSpyArtifacts(): void { log("building spy service and browser assets..."); runInherited("bun", ["run", "build:spy"], { @@ -736,7 +753,7 @@ fi } private async configureFirewallSpyService(): Promise { - this.writeSpyEnv(); + await this.writeSpyEnv(); const hostPath = join(this.config.generatedDir, "spy.env"); const stagedPath = "/tmp/.rootcell-spy.env.staged"; await this.providers.vm.copyToGuest(this.config.firewallVm, hostPath, stagedPath); @@ -744,11 +761,11 @@ fi "sudo", "install", "-m", - "0644", + "0640", "-o", "root", "-g", - "root", + "rootcell-spy", stagedPath, "/etc/agent-vm/spy.env", ]); @@ -789,8 +806,17 @@ fi }); } - private writeSpyEnv(): void { - writeFileSync(join(this.config.generatedDir, "spy.env"), renderSpyEnv(process.env), "utf8"); + private async writeSpyEnv(): Promise { + const extraEnv = this.isSpyProviderTokenCountingEnabled() + ? [ + ...await this.readSecretEnv(SPY_BEDROCK_SECRET_ENV_NAMES), + `AWS_REGION=${this.config.awsEc2?.region ?? process.env.AWS_REGION ?? "us-east-1"}`, + `AWS_DEFAULT_REGION=${this.config.awsEc2?.region ?? process.env.AWS_DEFAULT_REGION ?? process.env.AWS_REGION ?? "us-east-1"}`, + ] + : []; + const spyEnvPath = join(this.config.generatedDir, "spy.env"); + writeFileSync(spyEnvPath, renderSpyEnv(process.env, extraEnv), "utf8"); + chmodSync(spyEnvPath, 0o600); } private async ensureFirewall(force: boolean, options: { readonly allowProvision: boolean } = { allowProvision: true }): Promise { @@ -844,8 +870,7 @@ systemctl is-active mitmproxy-explicit >/dev/null 2>&1 \\ await this.bootstrapFirewallDns(); await this.runNixosSwitch("firewall", ` set -e -cd '${this.config.guestRepoDir}' -sudo nixos-rebuild switch --flake .#${this.nixosConfiguration("firewall")} +sudo nixos-rebuild switch --flake ${shellQuote(this.guestFlakeRef(this.nixosConfiguration("firewall")))} `); await this.syncFirewallCa(); await this.providers.vm.exec(this.config.firewallVm, [ @@ -897,7 +922,6 @@ sudo nixos-rebuild switch --flake .#${this.nixosConfiguration("firewall")} await this.bootstrapAgentFirewallTrust(); await this.runNixosSwitch("agent", ` set -e -cd '${this.config.guestRepoDir}' export NIX_SSL_CERT_FILE=/tmp/agent-vm-bootstrap-ca-bundle.crt export SSL_CERT_FILE=/tmp/agent-vm-bootstrap-ca-bundle.crt export GIT_SSL_CAINFO=/tmp/agent-vm-bootstrap-ca-bundle.crt @@ -907,7 +931,7 @@ sudo env \\ SSL_CERT_FILE="$SSL_CERT_FILE" \\ GIT_SSL_CAINFO="$GIT_SSL_CAINFO" \\ REQUESTS_CA_BUNDLE="$REQUESTS_CA_BUNDLE" \\ - nixos-rebuild switch --flake .#${this.nixosConfiguration("agent")} + nixos-rebuild switch --flake ${shellQuote(this.guestFlakeRef(this.nixosConfiguration("agent")))} `); await this.runAgentHomeManager(); log("agent provisioning complete."); @@ -965,12 +989,11 @@ fi private async runAgentHomeManager(): Promise { const script = ` set -e -cd '${this.config.guestRepoDir}' export NIX_SSL_CERT_FILE=/etc/ssl/certs/ca-certificates.crt export SSL_CERT_FILE=/etc/ssl/certs/ca-certificates.crt export GIT_SSL_CAINFO=/etc/ssl/certs/ca-certificates.crt export REQUESTS_CA_BUNDLE=/etc/ssl/certs/ca-certificates.crt -nix run .#home-manager -- switch --flake .#${this.config.guestUser} +nix run ${shellQuote(this.guestFlakeRef("home-manager"))} -- switch --flake ${shellQuote(this.guestFlakeRef(this.config.guestUser))} `; const attempts = 4; for (let attempt = 1; attempt <= attempts; attempt += 1) { @@ -991,7 +1014,7 @@ nix run .#home-manager -- switch --flake .#${this.config.guestUser} } } - private async readSecretEnv(): Promise { + private async readSecretEnv(allowedEnvNames?: ReadonlySet): Promise { const path = this.config.secretsPath; if (!existsSync(path)) { return []; @@ -1000,6 +1023,9 @@ nix run .#home-manager -- switch --flake .#${this.config.guestUser} const injected: string[] = []; for (const mapping of mappings) { + if (allowedEnvNames !== undefined && !allowedEnvNames.has(mapping.envName)) { + continue; + } let value; try { value = await this.providers.secrets.read(mapping.secret); @@ -1133,7 +1159,7 @@ function editPath(paths: ReturnType, target: (typeof EDIT_ return join(paths.proxyDir, EDIT_PROXY_FILES[target]); } -export function renderSpyEnv(env: NodeJS.ProcessEnv = process.env): string { +export function renderSpyEnv(env: NodeJS.ProcessEnv = process.env, extraEnv: readonly string[] = []): string { return [ "# Generated by ./rootcell provision. DO NOT EDIT.", ...SPY_ENV_KEYS.map((key) => { @@ -1143,6 +1169,18 @@ export function renderSpyEnv(env: NodeJS.ProcessEnv = process.env): string { : raw === undefined || raw.trim().length === 0 ? SPY_ENV_DEFAULTS[key] : raw.trim(); return `${key}=${envFileValue(value)}`; }), + ...extraEnv.map((entry) => { + const separator = entry.indexOf("="); + if (separator <= 0) { + throw new Error("spy environment extra entries must be NAME=value"); + } + const key = entry.slice(0, separator); + const value = entry.slice(separator + 1); + if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(key)) { + throw new Error(`invalid spy environment variable name: ${key}`); + } + return `${key}=${envFileValue(value)}`; + }), "", ].join("\n"); } diff --git a/src/spy/README.md b/src/spy/README.md index c7e6a24..9fed06b 100644 --- a/src/spy/README.md +++ b/src/spy/README.md @@ -24,7 +24,6 @@ V1 does not include: - OpenAI, direct Anthropic, Codex, Claude Code, Cursor, or multi-provider UI. - Multi-conversation grouping. -- Token estimates or provider token-count calls. - Automated compaction detection. - Public exposure, authentication, or collaboration features. - The old terminal/TUI spy or old NDJSON format. @@ -118,6 +117,7 @@ ROOTCELL_SPY_ENABLED=false # ROOTCELL_SPY_MAX_BYTES=6442450944 # ROOTCELL_SPY_SPOOL_MAX_BYTES=1073741824 # ROOTCELL_SPY_STORE_RAW=false +# ROOTCELL_SPY_TOKEN_COUNT_MODE=provider # ROOTCELL_SPY_BIND=127.0.0.1 # ROOTCELL_SPY_PORT=6174 ``` @@ -129,6 +129,7 @@ Defaults: - Limit the SQLite store to 6 GiB. - Limit pending spool files to 1 GiB. - Do not persist exact raw payload records by default. +- Count tokens through Bedrock CountTokens; no local token estimates are shown. - Bind the firewall-local service to `127.0.0.1:6174`. `./rootcell spy` tries host-local port `6174` first and falls back to the next @@ -138,7 +139,31 @@ Additional service-only environment variables exist for tests and development: `ROOTCELL_SPY_DB_PATH`, `ROOTCELL_SPY_SPOOL_DIR`, `ROOTCELL_SPY_STATIC_DIR`, `ROOTCELL_SPY_INGEST_INTERVAL_MS`, `ROOTCELL_SPY_RETENTION_INTERVAL_MS`, and -`ROOTCELL_SPY_INGEST_BATCH_LIMIT`. +`ROOTCELL_SPY_INGEST_BATCH_LIMIT`. `ROOTCELL_SPY_BEDROCK_REGION` can override +the AWS region used for provider token counting. + +## Token Counting + +The API and browser expose token counts for whole requests, request sections, +blocks, and selected text. Each count is labeled with provenance: +`provider_reported`, `provider_counted`, or `unavailable`. + +Default behavior uses Bedrock CountTokens for whole-request, section, block, +and selected-text counts. Whole-request counts use the captured provider request +body when available so they preserve the real request context: messages, system +prompt, tool config, cache hints, and provider overhead. Standalone block, +section, and selection counts are wrapped as a minimal user message regardless +of the original block role or kind. That keeps per-fragment attribution +consistent and avoids provider validation rules for incomplete assistant turns. + +The browser never calls Bedrock directly. Bedrock CountTokens is called with the +base Anthropic model ID because Bedrock inference-profile IDs such as +`us.anthropic.*` can be valid for inference but rejected for token counting. + +Provider token counting can send captured prompt text back to Bedrock. When +provider mode is enabled, `./rootcell provision` forwards only Bedrock-relevant +credential environment variables into `/etc/agent-vm/spy.env`, and installs that +file as `0640 root:rootcell-spy`. ## Capacity Defaults @@ -272,7 +297,12 @@ bun run test:spy-ui:unit bun run test:spy-ui:e2e ``` -During provisioning, `./rootcell` runs `bun run build:spy` before copying the -bundled service and static UI assets into the firewall VM. Do not copy host -`node_modules` into the VM; the service bundle is architecture-neutral -TypeScript/JavaScript and uses the firewall VM's Bun runtime. +During provisioning, `./rootcell` runs `bun run build:spy`, copies the generated +`dist/spy-service.js` and `dist/spy-ui` artifacts into the firewall VM's guest +flake source, and lets Nix install them into `/etc/agent-vm`. Guest rebuilds use +an explicit `path:` flake reference so copied generated assets remain visible +even if the guest source directory has stale Git metadata. Clean flake evals +still tolerate missing local `dist/` output because `dist/` is ignored by git. +Do not copy host `node_modules` into the VM; the service bundle is +architecture-neutral TypeScript/JavaScript and uses the firewall VM's Bun +runtime. diff --git a/src/spy/api-contracts.ts b/src/spy/api-contracts.ts index f91a4ad..635da3c 100644 --- a/src/spy/api-contracts.ts +++ b/src/spy/api-contracts.ts @@ -47,6 +47,14 @@ export const SpyHealthSnapshotSchema = z.object({ metadata: z.record(z.string(), z.string()), }).strict(); +export const SpyTokenCountModeSchema = z.enum(["provider"]); +export const SpyTokenCountProvenanceSchema = z.enum([ + "provider_reported", + "provider_counted", + "unavailable", +]); +export const SpyTokenCountSubjectTypeSchema = z.enum(["call", "section", "block", "selection"]); + export const SpyServiceHealthSchema = z.object({ ok: z.literal(true), service: z.object({ @@ -57,6 +65,7 @@ export const SpyServiceHealthSchema = z.object({ maxBytes: NonNegativeIntegerSchema, spoolMaxBytes: NonNegativeIntegerSchema, storeRaw: z.boolean(), + tokenCountMode: SpyTokenCountModeSchema, staticAssets: z.boolean(), }).strict(), store: SpyHealthSnapshotSchema, @@ -70,6 +79,101 @@ export const SpyUsageSummarySchema = z.object({ totalTokens: NonNegativeIntegerSchema.nullable(), }).strict(); +export const SpyTokenCountRecordSchema = z.object({ + subjectType: SpyTokenCountSubjectTypeSchema, + callId: z.string().min(1).optional(), + blockId: z.string().min(1).optional(), + direction: z.enum(["request", "response"]).optional(), + kind: NormalizedBlockKindSchema.optional(), + label: z.string().min(1).optional(), + sourceHash: z.string().min(1), + modelId: z.string().min(1), + tokens: NonNegativeIntegerSchema.nullable(), + provenance: SpyTokenCountProvenanceSchema, + countedAt: NonNegativeNumberSchema, + error: z.string().min(1).optional(), +}).strict(); + +export const SpyTokenCountSubjectSchema = z.discriminatedUnion("type", [ + z.object({ + type: z.literal("call"), + callId: z.string().min(1), + direction: z.literal("request"), + }).strict(), + z.object({ + type: z.literal("section"), + callId: z.string().min(1), + direction: z.enum(["request", "response"]), + kind: NormalizedBlockKindSchema, + }).strict(), + z.object({ + type: z.literal("block"), + callId: z.string().min(1), + blockId: z.string().min(1), + }).strict(), + z.object({ + type: z.literal("selection"), + callId: z.string().min(1), + text: z.string(), + label: z.string().min(1).optional(), + }).strict(), +]); + +export const SpyTokenCountRequestSchema = z.object({ + mode: SpyTokenCountModeSchema.optional(), + subjects: z.array(SpyTokenCountSubjectSchema).min(1).max(100), +}).strict(); + +export const SpyTokenCountResponseSchema = z.object({ + mode: SpyTokenCountModeSchema, + records: z.array(SpyTokenCountRecordSchema), +}).strict(); + +export const SpyCompactionDetectionSourceSchema = z.enum(["none", "pi_pattern", "heuristic", "summarization_request"]); +export const SpyCompactionConfidenceSchema = z.enum(["none", "low", "medium", "high"]); +export const SpyCompactionReasonSchema = z.enum([ + "no_previous_comparable_call", + "pi_request_context_profile", + "summarization_system_prompt", + "conversation_wrapper_input", + "large_current_user_input", + "stable_request_context", + "summary_like_history_block", + "prior_history_byte_drop", + "prior_history_block_drop", + "request_byte_drop", + "input_token_drop", +]); + +export const SpyCompactionEvidenceSchema = z.object({ + currentCallId: z.string().min(1), + previousCallId: z.string().min(1).nullable(), + currentRequestByteSize: NonNegativeIntegerSchema, + previousRequestByteSize: NonNegativeIntegerSchema.nullable(), + currentInputTokens: NonNegativeIntegerSchema.nullable(), + previousInputTokens: NonNegativeIntegerSchema.nullable(), + currentContextTokens: NonNegativeIntegerSchema.nullable(), + previousContextTokens: NonNegativeIntegerSchema.nullable(), + currentPriorHistoryByteSize: NonNegativeIntegerSchema, + previousPriorHistoryByteSize: NonNegativeIntegerSchema.nullable(), + currentPriorHistoryBlockCount: NonNegativeIntegerSchema, + previousPriorHistoryBlockCount: NonNegativeIntegerSchema.nullable(), + summaryLikeBlockIds: z.array(z.string().min(1)), + newHistoryBlockIds: z.array(z.string().min(1)), + changedHistoryBlockIds: z.array(z.string().min(1)), + repeatedContextBlockCount: NonNegativeIntegerSchema, + changedContextBlockCount: NonNegativeIntegerSchema, +}).strict(); + +export const SpyCompactionAssessmentSchema = z.object({ + status: z.enum(["none", "candidate"]), + source: SpyCompactionDetectionSourceSchema, + confidence: SpyCompactionConfidenceSchema, + label: z.string().min(1), + reasons: z.array(SpyCompactionReasonSchema), + evidence: SpyCompactionEvidenceSchema, +}).strict(); + export const SpyCallSummarySchema = z.object({ call: ProviderCallSchema, durationMs: NonNegativeIntegerSchema.nullable(), @@ -126,6 +230,8 @@ export const StreamEventPageSchema = spyPaginatedResultSchema(StreamEventSchema) export const SpyCallDetailSchema = z.object({ summary: SpyCallSummarySchema, requestComposition: SpyRequestCompositionSchema, + compaction: SpyCompactionAssessmentSchema, + tokenCounts: z.array(SpyTokenCountRecordSchema), httpEvents: z.array(HttpEventRecordSchema), blocks: z.array(NormalizedBlockSchema), usageRecords: z.array(UsageRecordSchema), @@ -144,7 +250,7 @@ export const SpyCallDiffSchema = z.object({ blocks: z.array(SpyBlockDiffSchema), }).strict(); -export const SseEventNameSchema = z.enum(["hello", "health", "calls-changed", "cleared"]); +export const SseEventNameSchema = z.enum(["hello", "health", "calls-changed", "token-counts-changed", "cleared"]); export const SseHelloPayloadSchema = z.object({ id: NonNegativeIntegerSchema, @@ -155,10 +261,16 @@ export const SseCallsChangedPayloadSchema = z.union([ z.object({ retention: RetentionResultSchema }).strict(), ]); +export const SseTokenCountsChangedPayloadSchema = z.object({ + callId: z.string().min(1), + records: z.array(SpyTokenCountRecordSchema).min(1), +}).strict(); + export const SseEventPayloadSchemas = { hello: SseHelloPayloadSchema, health: SpyServiceHealthSchema, "calls-changed": SseCallsChangedPayloadSchema, + "token-counts-changed": SseTokenCountsChangedPayloadSchema, cleared: ClearDataResultSchema, } as const; @@ -167,6 +279,17 @@ export type IngestSpoolBatchResult = Readonly>; export type SpyHealthSnapshot = Readonly>; export type SpyServiceHealth = Readonly>; +export type SpyTokenCountMode = z.infer; +export type SpyTokenCountProvenance = z.infer; +export type SpyTokenCountSubject = Readonly>; +export type SpyTokenCountRequest = Readonly>; +export type SpyTokenCountRecord = Readonly>; +export type SpyTokenCountResponse = Readonly>; +export type SpyCompactionDetectionSource = z.infer; +export type SpyCompactionConfidence = z.infer; +export type SpyCompactionReason = z.infer; +export type SpyCompactionEvidence = Readonly>; +export type SpyCompactionAssessment = Readonly>; export type SpyUsageSummary = Readonly>; export type SpyCallSummary = Readonly>; export type SpyRequestCompositionSection = Readonly>; @@ -181,3 +304,4 @@ export type SpyPaginatedResult = Readonly<{ export type SseEventName = z.infer; export type SseHelloPayload = Readonly>; export type SseCallsChangedPayload = Readonly>; +export type SseTokenCountsChangedPayload = Readonly>; diff --git a/src/spy/bedrock-token-count.ts b/src/spy/bedrock-token-count.ts new file mode 100644 index 0000000..d06404f --- /dev/null +++ b/src/spy/bedrock-token-count.ts @@ -0,0 +1,80 @@ +import { + BedrockRuntimeClient, + CountTokensCommand, + type CountTokensCommandInput, +} from "@aws-sdk/client-bedrock-runtime"; + +export interface BedrockTokenCounter { + count(input: BedrockTokenCountInput): Promise; +} + +export interface BedrockTokenCountInput { + readonly modelId: string; + readonly input: CountTokensCommandInput["input"]; +} + +export class AwsBedrockTokenCounter implements BedrockTokenCounter { + private readonly client: BedrockRuntimeClient; + + constructor(options: { readonly region: string }) { + this.client = new BedrockRuntimeClient({ region: options.region }); + } + + async count(input: BedrockTokenCountInput): Promise { + const response = await this.client.send(new CountTokensCommand({ + modelId: bedrockTokenCountModelId(input.modelId), + input: input.input, + })); + if (response.inputTokens === undefined) { + throw new Error("Bedrock CountTokens returned no inputTokens value"); + } + return response.inputTokens; + } +} + +export function bedrockTokenCountModelId(modelId: string): string { + const match = /^(?:us|eu|au|global)\.(anthropic\.claude-.+)$/.exec(modelId); + return match?.[1] ?? modelId; +} + +export function bedrockCountInputFromRequestBody(bodyText: string): CountTokensCommandInput["input"] | null { + let body: unknown; + try { + body = JSON.parse(bodyText) as unknown; + } catch { + return null; + } + if (!isRecord(body)) { + return null; + } + + const converse: Record = {}; + copyIfPresent(body, converse, "messages"); + copyIfPresent(body, converse, "system"); + copyIfPresent(body, converse, "toolConfig"); + copyIfPresent(body, converse, "additionalModelRequestFields"); + return Object.keys(converse).length === 0 ? null : { converse }; +} + +export function bedrockCountInputForText(text: string): CountTokensCommandInput["input"] { + return { + converse: { + messages: [ + { + role: "user", + content: [{ text }], + }, + ], + }, + }; +} + +function copyIfPresent(source: Record, target: Record, key: string): void { + if (source[key] !== undefined) { + target[key] = source[key]; + } +} + +function isRecord(value: unknown): value is Record { + return value !== null && typeof value === "object" && !Array.isArray(value); +} diff --git a/src/spy/compaction.test.ts b/src/spy/compaction.test.ts new file mode 100644 index 0000000..143784a --- /dev/null +++ b/src/spy/compaction.test.ts @@ -0,0 +1,283 @@ +import { readFileSync } from "node:fs"; +import { describe, expect, test } from "bun:test"; +import type { SpyCallSummary, SpyUsageSummary } from "./api-contracts.ts"; +import { detectCompaction } from "./compaction.ts"; +import { normalizeBedrockSpoolEvents, type NormalizedProviderCall } from "./bedrock.ts"; +import { + SpoolEventSchema, + type NormalizedBlock, + type ProviderCall, + type SpoolEvent, +} from "./schemas.ts"; + +const FIXTURE_PATH = new URL("./fixtures/bedrock-pi-us-sonnet-4-6.ndjson", import.meta.url); + +describe("compaction detection", () => { + test("does not flag existing Pi/Bedrock fixture calls as compaction candidates", () => { + const calls = normalizeBedrockSpoolEvents(fixtureEvents()); + let previous: NormalizedProviderCall | null = null; + + for (const call of calls) { + const assessment = detectCompaction({ + summary: summaryFromNormalizedCall(call), + requestBlocks: requestBlocks(call.blocks), + previousSummary: previous === null ? null : summaryFromNormalizedCall(previous), + previousRequestBlocks: previous === null ? [] : requestBlocks(previous.blocks), + }); + expect(assessment.status).toBe("none"); + previous = call; + } + }); + + test("labels Pi-pattern compaction candidates with high confidence", () => { + const previousBlocks = [ + requestBlock("previous-system", "harness-system-context", "You are operating inside pi.", { source: "pi-bedrock-system", hash: "stable-system" }), + requestBlock("previous-history-1", "prior-conversation-history", "First historical turn. ".repeat(120), { role: "user" }), + requestBlock("previous-history-2", "prior-conversation-history", "Second historical turn. ".repeat(120), { role: "assistant" }), + requestBlock("previous-history-3", "prior-conversation-history", "Third historical turn. ".repeat(120), { role: "user" }), + requestBlock("previous-history-4", "prior-conversation-history", "Fourth historical turn. ".repeat(120), { role: "assistant" }), + requestBlock("previous-current", "current-user-input", "continue with the task", { role: "user" }), + ]; + const currentBlocks = [ + requestBlock("current-system", "harness-system-context", "You are operating inside pi.", { source: "pi-bedrock-system", hash: "stable-system" }), + requestBlock("current-summary", "prior-conversation-history", "Summary of the conversation so far: the user asked for a filesystem refactor and the agent edited two modules.", { role: "user" }), + requestBlock("current-current", "current-user-input", "continue with the task", { role: "user" }), + ]; + + const assessment = detectCompaction({ + summary: summary("current-pi", currentBlocks, { requestByteSize: 4_000, inputTokens: 1_100 }), + requestBlocks: currentBlocks, + previousSummary: summary("previous-pi", previousBlocks, { requestByteSize: 16_000, inputTokens: 5_600 }), + previousRequestBlocks: previousBlocks, + }); + + expect(assessment).toMatchObject({ + status: "candidate", + source: "pi_pattern", + confidence: "high", + label: "Pi compaction candidate", + }); + expect(assessment.reasons).toContain("pi_request_context_profile"); + expect(assessment.reasons).toContain("summary_like_history_block"); + expect(assessment.reasons).toContain("prior_history_byte_drop"); + expect(assessment.evidence.summaryLikeBlockIds).toEqual(["current-summary"]); + }); + + test("labels Pi summarization requests as compaction events without a prior transition", () => { + const currentBlocks = [ + requestBlock( + "current-conversation", + "current-user-input", + `\n[User]: ${"Build the extension plan. ".repeat(900)}\n[Assistant]: ${"Edited files and tested. ".repeat(900)}\n`, + { role: "user" }, + ), + requestBlock( + "current-summary-system", + "harness-system-context", + "You are a context summarization assistant. Your task is to read a conversation between a user and an AI coding assistant, then produce a structured summary following the exact format specified. Do NOT continue the conversation.", + { source: "pi-bedrock-system" }, + ), + ]; + + const assessment = detectCompaction({ + summary: summary("current-summarization", currentBlocks), + requestBlocks: currentBlocks, + previousSummary: null, + previousRequestBlocks: [], + }); + + expect(assessment).toMatchObject({ + status: "candidate", + source: "summarization_request", + confidence: "high", + label: "Compaction summarization request", + }); + expect(assessment.reasons).toEqual([ + "summarization_system_prompt", + "conversation_wrapper_input", + "large_current_user_input", + ]); + }); + + test("labels generic structural compaction candidates separately from Pi patterns", () => { + const previousBlocks = [ + requestBlock("previous-system", "harness-system-context", "stable generic system context", { source: "generic", hash: "stable-system" }), + requestBlock("previous-history-1", "prior-conversation-history", "Long generic history. ".repeat(140), { source: "generic", role: "user" }), + requestBlock("previous-history-2", "prior-conversation-history", "Long generic reply. ".repeat(140), { source: "generic", role: "assistant" }), + requestBlock("previous-history-3", "prior-conversation-history", "More generic history. ".repeat(140), { source: "generic", role: "user" }), + requestBlock("previous-history-4", "prior-conversation-history", "More generic reply. ".repeat(140), { source: "generic", role: "assistant" }), + requestBlock("previous-current", "current-user-input", "next request", { source: "generic", role: "user" }), + ]; + const currentBlocks = [ + requestBlock("current-system", "harness-system-context", "stable generic system context", { source: "generic", hash: "stable-system" }), + requestBlock("current-summary", "prior-conversation-history", "Context summary: earlier turns established the implementation constraints.", { source: "generic", role: "user" }), + requestBlock("current-current", "current-user-input", "next request", { source: "generic", role: "user" }), + ]; + + const assessment = detectCompaction({ + summary: summary("current-generic", currentBlocks, { requestByteSize: 5_000, inputTokens: 1_600 }), + requestBlocks: currentBlocks, + previousSummary: summary("previous-generic", previousBlocks, { requestByteSize: 18_000, inputTokens: 6_000 }), + previousRequestBlocks: previousBlocks, + }); + + expect(assessment.status).toBe("candidate"); + expect(assessment.source).toBe("heuristic"); + expect(assessment.label).toBe("Heuristic compaction candidate"); + expect(assessment.reasons).not.toContain("pi_request_context_profile"); + }); + + test("does not treat summary-like current user input as compaction evidence", () => { + const previousBlocks = [ + requestBlock("previous-system", "harness-system-context", "stable generic system context", { source: "generic", hash: "stable-system" }), + requestBlock("previous-current", "current-user-input", "start", { source: "generic", role: "user" }), + ]; + const currentBlocks = [ + requestBlock("current-system", "harness-system-context", "stable generic system context", { source: "generic", hash: "stable-system" }), + requestBlock("current-current", "current-user-input", "Please summarize the current file.", { source: "generic", role: "user" }), + ]; + + const assessment = detectCompaction({ + summary: summary("current-no-history", currentBlocks, { requestByteSize: 1_000, inputTokens: 300 }), + requestBlocks: currentBlocks, + previousSummary: summary("previous-no-history", previousBlocks, { requestByteSize: 1_000, inputTokens: 300 }), + previousRequestBlocks: previousBlocks, + }); + + expect(assessment.status).toBe("none"); + expect(assessment.evidence.summaryLikeBlockIds).toEqual([]); + }); + + test("does not flag summary-like history without real context drop evidence", () => { + const previousBlocks = [ + requestBlock("previous-system", "harness-system-context", "You are operating inside pi.", { source: "pi-bedrock-system", hash: "stable-system" }), + requestBlock("previous-history", "prior-conversation-history", "Long prior context. ".repeat(180), { role: "user" }), + requestBlock("previous-current", "current-user-input", "continue", { role: "user" }), + ]; + const currentBlocks = [ + requestBlock("current-system", "harness-system-context", "You are operating inside pi.", { source: "pi-bedrock-system", hash: "stable-system" }), + requestBlock("current-history", "prior-conversation-history", "Summary of the conversation so far, but no meaningful byte drop. ".repeat(80), { role: "user" }), + requestBlock("current-current", "current-user-input", "continue", { role: "user" }), + ]; + + const assessment = detectCompaction({ + summary: summary("current-no-drop", currentBlocks, { requestByteSize: 14_000, inputTokens: 4_800 }), + requestBlocks: currentBlocks, + previousSummary: summary("previous-no-drop", previousBlocks, { requestByteSize: 15_000, inputTokens: 5_000 }), + previousRequestBlocks: previousBlocks, + }); + + expect(assessment.status).toBe("none"); + expect(assessment.reasons).toEqual([]); + }); +}); + +function fixtureEvents(): SpoolEvent[] { + return readFileSync(FIXTURE_PATH, "utf8") + .trim() + .split("\n") + .filter(Boolean) + .map((line) => SpoolEventSchema.parse(JSON.parse(line) as unknown)); +} + +function requestBlocks(blocks: readonly NormalizedBlock[]): NormalizedBlock[] { + return blocks.filter((block) => block.direction === "request"); +} + +function summaryFromNormalizedCall(call: NormalizedProviderCall): SpyCallSummary { + const request = requestBlocks(call.blocks); + const response = call.blocks.filter((block) => block.direction === "response"); + return { + call: call.call, + durationMs: call.call.completed_at === undefined ? null : Math.round((call.call.completed_at - call.call.started_at) * 1000), + usage: usageSummary(call.usage[0]), + requestBlockCount: request.length, + responseBlockCount: response.length, + requestByteSize: byteSize(request), + responseByteSize: byteSize(response), + cacheMarkerCount: call.blocks.filter((block) => block.cache_marker).length, + streamEventCount: call.streamEvents.length, + rawPayloadCount: call.rawPayloads.length, + }; +} + +function summary( + id: string, + blocks: readonly NormalizedBlock[], + options: { readonly requestByteSize?: number | undefined; readonly inputTokens?: number | undefined } = {}, +): SpyCallSummary { + const request = requestBlocks(blocks); + const response = blocks.filter((block) => block.direction === "response"); + return { + call: { + id, + provider: "bedrock", + operation: "converse-stream", + model_id: "us.anthropic.claude-sonnet-4-6", + status: "complete", + started_at: id.startsWith("previous") ? 1 : 2, + completed_at: id.startsWith("previous") ? 1.5 : 2.5, + status_code: 200, + request_flow_id: `${id}-request`, + response_flow_id: `${id}-response`, + request_content_hash: `${id}-request-hash`, + response_content_hash: `${id}-response-hash`, + } satisfies ProviderCall, + durationMs: 500, + usage: { + inputTokens: options.inputTokens ?? null, + outputTokens: null, + cacheReadTokens: null, + cacheWriteTokens: null, + totalTokens: options.inputTokens ?? null, + }, + requestBlockCount: request.length, + responseBlockCount: response.length, + requestByteSize: options.requestByteSize ?? byteSize(request), + responseByteSize: byteSize(response), + cacheMarkerCount: request.filter((block) => block.cache_marker).length, + streamEventCount: 0, + rawPayloadCount: 0, + }; +} + +function usageSummary(record: NormalizedProviderCall["usage"][number] | undefined): SpyUsageSummary { + return { + inputTokens: record?.input_tokens ?? null, + outputTokens: record?.output_tokens ?? null, + cacheReadTokens: record?.cache_read_tokens ?? null, + cacheWriteTokens: record?.cache_write_tokens ?? null, + totalTokens: record?.total_tokens ?? null, + }; +} + +function requestBlock( + id: string, + kind: NormalizedBlock["kind"], + text: string, + options: { + readonly source?: string | undefined; + readonly role?: string | undefined; + readonly hash?: string | undefined; + } = {}, +): NormalizedBlock { + return { + id, + call_id: id.startsWith("previous") ? "previous-call" : "current-call", + direction: "request", + ordinal: Number(id.replace(/\D/g, "")) || 0, + role: options.role, + kind, + source: options.source ?? "pi-bedrock-message", + provider_path: `$.${id}`, + text, + char_size: text.length, + byte_size: new TextEncoder().encode(text).length, + content_hash: options.hash ?? `${id}-hash`, + cache_marker: false, + }; +} + +function byteSize(blocks: readonly NormalizedBlock[]): number { + return blocks.reduce((total, block) => total + block.byte_size, 0); +} diff --git a/src/spy/compaction.ts b/src/spy/compaction.ts new file mode 100644 index 0000000..e0960e7 --- /dev/null +++ b/src/spy/compaction.ts @@ -0,0 +1,293 @@ +import type { + SpyCallSummary, + SpyCompactionAssessment, + SpyCompactionConfidence, + SpyCompactionReason, +} from "./api-contracts.ts"; +import type { NormalizedBlock } from "./schemas.ts"; + +const MIN_HISTORY_BYTES_FOR_DROP = 2_048; +const MIN_REQUEST_BYTES_FOR_DROP = 8_192; +const MIN_INPUT_TOKENS_FOR_DROP = 2_000; +const MIN_LARGE_SUMMARIZATION_INPUT_BYTES = 16_384; +const HISTORY_BYTE_DROP_RATIO = 0.55; +const REQUEST_BYTE_DROP_RATIO = 0.75; +const INPUT_TOKEN_DROP_RATIO = 0.75; +const HISTORY_BLOCK_DROP_RATIO = 0.4; +const SUMMARY_LIKE_HISTORY_PATTERN = /\b(summary|summarized|summarised|condensed|compacted|compressed|conversation so far|context summary|previous context|prior conversation|earlier conversation)\b/i; +const SUMMARIZATION_SYSTEM_PATTERN = /\b(context summarization assistant|structured summary|summari[sz]e the conversation|read a conversation between a user and an AI coding assistant|do not continue the conversation)\b/i; +const CONVERSATION_WRAPPER_PATTERN = /[\s\S]*<\/conversation>|\[User\]:[\s\S]*\[Assistant\]:/i; + +export interface RequestTransitionInput { + readonly summary: SpyCallSummary; + readonly requestBlocks: readonly NormalizedBlock[]; + readonly previousSummary: SpyCallSummary | null; + readonly previousRequestBlocks: readonly NormalizedBlock[]; +} + +export function detectCompaction(input: RequestTransitionInput): SpyCompactionAssessment { + const currentHistoryBlocks = historyBlocks(input.requestBlocks); + const previousHistoryBlocks = historyBlocks(input.previousRequestBlocks); + const summaryLikeBlockIds = currentHistoryBlocks + .filter((block) => SUMMARY_LIKE_HISTORY_PATTERN.test(block.text ?? "")) + .map((block) => block.id); + const historyDiff = classifyHistoryBlocks(currentHistoryBlocks, previousHistoryBlocks); + const contextStability = stableContext(input.requestBlocks, input.previousRequestBlocks); + const summarizationRequest = detectSummarizationRequest(input.requestBlocks); + + const evidence = { + currentCallId: input.summary.call.id, + previousCallId: input.previousSummary?.call.id ?? null, + currentRequestByteSize: input.summary.requestByteSize, + previousRequestByteSize: input.previousSummary?.requestByteSize ?? null, + currentInputTokens: input.summary.usage.inputTokens, + previousInputTokens: input.previousSummary?.usage.inputTokens ?? null, + currentContextTokens: contextTokens(input.summary), + previousContextTokens: input.previousSummary === null ? null : contextTokens(input.previousSummary), + currentPriorHistoryByteSize: byteSize(currentHistoryBlocks), + previousPriorHistoryByteSize: input.previousSummary === null ? null : byteSize(previousHistoryBlocks), + currentPriorHistoryBlockCount: currentHistoryBlocks.length, + previousPriorHistoryBlockCount: input.previousSummary === null ? null : previousHistoryBlocks.length, + summaryLikeBlockIds, + newHistoryBlockIds: historyDiff.newBlockIds, + changedHistoryBlockIds: historyDiff.changedBlockIds, + repeatedContextBlockCount: contextStability.repeatedBlockCount, + changedContextBlockCount: contextStability.changedBlockCount, + }; + + if (summarizationRequest.isCandidate) { + return { + status: "candidate", + source: "summarization_request", + confidence: summarizationRequest.largeCurrentInput ? "high" : "medium", + label: "Compaction summarization request", + reasons: summarizationRequest.reasons, + evidence, + }; + } + + if (input.previousSummary === null) { + return { + status: "none", + source: "none", + confidence: "none", + label: "No compaction candidate", + reasons: ["no_previous_comparable_call"], + evidence, + }; + } + + const reasons: SpyCompactionReason[] = []; + const piProfile = isPiRequestContext(input); + const stable = contextStability.repeatedBlockCount + contextStability.changedBlockCount > 0; + const currentInputExists = input.requestBlocks.some((block) => block.kind === "current-user-input"); + const historyByteDrop = evidence.previousPriorHistoryByteSize !== null + && evidence.previousPriorHistoryByteSize >= MIN_HISTORY_BYTES_FOR_DROP + && evidence.currentPriorHistoryByteSize <= evidence.previousPriorHistoryByteSize * HISTORY_BYTE_DROP_RATIO; + const historyBlockDrop = evidence.previousPriorHistoryBlockCount !== null + && evidence.previousPriorHistoryBlockCount >= 4 + && evidence.currentPriorHistoryBlockCount <= Math.max(1, Math.floor(evidence.previousPriorHistoryBlockCount * HISTORY_BLOCK_DROP_RATIO)); + const requestByteDrop = evidence.previousRequestByteSize !== null + && evidence.previousRequestByteSize >= MIN_REQUEST_BYTES_FOR_DROP + && evidence.currentRequestByteSize <= evidence.previousRequestByteSize * REQUEST_BYTE_DROP_RATIO; + const inputTokenDrop = evidence.currentContextTokens !== null + && evidence.previousContextTokens !== null + && evidence.previousContextTokens >= MIN_INPUT_TOKENS_FOR_DROP + && evidence.currentContextTokens <= evidence.previousContextTokens * INPUT_TOKEN_DROP_RATIO; + const summaryLikeHistory = summaryLikeBlockIds.length > 0; + + if (piProfile) { + reasons.push("pi_request_context_profile"); + } + if (stable) { + reasons.push("stable_request_context"); + } + if (summaryLikeHistory) { + reasons.push("summary_like_history_block"); + } + if (historyByteDrop) { + reasons.push("prior_history_byte_drop"); + } + if (historyBlockDrop) { + reasons.push("prior_history_block_drop"); + } + if (requestByteDrop) { + reasons.push("request_byte_drop"); + } + if (inputTokenDrop) { + reasons.push("input_token_drop"); + } + + const hasPriorHistory = evidence.currentPriorHistoryBlockCount > 0 && evidence.previousPriorHistoryBlockCount !== null && evidence.previousPriorHistoryBlockCount > 0; + const piCandidate = piProfile && stable && currentInputExists && hasPriorHistory && ( + (summaryLikeHistory && (historyByteDrop || requestByteDrop || inputTokenDrop)) + || (historyByteDrop && (historyBlockDrop || requestByteDrop || inputTokenDrop)) + ); + const heuristicCandidate = !piCandidate && stable && currentInputExists && hasPriorHistory && ( + (summaryLikeHistory && historyByteDrop) + || (historyByteDrop && historyBlockDrop && (requestByteDrop || inputTokenDrop)) + ); + + if (!piCandidate && !heuristicCandidate) { + return { + status: "none", + source: "none", + confidence: "none", + label: "No compaction candidate", + reasons: [], + evidence, + }; + } + + return { + status: "candidate", + source: piCandidate ? "pi_pattern" : "heuristic", + confidence: candidateConfidence({ + piCandidate, + summaryLikeHistory, + historyByteDrop, + historyBlockDrop, + requestByteDrop, + inputTokenDrop, + }), + label: piCandidate ? "Pi compaction candidate" : "Heuristic compaction candidate", + reasons, + evidence, + }; +} + +function historyBlocks(blocks: readonly NormalizedBlock[]): NormalizedBlock[] { + return blocks.filter((block) => block.direction === "request" && block.kind === "prior-conversation-history"); +} + +function detectSummarizationRequest(blocks: readonly NormalizedBlock[]): { + readonly isCandidate: boolean; + readonly largeCurrentInput: boolean; + readonly reasons: SpyCompactionReason[]; +} { + const systemPrompt = blocks.find((block) => + block.direction === "request" + && block.kind === "harness-system-context" + && SUMMARIZATION_SYSTEM_PATTERN.test(block.text ?? "") + ); + const wrappedInput = blocks.find((block) => + block.direction === "request" + && block.kind === "current-user-input" + && CONVERSATION_WRAPPER_PATTERN.test(block.text ?? "") + ); + const largeCurrentInput = wrappedInput !== undefined && wrappedInput.byte_size >= MIN_LARGE_SUMMARIZATION_INPUT_BYTES; + const reasons: SpyCompactionReason[] = []; + if (systemPrompt !== undefined) { + reasons.push("summarization_system_prompt"); + } + if (wrappedInput !== undefined) { + reasons.push("conversation_wrapper_input"); + } + if (largeCurrentInput) { + reasons.push("large_current_user_input"); + } + return { + isCandidate: systemPrompt !== undefined && wrappedInput !== undefined, + largeCurrentInput, + reasons, + }; +} + +function byteSize(blocks: readonly NormalizedBlock[]): number { + return blocks.reduce((total, block) => total + block.byte_size, 0); +} + +function contextTokens(summary: SpyCallSummary): number | null { + const parts = [ + summary.usage.inputTokens, + summary.usage.cacheReadTokens, + summary.usage.cacheWriteTokens, + ].filter((value): value is number => value !== null); + return parts.length === 0 ? null : parts.reduce((total, value) => total + value, 0); +} + +function classifyHistoryBlocks( + currentBlocks: readonly NormalizedBlock[], + previousBlocks: readonly NormalizedBlock[], +): { readonly newBlockIds: string[]; readonly changedBlockIds: string[] } { + const previousHashes = new Set(previousBlocks.map((block) => block.content_hash)); + const previousSignatures = new Set(previousBlocks.map(blockSignature)); + const newBlockIds: string[] = []; + const changedBlockIds: string[] = []; + for (const block of currentBlocks) { + if (previousHashes.has(block.content_hash)) { + continue; + } + if (previousSignatures.has(blockSignature(block))) { + changedBlockIds.push(block.id); + } else { + newBlockIds.push(block.id); + } + } + return { newBlockIds, changedBlockIds }; +} + +function stableContext( + currentBlocks: readonly NormalizedBlock[], + previousBlocks: readonly NormalizedBlock[], +): { readonly repeatedBlockCount: number; readonly changedBlockCount: number } { + const previousContext = previousBlocks.filter(isContextBlock); + const previousHashes = new Set(previousContext.map((block) => block.content_hash)); + const previousSignatures = new Set(previousContext.map(blockSignature)); + let repeatedBlockCount = 0; + let changedBlockCount = 0; + for (const block of currentBlocks.filter(isContextBlock)) { + if (previousHashes.has(block.content_hash)) { + repeatedBlockCount += 1; + } else if (previousSignatures.has(blockSignature(block))) { + changedBlockCount += 1; + } + } + return { repeatedBlockCount, changedBlockCount }; +} + +function isContextBlock(block: NormalizedBlock): boolean { + return block.direction === "request" && ( + block.kind === "harness-system-context" + || block.kind === "tool-definition" + || block.kind === "provider-envelope" + ); +} + +function isPiRequestContext(input: RequestTransitionInput): boolean { + return input.requestBlocks.some((block) => { + if (block.source.includes("pi")) { + return true; + } + return block.kind === "harness-system-context" && /\binside pi\b/i.test(block.text ?? ""); + }); +} + +function blockSignature(block: NormalizedBlock): string { + return [ + block.direction, + block.kind, + block.role ?? "", + block.provider_path ?? "", + ].join(":"); +} + +function candidateConfidence(input: { + readonly piCandidate: boolean; + readonly summaryLikeHistory: boolean; + readonly historyByteDrop: boolean; + readonly historyBlockDrop: boolean; + readonly requestByteDrop: boolean; + readonly inputTokenDrop: boolean; +}): SpyCompactionConfidence { + const signalCount = [ + input.summaryLikeHistory, + input.historyByteDrop, + input.historyBlockDrop, + input.requestByteDrop || input.inputTokenDrop, + ].filter(Boolean).length; + if (input.piCandidate && signalCount >= 3) { + return "high"; + } + return signalCount >= 2 ? "medium" : "low"; +} diff --git a/src/spy/migrations.ts b/src/spy/migrations.ts index 6dcc6b0..e99bf1a 100644 --- a/src/spy/migrations.ts +++ b/src/spy/migrations.ts @@ -176,6 +176,70 @@ INSERT INTO normalized_block_fts(block_id, text) FROM normalized_block_fts WHERE normalized_block_fts.block_id = normalized_block.id ); +`, + }, + { + version: 3, + name: "token count cache", + sql: ` +CREATE TABLE IF NOT EXISTS token_count ( + id TEXT PRIMARY KEY, + call_id TEXT NOT NULL REFERENCES provider_call(id) ON DELETE CASCADE, + subject_type TEXT NOT NULL CHECK (subject_type IN ('call', 'section', 'block')), + direction TEXT CHECK (direction IN ('request', 'response')), + block_id TEXT, + kind TEXT, + source_hash TEXT NOT NULL, + cache_key TEXT NOT NULL UNIQUE, + model_id TEXT NOT NULL, + tokens INTEGER, + provenance TEXT NOT NULL CHECK (provenance = 'provider_counted'), + counted_at REAL NOT NULL, + error TEXT +); + +CREATE INDEX IF NOT EXISTS token_count_call_idx + ON token_count(call_id); +CREATE INDEX IF NOT EXISTS token_count_subject_idx + ON token_count(subject_type, call_id, direction, block_id, kind); +`, + }, + { + version: 4, + name: "provider-only token count cache subjects", + sql: ` +CREATE TABLE IF NOT EXISTS token_count_next ( + id TEXT PRIMARY KEY, + call_id TEXT NOT NULL REFERENCES provider_call(id) ON DELETE CASCADE, + subject_type TEXT NOT NULL CHECK (subject_type IN ('call', 'section', 'block', 'selection')), + direction TEXT CHECK (direction IN ('request', 'response')), + block_id TEXT, + kind TEXT, + label TEXT, + source_hash TEXT NOT NULL, + cache_key TEXT NOT NULL UNIQUE, + model_id TEXT NOT NULL, + tokens INTEGER, + provenance TEXT NOT NULL CHECK (provenance = 'provider_counted'), + counted_at REAL NOT NULL, + error TEXT +); + +INSERT OR REPLACE INTO token_count_next ( + id, call_id, subject_type, direction, block_id, kind, label, source_hash, + cache_key, model_id, tokens, provenance, counted_at, error +) +SELECT id, call_id, subject_type, direction, block_id, kind, NULL, source_hash, + cache_key, model_id, tokens, provenance, counted_at, error +FROM token_count; + +DROP TABLE token_count; +ALTER TABLE token_count_next RENAME TO token_count; + +CREATE INDEX IF NOT EXISTS token_count_call_idx + ON token_count(call_id); +CREATE INDEX IF NOT EXISTS token_count_subject_idx + ON token_count(subject_type, call_id, direction, block_id, kind); `, }, ]; diff --git a/src/spy/schemas.test.ts b/src/spy/schemas.test.ts index 38641dc..50871f1 100644 --- a/src/spy/schemas.test.ts +++ b/src/spy/schemas.test.ts @@ -114,8 +114,8 @@ describe("spy sqlite migrations", () => { const db = new Database(":memory:"); try { applySpyMigrations(db); - expect(currentSpySchemaVersion()).toBe(2); - expect(db.query("SELECT MAX(version) AS version FROM schema_migration").get()).toEqual({ version: 2 }); + expect(currentSpySchemaVersion()).toBe(4); + expect(db.query("SELECT MAX(version) AS version FROM schema_migration").get()).toEqual({ version: 4 }); db.query(` INSERT INTO provider_call ( diff --git a/src/spy/service.test.ts b/src/spy/service.test.ts index 9e0bd65..34e6dce 100644 --- a/src/spy/service.test.ts +++ b/src/spy/service.test.ts @@ -9,12 +9,15 @@ import { SpyCallDiffSchema, SpyCallSummaryPageSchema, SpyServiceHealthSchema, + SpyTokenCountResponseSchema, StreamEventPageSchema, SseEventNameSchema, SseEventPayloadSchemas, + type SpyCallDetail, } from "./api-contracts.ts"; import { SpoolEventSchema, type SpoolEvent } from "./schemas.ts"; import { spyServiceConfigFromEnv, startSpyService, type SpyServiceHandle } from "./service.ts"; +import type { BedrockTokenCounter, BedrockTokenCountInput } from "./bedrock-token-count.ts"; const FIXTURE_PATH = new URL("./fixtures/bedrock-pi-us-sonnet-4-6.ndjson", import.meta.url); @@ -33,6 +36,29 @@ interface SseReader { >; } +class FakeTokenCounter implements BedrockTokenCounter { + readonly inputs: BedrockTokenCountInput[] = []; + + constructor( + private readonly result: number, + private readonly failure?: Error | undefined, + private readonly delayMs = 0, + ) {} + + async count(input: BedrockTokenCountInput): Promise { + if (this.delayMs > 0) { + await sleep(this.delayMs); + } else { + await Promise.resolve(); + } + this.inputs.push(input); + if (this.failure !== undefined) { + throw this.failure; + } + return this.result; + } +} + const tempRoots: string[] = []; const serviceHandles: SpyServiceHandle[] = []; @@ -56,6 +82,8 @@ function fixtureEvents(): SpoolEvent[] { function createTestService(options: { readonly storeRaw?: boolean | undefined; readonly startIngestion?: boolean | undefined; + readonly tokenCounter?: BedrockTokenCounter | undefined; + readonly tokenCountMode?: "provider" | undefined; } = {}): TestService { const root = mkdtempSync(join(tmpdir(), "rootcell-spy-service-")); tempRoots.push(root); @@ -75,10 +103,12 @@ function createTestService(options: { spoolDir, staticDir, storeRaw: options.storeRaw === true, + ...(options.tokenCountMode === undefined ? {} : { tokenCountMode: options.tokenCountMode }), ingestIntervalMs: 60_000, retentionIntervalMs: 60_000, }, startIngestion: options.startIngestion ?? false, + tokenCounter: options.tokenCounter, }); serviceHandles.push(handle); return { root, dbPath, spoolDir, staticDir, handle }; @@ -104,6 +134,10 @@ describe("spy web service", () => { expect(spyServiceConfigFromEnv({}).enabled).toBe(true); expect(spyServiceConfigFromEnv({ ROOTCELL_SPY_ENABLED: "false" }).enabled).toBe(false); expect(spyServiceConfigFromEnv({ ROOTCELL_SPY_ENABLED: "true" }).enabled).toBe(true); + expect(spyServiceConfigFromEnv({}).tokenCountMode).toBe("provider"); + expect(spyServiceConfigFromEnv({ ROOTCELL_SPY_TOKEN_COUNT_MODE: "estimate" }).tokenCountMode).toBe("provider"); + expect(spyServiceConfigFromEnv({ ROOTCELL_SPY_TOKEN_COUNT_MODE: "provider" }).tokenCountMode).toBe("provider"); + expect(spyServiceConfigFromEnv({ AWS_REGION: "us-west-2" }).bedrockRegion).toBe("us-west-2"); }); test("serves health, paginated calls, details, diff, stream events, and search", async () => { @@ -122,6 +156,7 @@ describe("spy web service", () => { const health = await jsonAs(healthResponse, SpyServiceHealthSchema); expect(health.service.enabled).toBe(true); expect(health.service.storeRaw).toBe(false); + expect(health.service.tokenCountMode).toBe("provider"); expect(health.store.providerCallCount).toBe(5); expect(health.store.droppedCaptureCount).toBe(0); expect(health.store.lastIngestAt).not.toBeNull(); @@ -150,6 +185,7 @@ describe("spy web service", () => { expect(detail.requestComposition.totalMessageCount).toBeGreaterThan(0); expect(detail.requestComposition.sections.some((section) => section.present)).toBe(true); expect(detail.requestComposition.usage).toEqual(detail.summary.usage); + expect(detail.compaction.status).toBe("none"); expect(detail.httpEvents).toHaveLength(2); expect(detail.blocks.length).toBeGreaterThan(0); expect(detail.usageRecords.length).toBeGreaterThan(0); @@ -221,6 +257,141 @@ describe("spy web service", () => { expect(missingResponse.status).toBe(404); }); + test("returns call details immediately and streams provider token counts over SSE", async () => { + const counter = new FakeTokenCounter(77, undefined, 250); + const { handle, spoolDir } = createTestService({ tokenCounter: counter, tokenCountMode: "provider" }); + writeSpoolEvents(spoolDir, fixtureEvents()); + expect(handle.ingestOnce()).toMatchObject({ ingested: 10 }); + + const response = await fetch(`${handle.url}/api/events`); + expect(response.status).toBe(200); + if (response.body === null) { + throw new Error("missing SSE body"); + } + const reader = response.body.getReader(); + try { + await readSseUntil(reader, "event: hello"); + + const page = await jsonAs(await fetch(`${handle.url}/api/calls?limit=1`), SpyCallSummaryPageSchema); + const callId = page.items[0]?.call.id; + if (callId === undefined) { + throw new Error("missing call id"); + } + const detail = await jsonAs(await fetch(`${handle.url}/api/calls/${encodeURIComponent(callId)}`), SpyCallDetailSchema); + const block = detail.blocks.find((candidate) => candidate.direction === "request" && candidate.text !== undefined); + if (block === undefined) { + throw new Error("missing text block"); + } + const responseBlock = detail.blocks.find((candidate) => candidate.direction === "response" && candidate.text !== undefined); + if (responseBlock === undefined) { + throw new Error("missing response text block"); + } + + expect(detail.tokenCounts.find((record) => record.subjectType === "block" && record.blockId === block.id)) + .toBeUndefined(); + expect(counter.inputs).toHaveLength(0); + + const tokenEvents = await readSseUntil(reader, "event: token-counts-changed"); + expect(tokenEvents).toContain(`"callId":"${callId}"`); + expect(tokenEvents).toContain("\"provenance\":\"provider_counted\""); + expectValidSsePayloads(tokenEvents); + await waitUntil(() => counter.inputs.length >= expectedBackgroundProviderCount(detail)); + + const refreshed = await jsonAs(await fetch(`${handle.url}/api/calls/${encodeURIComponent(callId)}`), SpyCallDetailSchema); + expect(refreshed.tokenCounts.find((record) => record.subjectType === "block" && record.blockId === responseBlock.id)) + .toMatchObject({ subjectType: "block", direction: "response", provenance: "provider_counted", tokens: 77 }); + } finally { + await reader.cancel(); + } + }); + + test("counts tokens through explicit provider calls and cached provider counts", async () => { + const counter = new FakeTokenCounter(77); + const { handle, spoolDir } = createTestService({ tokenCounter: counter, tokenCountMode: "provider" }); + writeSpoolEvents(spoolDir, fixtureEvents()); + expect(handle.ingestOnce()).toMatchObject({ ingested: 10 }); + + const page = await jsonAs(await fetch(`${handle.url}/api/calls?limit=1`), SpyCallSummaryPageSchema); + const callId = page.items[0]?.call.id; + if (callId === undefined) { + throw new Error("missing call id"); + } + const detail = handle.store.getCallDetail(callId); + if (detail === null) { + throw new Error("missing call detail"); + } + const block = detail.blocks.find((candidate) => candidate.direction === "request" && candidate.text !== undefined); + if (block === undefined) { + throw new Error("missing text block"); + } + + const countedBlock = await jsonAs(await fetch(`${handle.url}/api/token-count`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ mode: "provider", subjects: [{ type: "block", callId, blockId: block.id }] }), + }), SpyTokenCountResponseSchema); + expect(countedBlock.records[0]).toMatchObject({ subjectType: "block", provenance: "provider_counted", tokens: 77 }); + expect(counter.inputs).toHaveLength(1); + + const cachedBlock = await jsonAs(await fetch(`${handle.url}/api/token-count`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ mode: "provider", subjects: [{ type: "block", callId, blockId: block.id }] }), + }), SpyTokenCountResponseSchema); + expect(cachedBlock.records[0]).toMatchObject({ subjectType: "block", provenance: "provider_counted", tokens: 77 }); + expect(counter.inputs).toHaveLength(1); + + const selection = await jsonAs(await fetch(`${handle.url}/api/token-count`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ mode: "provider", subjects: [{ type: "selection", callId, text: "selected text" }] }), + }), SpyTokenCountResponseSchema); + expect(selection.records[0]).toMatchObject({ provenance: "provider_counted", tokens: 77 }); + expect(counter.inputs).toHaveLength(2); + + const cachedSelection = await jsonAs(await fetch(`${handle.url}/api/token-count`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ mode: "provider", subjects: [{ type: "selection", callId, text: "selected text" }] }), + }), SpyTokenCountResponseSchema); + expect(cachedSelection.records[0]).toMatchObject({ provenance: "provider_counted", tokens: 77 }); + expect(counter.inputs).toHaveLength(2); + + const reported = await jsonAs(await fetch(`${handle.url}/api/token-count`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ mode: "provider", subjects: [{ type: "call", callId, direction: "request" }] }), + }), SpyTokenCountResponseSchema); + expect(reported.records[0]).toMatchObject({ subjectType: "call", provenance: "provider_counted", tokens: 77 }); + expect(counter.inputs).toHaveLength(3); + }); + + test("returns unavailable token records when provider counting fails", async () => { + const { handle, spoolDir } = createTestService({ + tokenCounter: new FakeTokenCounter(0, new Error("provider offline")), + tokenCountMode: "provider", + }); + writeSpoolEvents(spoolDir, fixtureEvents()); + expect(handle.ingestOnce()).toMatchObject({ ingested: 10 }); + + const page = await jsonAs(await fetch(`${handle.url}/api/calls?limit=1`), SpyCallSummaryPageSchema); + const callId = page.items[0]?.call.id; + if (callId === undefined) { + throw new Error("missing call id"); + } + const response = await jsonAs(await fetch(`${handle.url}/api/token-count`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ mode: "provider", subjects: [{ type: "selection", callId, text: "selected text" }] }), + }), SpyTokenCountResponseSchema); + expect(response.records[0]).toMatchObject({ + subjectType: "selection", + provenance: "unavailable", + tokens: null, + error: "provider offline", + }); + }); + test("returns raw payloads only when raw storage is enabled", async () => { const { handle, spoolDir } = createTestService({ storeRaw: true }); writeSpoolEvents(spoolDir, fixtureEvents()); @@ -348,6 +519,49 @@ async function sleep(ms: number): Promise { await new Promise((resolve) => setTimeout(resolve, ms)); } +async function waitUntil(predicate: () => boolean, timeoutMs = 2_000): Promise { + const deadline = Date.now() + timeoutMs; + while (!predicate()) { + if (Date.now() >= deadline) { + throw new Error("timed out waiting for condition"); + } + await sleep(10); + } +} + +function expectedBackgroundProviderCount(detail: SpyCallDetail): number { + const present = new Set(detail.tokenCounts + .filter((record) => record.provenance !== "unavailable") + .map((record) => { + if (record.subjectType === "call") { + return ["call", record.callId ?? "", record.direction ?? "", "", "", ""].join(":"); + } + if (record.subjectType === "section") { + return ["section", record.callId ?? "", record.direction ?? "", "", record.kind ?? "", ""].join(":"); + } + if (record.subjectType === "block") { + return ["block", record.callId ?? "", "", record.blockId ?? "", ""].join(":"); + } + return ["selection", record.callId ?? "", "", "", "", record.label ?? record.sourceHash].join(":"); + })); + const missing = new Set(); + const callKey = ["call", detail.summary.call.id, "request", "", "", ""].join(":"); + if (!present.has(callKey)) { + missing.add(callKey); + } + for (const block of detail.blocks) { + const sectionKey = ["section", detail.summary.call.id, block.direction, "", block.kind, ""].join(":"); + if (!present.has(sectionKey)) { + missing.add(sectionKey); + } + const blockKey = ["block", detail.summary.call.id, "", block.id, "", ""].join(":"); + if (!present.has(blockKey)) { + missing.add(blockKey); + } + } + return missing.size; +} + function expectValidSsePayloads(text: string): void { const frames = text.split("\n\n").filter((frame) => frame.trim().length > 0); for (const frame of frames) { diff --git a/src/spy/service.ts b/src/spy/service.ts index cfb6128..a1e1c49 100644 --- a/src/spy/service.ts +++ b/src/spy/service.ts @@ -1,11 +1,24 @@ import { existsSync, statSync } from "node:fs"; import { extname, isAbsolute, join, relative, resolve } from "node:path"; import { z } from "zod"; +import { + AwsBedrockTokenCounter, + bedrockCountInputForText, + bedrockCountInputFromRequestBody, + type BedrockTokenCounter, +} from "./bedrock-token-count.ts"; +import { + SpyTokenCountRequestSchema, + type SpyTokenCountMode, + type SpyTokenCountRecord, + type SpyTokenCountSubject, +} from "./api-contracts.ts"; import { openSpyStore, type IngestSpoolBatchResult, type RetentionResult, type SpyHealthSnapshot, + type SpyCallDetail, type SpyListCallsOptions, type SpySearchCallsOptions, type SpyStore, @@ -13,6 +26,7 @@ import { type SpyStreamEventsOptions, } from "./store.ts"; import { ProviderCallStatusSchema } from "./schemas.ts"; +import { unavailableTokenRecord } from "./tokens.ts"; const DEFAULT_BIND = "127.0.0.1"; const DEFAULT_PORT = 6174; @@ -24,6 +38,8 @@ const DEFAULT_SPOOL_MAX_BYTES = 1024 * 1024 * 1024; const DEFAULT_INGEST_INTERVAL_MS = 500; const DEFAULT_RETENTION_INTERVAL_MS = 15 * 60 * 1000; const DEFAULT_INGEST_BATCH_LIMIT = 100; +const DEFAULT_TOKEN_COUNT_MODE: SpyTokenCountMode = "provider"; +const TOKEN_COUNT_CONCURRENCY = 8; const ClearRequestSchema = z.object({ confirm: z.literal(true), @@ -40,6 +56,8 @@ export interface SpyServiceConfig { readonly maxBytes: number; readonly spoolMaxBytes: number; readonly storeRaw: boolean; + readonly tokenCountMode: SpyTokenCountMode; + readonly bedrockRegion: string; readonly ingestIntervalMs: number; readonly retentionIntervalMs: number; readonly ingestBatchLimit: number; @@ -55,6 +73,7 @@ export interface SpyServiceHealth { readonly maxBytes: number; readonly spoolMaxBytes: number; readonly storeRaw: boolean; + readonly tokenCountMode: SpyTokenCountMode; readonly staticAssets: boolean; }; readonly store: SpyHealthSnapshot; @@ -64,6 +83,7 @@ export interface StartSpyServiceOptions { readonly config?: Partial | undefined; readonly startIngestion?: boolean | undefined; readonly now?: (() => number) | undefined; + readonly tokenCounter?: BedrockTokenCounter | undefined; } export interface SpyServiceHandle { @@ -93,6 +113,7 @@ class HttpError extends Error { class SpyHttpService { private readonly encoder = new TextEncoder(); private readonly clients = new Map(); + private readonly backgroundTokenCountKeys = new Set(); private ingestTimer: ReturnType | undefined; private retentionTimer: ReturnType | undefined; private nextClientId = 1; @@ -100,6 +121,7 @@ class SpyHttpService { constructor( private readonly config: SpyServiceConfig, private readonly store: SpyStore, + private readonly tokenCounter: BedrockTokenCounter | undefined, ) {} start(startIngestion: boolean): void { @@ -190,6 +212,10 @@ class SpyHttpService { this.broadcastHealth(); return jsonResponse(result); } + if (request.method === "POST" && path === "/api/token-count") { + const body = SpyTokenCountRequestSchema.parse(await jsonBody(request)); + return jsonResponse(await this.countTokens(body.subjects, body.mode ?? this.config.tokenCountMode)); + } const streamMatch = /^\/api\/calls\/([^/]+)\/stream-events$/.exec(path); if (request.method === "GET" && streamMatch !== null) { @@ -214,6 +240,7 @@ class SpyHttpService { if (detail === null) { throw new HttpError(404, "call not found"); } + this.startBackgroundTokenCounts(detail); return jsonResponse(detail); } @@ -231,6 +258,7 @@ class SpyHttpService { maxBytes: this.config.maxBytes, spoolMaxBytes: this.config.spoolMaxBytes, storeRaw: this.config.storeRaw, + tokenCountMode: this.config.tokenCountMode, staticAssets: this.config.staticDir !== undefined, }, store: this.store.getHealthSnapshot(), @@ -266,6 +294,87 @@ class SpyHttpService { }); } + private async countTokens( + subjects: readonly SpyTokenCountSubject[], + mode: SpyTokenCountMode, + ): Promise<{ readonly mode: SpyTokenCountMode; readonly records: readonly SpyTokenCountRecord[] }> { + const records = await mapWithConcurrency(subjects, TOKEN_COUNT_CONCURRENCY, async (subject) => await this.countTokenSubject(subject)); + return { mode, records }; + } + + private async countTokenSubject(subject: SpyTokenCountSubject): Promise { + const prepared = this.store.prepareTokenCountSubject(subject); + if (prepared === null) { + return unavailableTokenRecord({ + subjectType: subject.type, + callId: subject.callId, + ...(subject.type === "block" ? { blockId: subject.blockId } : {}), + ...(subject.type === "section" || subject.type === "call" ? { direction: subject.direction } : {}), + ...(subject.type === "section" ? { kind: subject.kind } : {}), + ...(subject.type === "selection" && subject.label !== undefined ? { label: subject.label } : {}), + sourceHash: "missing", + modelId: "unknown", + countedAt: Date.now() / 1000, + }, "call or subject not found"); + } + if (prepared.cacheKey !== undefined) { + const cached = this.store.getCachedProviderTokenCount(prepared.cacheKey); + if (cached !== null) { + return cached; + } + } + if (this.tokenCounter === undefined) { + return unavailableTokenRecord(prepared.base, "provider token counting is not configured"); + } + try { + const input = prepared.requestBodyText === undefined + ? bedrockCountInputForText(prepared.text) + : bedrockCountInputFromRequestBody(prepared.requestBodyText) ?? bedrockCountInputForText(prepared.text); + const tokens = await this.tokenCounter.count({ modelId: prepared.base.modelId, input }); + const record: SpyTokenCountRecord = { + ...prepared.base, + tokens, + provenance: "provider_counted", + }; + this.store.saveProviderTokenCount(record); + return record; + } catch (error) { + return unavailableTokenRecord(prepared.base, errorMessage(error)); + } + } + + private startBackgroundTokenCounts(detail: SpyCallDetail): void { + const subjects = missingTokenCountSubjects(detail); + if (subjects.length === 0) { + return; + } + const pending = subjects.filter((subject) => { + const key = subjectKey(subject); + if (this.backgroundTokenCountKeys.has(key)) { + return false; + } + this.backgroundTokenCountKeys.add(key); + return true; + }); + if (pending.length === 0) { + return; + } + const callId = detail.summary.call.id; + void mapWithConcurrency(pending, TOKEN_COUNT_CONCURRENCY, async (subject) => { + const key = subjectKey(subject); + try { + const record = await this.countTokenSubject(subject); + this.broadcast("token-counts-changed", { callId, records: [record] }); + } finally { + this.backgroundTokenCountKeys.delete(key); + } + }).catch(() => { + for (const subject of pending) { + this.backgroundTokenCountKeys.delete(subjectKey(subject)); + } + }); + } + private serveStatic(path: string, requestHeaders: Headers): Response { const staticDir = this.config.staticDir; if (staticDir === undefined) { @@ -350,6 +459,8 @@ export function spyServiceConfigFromEnv(env: NodeJS.ProcessEnv = process.env): S maxBytes: envNumber(env.ROOTCELL_SPY_MAX_BYTES, DEFAULT_MAX_BYTES), spoolMaxBytes: envNumber(env.ROOTCELL_SPY_SPOOL_MAX_BYTES, DEFAULT_SPOOL_MAX_BYTES), storeRaw: envBoolean(env.ROOTCELL_SPY_STORE_RAW, false), + tokenCountMode: tokenCountModeFromEnv(env.ROOTCELL_SPY_TOKEN_COUNT_MODE), + bedrockRegion: nonEmpty(env.ROOTCELL_SPY_BEDROCK_REGION) ?? nonEmpty(env.AWS_REGION) ?? nonEmpty(env.AWS_DEFAULT_REGION) ?? "us-east-1", ingestIntervalMs: envNumber(env.ROOTCELL_SPY_INGEST_INTERVAL_MS, DEFAULT_INGEST_INTERVAL_MS), retentionIntervalMs: envNumber(env.ROOTCELL_SPY_RETENTION_INTERVAL_MS, DEFAULT_RETENTION_INTERVAL_MS), ingestBatchLimit: envNumber(env.ROOTCELL_SPY_INGEST_BATCH_LIMIT, DEFAULT_INGEST_BATCH_LIMIT), @@ -367,17 +478,23 @@ export function startSpyService(options: StartSpyServiceOptions = {}): SpyServic ...(options.now === undefined ? {} : { now: options.now }), }; const store = openSpyStore(storeOptions); + const tokenCounter = options.tokenCounter ?? ( + hasBedrockCredentialEnv() + ? new AwsBedrockTokenCounter({ region: config.bedrockRegion }) + : undefined + ); let service: SpyHttpService | undefined; let server: Bun.Server | undefined; let activeConfig = config; let lastListenError: unknown; for (const port of candidatePorts(config.port)) { activeConfig = { ...config, port }; - service = new SpyHttpService(activeConfig, store); + service = new SpyHttpService(activeConfig, store, tokenCounter); try { server = Bun.serve({ hostname: activeConfig.bind, port: activeConfig.port, + idleTimeout: 120, fetch: (request) => { if (service === undefined) { return jsonError(503, "service unavailable"); @@ -550,6 +667,10 @@ function responseForError(error: unknown): Response { return jsonError(500, error instanceof Error ? error.message : "internal error"); } +function errorMessage(error: unknown): string { + return error instanceof Error ? error.message : String(error); +} + function nonEmpty(value: string | undefined): string | undefined { const trimmed = value?.trim(); return trimmed === undefined || trimmed.length === 0 ? undefined : trimmed; @@ -570,6 +691,116 @@ function envBoolean(value: string | undefined, fallback: boolean): boolean { return ["1", "true", "yes", "on"].includes(value.trim().toLowerCase()); } +function hasBedrockCredentialEnv(env: NodeJS.ProcessEnv = process.env): boolean { + return nonEmpty(env.AWS_BEARER_TOKEN_BEDROCK) !== undefined + || nonEmpty(env.AWS_ACCESS_KEY_ID) !== undefined + || nonEmpty(env.AWS_SECRET_ACCESS_KEY) !== undefined + || nonEmpty(env.AWS_SESSION_TOKEN) !== undefined + || nonEmpty(env.AWS_SECURITY_TOKEN) !== undefined; +} + +function tokenCountModeFromEnv(value: string | undefined): SpyTokenCountMode { + const normalized = value?.trim().toLowerCase(); + return normalized === "provider" ? "provider" : DEFAULT_TOKEN_COUNT_MODE; +} + +function missingTokenCountSubjects(detail: SpyCallDetail): SpyTokenCountSubject[] { + const present = new Set( + detail.tokenCounts + .filter((record) => record.provenance !== "unavailable") + .map(recordSubjectKey), + ); + const subjects: SpyTokenCountSubject[] = []; + const requestCall: SpyTokenCountSubject = { + type: "call", + callId: detail.summary.call.id, + direction: "request", + }; + if (!present.has(subjectKey(requestCall))) { + subjects.push(requestCall); + } + + const sectionKeys = new Set(); + for (const block of detail.blocks) { + const section: SpyTokenCountSubject = { + type: "section", + callId: detail.summary.call.id, + direction: block.direction, + kind: block.kind, + }; + const key = subjectKey(section); + if (!sectionKeys.has(key)) { + sectionKeys.add(key); + if (!present.has(key)) { + subjects.push(section); + } + } + + const blockSubject: SpyTokenCountSubject = { + type: "block", + callId: detail.summary.call.id, + blockId: block.id, + }; + if (!present.has(subjectKey(blockSubject))) { + subjects.push(blockSubject); + } + } + return subjects; +} + +function subjectKey(subject: SpyTokenCountSubject): string { + if (subject.type === "call") { + return ["call", subject.callId, subject.direction, "", "", ""].join(":"); + } + if (subject.type === "section") { + return ["section", subject.callId, subject.direction, "", subject.kind, ""].join(":"); + } + if (subject.type === "block") { + return ["block", subject.callId, "", subject.blockId, "", ""].join(":"); + } + return ["selection", subject.callId, "", "", "", subject.label ?? subject.text].join(":"); +} + +function recordSubjectKey(record: SpyTokenCountRecord): string { + if (record.subjectType === "call") { + return ["call", record.callId ?? "", record.direction ?? "", "", "", ""].join(":"); + } + if (record.subjectType === "section") { + return ["section", record.callId ?? "", record.direction ?? "", "", record.kind ?? "", ""].join(":"); + } + if (record.subjectType === "block") { + return ["block", record.callId ?? "", "", record.blockId ?? "", "", ""].join(":"); + } + return ["selection", record.callId ?? "", "", "", "", record.label ?? record.sourceHash].join(":"); +} + +async function mapWithConcurrency( + items: readonly T[], + concurrency: number, + mapper: (item: T) => Promise, +): Promise { + const results = new Array(items.length); + let nextIndex = 0; + async function worker(): Promise { + for (;;) { + const index = nextIndex; + nextIndex += 1; + if (index >= items.length) { + return; + } + const item = items[index] as T; + results[index] = await mapper(item); + } + } + await Promise.all(Array.from( + { length: Math.min(concurrency, items.length) }, + async () => { + await worker(); + }, + )); + return results; +} + function candidatePorts(port: number): number[] { if (port !== 0) { return [port]; diff --git a/src/spy/store.test.ts b/src/spy/store.test.ts index 1a03edd..05f7d7d 100644 --- a/src/spy/store.test.ts +++ b/src/spy/store.test.ts @@ -141,6 +141,23 @@ function requestVariant( return SpoolRequestEventSchema.parse({ ...event, ...overrides }); } +function syntheticBedrockRequest(flowId: string, ts: number, body: Record): SpoolRequestEvent { + return SpoolRequestEventSchema.parse({ + version: 1, + ts, + direction: "request", + flow_id: flowId, + provider: "bedrock", + operation: "converse-stream", + model_id: "us.anthropic.claude-sonnet-4-6", + host: "bedrock-runtime.us-east-1.amazonaws.com", + method: "POST", + path: "/model/us.anthropic.claude-sonnet-4-6/converse-stream", + headers: [["content-type", "application/json"]], + body_text: JSON.stringify(body), + }); +} + function responseVariant( event: SpoolResponseEvent, overrides: Partial>, @@ -357,6 +374,7 @@ describe("spy SQLite store", () => { const simple = requiredDetail(store, "call-fixture-flow-simple"); expect(simple.requestComposition.totalBlockCount).toBe(simple.summary.requestBlockCount); + expect(simple.compaction.status).toBe("none"); expect(simple.requestComposition.totalByteSize).toBe(simple.summary.requestByteSize); expect(simple.requestComposition.totalMessageCount).toBe(1); expect(simple.requestComposition.usage).toEqual(simple.summary.usage); @@ -389,6 +407,110 @@ describe("spy SQLite store", () => { } }); + test("computes Pi compaction assessments from request context transitions", () => { + const { store } = createTestStore(); + try { + store.persistRequest(syntheticBedrockRequest("fixture-flow-pre-compaction", 1, { + messages: [ + { role: "user", content: [{ text: "First historical user turn. ".repeat(180) }] }, + { role: "assistant", content: [{ text: "First historical assistant turn. ".repeat(180) }] }, + { role: "user", content: [{ text: "Second historical user turn. ".repeat(180) }] }, + { role: "assistant", content: [{ text: "Second historical assistant turn. ".repeat(180) }] }, + { role: "user", content: [{ text: "continue before compaction" }] }, + ], + system: [{ text: "You are an expert coding assistant operating inside pi, a coding agent harness." }], + inferenceConfig: { maxTokens: 32_000 }, + })); + store.persistRequest(syntheticBedrockRequest("fixture-flow-post-compaction", 2, { + messages: [ + { role: "user", content: [{ text: "Summary of the conversation so far: the user asked for a multi-file refactor and the agent edited the store layer." }] }, + { role: "user", content: [{ text: "continue after compaction" }] }, + ], + system: [{ text: "You are an expert coding assistant operating inside pi, a coding agent harness." }], + inferenceConfig: { maxTokens: 32_000 }, + })); + + const detail = requiredDetail(store, bedrockCallIdForFlow("fixture-flow-post-compaction")); + expect(detail.compaction).toMatchObject({ + status: "candidate", + source: "pi_pattern", + label: "Pi compaction candidate", + }); + expect(detail.compaction.reasons).toContain("summary_like_history_block"); + expect(detail.compaction.reasons).toContain("prior_history_byte_drop"); + expect(detail.compaction.evidence.previousCallId).toBe(bedrockCallIdForFlow("fixture-flow-pre-compaction")); + } finally { + store.close(); + } + }); + + test("prepares, caches, and cascades token count records", () => { + let now = 1_000; + const { dbPath, spoolDir, store } = createTestStore({ now: () => now }); + try { + writeSpoolEvents(spoolDir, fixturePair()); + expect(store.ingestSpoolBatch()).toMatchObject({ ingested: 2 }); + + const callId = bedrockCallIdForFlow("fixture-flow-simple"); + let detail = requiredDetail(store, callId); + const requestTokenCount = detail.tokenCounts.find((record) => + record.subjectType === "call" && record.direction === "request" + ); + expect(requestTokenCount).toBeUndefined(); + + const block = detail.blocks.find((candidate) => candidate.direction === "request" && candidate.text !== undefined); + if (block === undefined) { + throw new Error("missing request text block"); + } + const prepared = store.prepareTokenCountSubject({ type: "block", callId, blockId: block.id }); + expect(prepared).not.toBeNull(); + expect(prepared?.base).toMatchObject({ + subjectType: "block", + blockId: block.id, + }); + + now = 1_100; + store.saveProviderTokenCount({ + subjectType: "block", + callId, + blockId: block.id, + direction: block.direction, + kind: block.kind, + sourceHash: block.content_hash, + modelId: detail.summary.call.model_id, + tokens: 42, + provenance: "provider_counted", + countedAt: now, + }); + detail = requiredDetail(store, callId); + expect(detail.tokenCounts.find((record) => record.subjectType === "block" && record.blockId === block.id)) + .toMatchObject({ provenance: "provider_counted", tokens: 42 }); + expect(countRows(dbPath, "token_count")).toBe(1); + + const selection = store.prepareTokenCountSubject({ type: "selection", callId, text: "selected text", label: "selection" }); + expect(selection?.cacheKey).toBeDefined(); + now = 1_200; + store.saveProviderTokenCount({ + subjectType: "selection", + callId, + label: "selection", + sourceHash: selection?.base.sourceHash ?? "", + modelId: detail.summary.call.model_id, + tokens: 7, + provenance: "provider_counted", + countedAt: now, + }); + expect(store.getCachedProviderTokenCount(selection?.cacheKey ?? "missing")) + .toMatchObject({ subjectType: "selection", label: "selection", provenance: "provider_counted", tokens: 7 }); + expect(countRows(dbPath, "token_count")).toBe(2); + + store.clearData(); + expect(countRows(dbPath, "token_count")).toBe(0); + } finally { + store.close(); + } + }); + test("persists raw payloads only when raw storage is enabled", () => { const disabledComposition = (() => { const rawDisabled = createTestStore(); diff --git a/src/spy/store.ts b/src/spy/store.ts index 9066584..5e15048 100644 --- a/src/spy/store.ts +++ b/src/spy/store.ts @@ -1,11 +1,17 @@ import { existsSync, mkdirSync, readdirSync, readFileSync, rmSync, statSync } from "node:fs"; import { dirname, join } from "node:path"; import { Database } from "bun:sqlite"; +import type { + SpyCompactionAssessment, + SpyTokenCountRecord, + SpyTokenCountSubject, +} from "./api-contracts.ts"; import { bedrockCallIdForFlow, normalizeBedrockRequest, normalizeBedrockResponse, } from "./bedrock.ts"; +import { detectCompaction } from "./compaction.ts"; import { applySpyMigrations, currentSpySchemaVersion } from "./migrations.ts"; import { HttpEventRecordSchema, @@ -29,6 +35,13 @@ import { type StreamEvent, type UsageRecord, } from "./schemas.ts"; +import { + textForBlock, + textForBlocks, + tokenCacheKey, + tokenSourceHash, + type TokenRecordBase, +} from "./tokens.ts"; const DEFAULT_RETENTION_DAYS = 7; const DEFAULT_MAX_BYTES = 6 * 1024 * 1024 * 1024; @@ -177,12 +190,22 @@ export interface SpyStreamEventsOptions { export interface SpyCallDetail { readonly summary: SpyCallSummary; readonly requestComposition: SpyRequestComposition; + readonly compaction: SpyCompactionAssessment; + readonly tokenCounts: readonly SpyTokenCountRecord[]; readonly httpEvents: readonly HttpEventRecord[]; readonly blocks: readonly NormalizedBlock[]; readonly usageRecords: readonly UsageRecord[]; readonly rawPayloads: readonly RawPayloadRecord[]; } +export interface SpyPreparedTokenCountSubject { + readonly subject: SpyTokenCountSubject; + readonly base: TokenRecordBase; + readonly text: string; + readonly requestBodyText?: string | undefined; + readonly cacheKey?: string | undefined; +} + export interface SpyBlockDiff { readonly block: NormalizedBlock; readonly classification: DiffClassification; @@ -204,6 +227,9 @@ export interface SpyStore { getCallDiff(callId: string): SpyCallDiff | null; getStreamEvents(callId: string, options?: SpyStreamEventsOptions): SpyPaginatedResult; searchCallSummaries(options: SpySearchCallsOptions): SpyPaginatedResult; + prepareTokenCountSubject(subject: SpyTokenCountSubject): SpyPreparedTokenCountSubject | null; + getCachedProviderTokenCount(cacheKey: string): SpyTokenCountRecord | null; + saveProviderTokenCount(record: SpyTokenCountRecord): void; runRetention(): RetentionResult; clearData(): ClearDataResult; getHealthSnapshot(): SpyHealthSnapshot; @@ -320,6 +346,23 @@ interface RawPayloadRow { readonly body_encoding: "aws-eventstream" | null; } +interface TokenCountRow { + readonly id: string; + readonly call_id: string; + readonly subject_type: SpyTokenCountRecord["subjectType"]; + readonly direction: "request" | "response" | null; + readonly block_id: string | null; + readonly kind: NormalizedBlock["kind"] | null; + readonly label: string | null; + readonly source_hash: string; + readonly cache_key: string; + readonly model_id: string; + readonly tokens: number | null; + readonly provenance: "provider_counted"; + readonly counted_at: number; + readonly error: string | null; +} + interface UsageSummaryRow { readonly input_tokens: number | null; readonly output_tokens: number | null; @@ -454,12 +497,21 @@ ORDER BY observed_at ASC, direction ASC `).all(callId) as HttpEventRow[]; const blocks = this.blocksForCall(callId); + const requestBlocks = blocks.filter((block) => block.direction === "request"); + const requestComposition = requestCompositionForBlocks(requestBlocks, summary.usage); + const previousRow = this.previousComparableCallRow(row); + const previousSummary = previousRow === null ? null : this.callSummaryForRow(previousRow); + const previousRequestBlocks = previousRow === null ? [] : this.blocksForCall(previousRow.id, "request"); return { summary, - requestComposition: requestCompositionForBlocks( - blocks.filter((block) => block.direction === "request"), - summary.usage, - ), + requestComposition, + compaction: detectCompaction({ + summary, + requestBlocks, + previousSummary, + previousRequestBlocks, + }), + tokenCounts: this.tokenCountsForCall(summary), httpEvents: httpEvents.map(httpEventFromRow), blocks, usageRecords: this.usageRecordsForCall(callId), @@ -473,19 +525,7 @@ ORDER BY observed_at ASC, direction ASC return null; } - const previousRow = this.db.query(` -SELECT id, provider, operation, model_id, status, started_at, completed_at, - status_code, request_flow_id, response_flow_id, request_content_hash, - response_content_hash -FROM provider_call -WHERE provider = ? - AND model_id = ? - AND operation = ? - AND (started_at < ? OR (started_at = ? AND id < ?)) -ORDER BY started_at DESC, id DESC -LIMIT 1 -`).get(row.provider, row.model_id, row.operation, row.started_at, row.started_at, row.id) as ProviderCallRow | null; - + const previousRow = this.previousComparableCallRow(row); const currentBlocks = this.blocksForCall(callId, "request"); const previousBlocks = previousRow === null ? [] : this.blocksForCall(previousRow.id, "request"); const previousByHash = new Map(); @@ -598,6 +638,155 @@ LIMIT ? return this.paginatedCallSummaries(rows, limit); } + prepareTokenCountSubject(subject: SpyTokenCountSubject): SpyPreparedTokenCountSubject | null { + const row = this.callRow(subject.callId); + if (row === null) { + return null; + } + const countedAt = this.now(); + + if (subject.type === "call") { + const blocks = this.blocksForCall(subject.callId, subject.direction); + const text = textForBlocks(blocks); + const sourceHash = row.request_content_hash ?? tokenSourceHash(text); + const base: TokenRecordBase = { + subjectType: "call", + callId: subject.callId, + direction: subject.direction, + sourceHash, + modelId: row.model_id, + countedAt, + }; + return { + subject, + base, + text, + requestBodyText: this.requestBodyTextForCall(subject.callId), + cacheKey: tokenCacheKey(base), + }; + } + + if (subject.type === "section") { + const blocks = this.blocksForCall(subject.callId, subject.direction) + .filter((block) => block.kind === subject.kind); + const text = textForBlocks(blocks); + const sourceHash = tokenSourceHash(blocks.map((block) => block.content_hash).join("\n")); + const base: TokenRecordBase = { + subjectType: "section", + callId: subject.callId, + direction: subject.direction, + kind: subject.kind, + sourceHash, + modelId: row.model_id, + countedAt, + }; + return { + subject, + base, + text, + cacheKey: tokenCacheKey(base), + }; + } + + if (subject.type === "block") { + const block = this.blockForCall(subject.callId, subject.blockId); + if (block === null) { + return null; + } + const text = textForBlock(block); + const base: TokenRecordBase = { + subjectType: "block", + callId: subject.callId, + blockId: subject.blockId, + direction: block.direction, + kind: block.kind, + sourceHash: block.content_hash, + modelId: row.model_id, + countedAt, + }; + return { + subject, + base, + text, + cacheKey: tokenCacheKey(base), + }; + } + + const sourceHash = tokenSourceHash(subject.text); + const base: TokenRecordBase = { + subjectType: "selection", + callId: subject.callId, + label: subject.label, + sourceHash, + modelId: row.model_id, + countedAt, + }; + return { + subject, + base, + text: subject.text, + cacheKey: tokenCacheKey(base), + }; + } + + getCachedProviderTokenCount(cacheKey: string): SpyTokenCountRecord | null { + const row = this.db.query(` +SELECT id, call_id, subject_type, direction, block_id, kind, label, source_hash, + cache_key, model_id, tokens, provenance, counted_at, error +FROM token_count +WHERE cache_key = ? +`).get(cacheKey) as TokenCountRow | null; + return row === null ? null : tokenCountFromRow(row); + } + + saveProviderTokenCount(record: SpyTokenCountRecord): void { + if (record.callId === undefined) { + return; + } + if (record.provenance !== "provider_counted") { + return; + } + const callId = record.callId; + const cacheKey = tokenCacheKey({ + subjectType: record.subjectType, + callId, + blockId: record.blockId, + direction: record.direction, + kind: record.kind, + sourceHash: record.sourceHash, + modelId: record.modelId, + }); + this.withWriteLock(() => { + this.db.query(` +INSERT INTO token_count ( + id, call_id, subject_type, direction, block_id, kind, label, source_hash, cache_key, + model_id, tokens, provenance, counted_at, error +) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) +ON CONFLICT(cache_key) DO UPDATE SET + label = excluded.label, + tokens = excluded.tokens, + provenance = excluded.provenance, + counted_at = excluded.counted_at, + error = excluded.error +`).run( + tokenCountId(record), + callId, + record.subjectType, + record.direction ?? null, + record.blockId ?? null, + record.kind ?? null, + record.label ?? null, + record.sourceHash, + cacheKey, + record.modelId, + record.tokens, + record.provenance, + record.countedAt, + record.error ?? null, + ); + }); + } + runRetention(): RetentionResult { return this.withWriteLock(() => { let deletedByAge = 0; @@ -1029,6 +1218,58 @@ ORDER BY direction ASC, ordinal ASC, id ASC return rows.map(normalizedBlockFromRow); } + private previousComparableCallRow(row: ProviderCallRow): ProviderCallRow | null { + return this.db.query(` +SELECT id, provider, operation, model_id, status, started_at, completed_at, + status_code, request_flow_id, response_flow_id, request_content_hash, + response_content_hash +FROM provider_call +WHERE provider = ? + AND model_id = ? + AND operation = ? + AND (started_at < ? OR (started_at = ? AND id < ?)) +ORDER BY started_at DESC, id DESC +LIMIT 1 +`).get(row.provider, row.model_id, row.operation, row.started_at, row.started_at, row.id) as ProviderCallRow | null; + } + + private blockForCall(callId: string, blockId: string): NormalizedBlock | null { + const row = this.db.query(` +SELECT id, call_id, direction, ordinal, role, kind, source, provider_path, + text, json, char_size, byte_size, content_hash, cache_marker +FROM normalized_block +WHERE call_id = ? AND id = ? +`).get(callId, blockId) as NormalizedBlockRow | null; + return row === null ? null : normalizedBlockFromRow(row); + } + + private requestBodyTextForCall(callId: string): string | undefined { + const row = this.db.query(` +SELECT body_text +FROM http_event +WHERE call_id = ? AND direction = 'request' AND body_text IS NOT NULL +ORDER BY observed_at ASC, id ASC +LIMIT 1 +`).get(callId) as { readonly body_text: string | null } | null; + return row?.body_text ?? undefined; + } + + private tokenCountsForCall(summary: SpyCallSummary): SpyTokenCountRecord[] { + const records = new Map(); + const cachedRows = this.db.query(` +SELECT id, call_id, subject_type, direction, block_id, kind, label, source_hash, + cache_key, model_id, tokens, provenance, counted_at, error +FROM token_count +WHERE call_id = ? +ORDER BY counted_at DESC, id ASC +`).all(summary.call.id) as TokenCountRow[]; + for (const row of cachedRows) { + addBestTokenRecord(records, tokenCountFromRow(row)); + } + + return [...records.values()].sort(compareTokenCountRecords); + } + private usageRecordsForCall(callId: string): UsageRecord[] { const rows = this.db.query(` SELECT id, call_id, source, input_tokens, output_tokens, cache_read_tokens, @@ -1310,6 +1551,83 @@ function requestCompositionForBlocks( }; } +function addBestTokenRecord(records: Map, record: SpyTokenCountRecord): void { + const key = tokenRecordKey(record); + const existing = records.get(key); + if (existing === undefined || tokenRecordPriority(record) > tokenRecordPriority(existing)) { + records.set(key, record); + } +} + +function tokenRecordKey(record: SpyTokenCountRecord): string { + return [ + record.subjectType, + record.callId ?? "", + record.direction ?? "", + record.blockId ?? "", + record.kind ?? "", + record.label ?? "", + ].join(":"); +} + +function tokenRecordPriority(record: SpyTokenCountRecord): number { + if (record.provenance === "provider_counted") { + return 4; + } + if (record.provenance === "provider_reported") { + return 3; + } + return 1; +} + +function compareTokenCountRecords(left: SpyTokenCountRecord, right: SpyTokenCountRecord): number { + const subject = left.subjectType.localeCompare(right.subjectType); + if (subject !== 0) { + return subject; + } + const direction = (left.direction ?? "").localeCompare(right.direction ?? ""); + if (direction !== 0) { + return direction; + } + const ordinal = tokenKindOrdinal(left.kind) - tokenKindOrdinal(right.kind); + if (ordinal !== 0) { + return ordinal; + } + return (left.blockId ?? left.label ?? "").localeCompare(right.blockId ?? right.label ?? ""); +} + +function tokenKindOrdinal(kind: NormalizedBlock["kind"] | undefined): number { + return kind === undefined ? -1 : REQUEST_COMPOSITION_SECTION_ORDER.indexOf(kind); +} + +function tokenCountId(record: SpyTokenCountRecord): string { + return [ + "token", + record.callId ?? "transient", + record.subjectType, + record.direction ?? "none", + record.blockId ?? record.kind ?? "all", + record.sourceHash.slice(0, 16), + ].join("-"); +} + +function tokenCountFromRow(row: TokenCountRow): SpyTokenCountRecord { + return { + subjectType: row.subject_type, + callId: row.call_id, + ...(row.block_id === null ? {} : { blockId: row.block_id }), + ...(row.direction === null ? {} : { direction: row.direction }), + ...(row.kind === null ? {} : { kind: row.kind }), + ...(row.label === null ? {} : { label: row.label }), + sourceHash: row.source_hash, + modelId: row.model_id, + tokens: row.tokens, + provenance: row.provenance, + countedAt: row.counted_at, + ...(row.error === null ? {} : { error: row.error }), + }; +} + function messageIndexForBlock(block: NormalizedBlock): number | undefined { const match = /^\$\.messages\[(\d+)\]/.exec(block.provider_path ?? ""); if (match === null) { diff --git a/src/spy/tokens.test.ts b/src/spy/tokens.test.ts new file mode 100644 index 0000000..bc96c78 --- /dev/null +++ b/src/spy/tokens.test.ts @@ -0,0 +1,83 @@ +import { describe, expect, test } from "bun:test"; +import { bedrockCountInputForText, bedrockCountInputFromRequestBody, bedrockTokenCountModelId } from "./bedrock-token-count.ts"; +import { + textForBlock, + tokenCacheKey, + tokenSourceHash, +} from "./tokens.ts"; +import type { NormalizedBlock } from "./schemas.ts"; + +describe("spy token accounting helpers", () => { + test("uses stable JSON text for JSON-only blocks", () => { + const block: NormalizedBlock = { + id: "block-json", + call_id: "call-json", + direction: "request", + ordinal: 0, + kind: "tool-definition", + source: "test", + json: { z: 1, a: { b: 2 } }, + char_size: 0, + byte_size: 0, + content_hash: "hash-json", + cache_marker: false, + }; + expect(textForBlock(block)).toBe('{"a":{"b":2},"z":1}'); + }); + + test("builds stable provider cache keys from subject identity and source hash", () => { + const sourceHash = tokenSourceHash("hello"); + expect(tokenCacheKey({ + subjectType: "block", + callId: "call-one", + blockId: "block-one", + sourceHash, + modelId: "model-one", + })).toBe(`block:call-one::block-one::${sourceHash}:model-one`); + }); + + test("builds Bedrock CountTokens converse input from captured request bodies", () => { + expect(bedrockCountInputFromRequestBody(JSON.stringify({ + messages: [{ role: "user", content: [{ text: "hi" }] }], + system: [{ text: "system" }], + inferenceConfig: { maxTokens: 100 }, + toolConfig: { tools: [] }, + }))).toEqual({ + converse: { + messages: [{ role: "user", content: [{ text: "hi" }] }], + system: [{ text: "system" }], + toolConfig: { tools: [] }, + }, + }); + expect(bedrockCountInputFromRequestBody("{")).toBeNull(); + }); + + test("uses base Anthropic model ids for Bedrock CountTokens", () => { + expect(bedrockTokenCountModelId("us.anthropic.claude-haiku-4-5-20251001-v1:0")) + .toBe("anthropic.claude-haiku-4-5-20251001-v1:0"); + expect(bedrockTokenCountModelId("global.anthropic.claude-haiku-4-5-20251001-v1:0")) + .toBe("anthropic.claude-haiku-4-5-20251001-v1:0"); + expect(bedrockTokenCountModelId("anthropic.claude-3-5-haiku-20241022-v1:0")) + .toBe("anthropic.claude-3-5-haiku-20241022-v1:0"); + expect(bedrockTokenCountModelId("us.amazon.nova-pro-v1:0")) + .toBe("us.amazon.nova-pro-v1:0"); + }); + + test("wraps block text as a minimal Converse token input", () => { + expect(bedrockCountInputForText("selected text")).toEqual({ + converse: { + messages: [{ role: "user", content: [{ text: "selected text" }] }], + }, + }); + expect(bedrockCountInputForText("system text")).toEqual({ + converse: { + messages: [{ role: "user", content: [{ text: "system text" }] }], + }, + }); + expect(bedrockCountInputForText("assistant response")).toEqual({ + converse: { + messages: [{ role: "user", content: [{ text: "assistant response" }] }], + }, + }); + }); +}); diff --git a/src/spy/tokens.ts b/src/spy/tokens.ts new file mode 100644 index 0000000..9cf8e67 --- /dev/null +++ b/src/spy/tokens.ts @@ -0,0 +1,121 @@ +import { createHash } from "node:crypto"; +import type { NormalizedBlock } from "./schemas.ts"; +import type { + SpyTokenCountRecord, + SpyTokenCountSubject, +} from "./api-contracts.ts"; + +export interface TokenRecordBase { + readonly subjectType: SpyTokenCountRecord["subjectType"]; + readonly callId?: string | undefined; + readonly blockId?: string | undefined; + readonly direction?: NormalizedBlock["direction"] | undefined; + readonly kind?: NormalizedBlock["kind"] | undefined; + readonly label?: string | undefined; + readonly sourceHash: string; + readonly modelId: string; + readonly countedAt: number; +} + +export function textForTokenCounting(value: unknown): string { + if (typeof value === "string") { + return value; + } + return stableJson(value); +} + +export function textForBlock(block: NormalizedBlock): string { + if (block.text !== undefined) { + return block.text; + } + if (block.json !== undefined) { + return stableJson(block.json); + } + return ""; +} + +export function textForBlocks(blocks: readonly NormalizedBlock[]): string { + return blocks + .map(textForBlock) + .filter((text) => text.length > 0) + .join("\n\n"); +} + +export function tokenSourceHash(text: string): string { + return createHash("sha256").update(text).digest("hex"); +} + +export function tokenCacheKey(base: Omit): string { + return [ + base.subjectType, + base.callId ?? "", + base.direction ?? "", + base.blockId ?? "", + base.kind ?? "", + base.sourceHash, + base.modelId, + ].join(":"); +} + +export function unavailableTokenRecord(base: TokenRecordBase, error: string): SpyTokenCountRecord { + return { + ...base, + tokens: null, + provenance: "unavailable", + error, + }; +} + +export function tokenSubjectCacheKey(subject: SpyTokenCountSubject, sourceHash: string, modelId: string): string { + if (subject.type === "call") { + return tokenCacheKey({ + subjectType: "call", + callId: subject.callId, + direction: subject.direction, + sourceHash, + modelId, + }); + } + if (subject.type === "section") { + return tokenCacheKey({ + subjectType: "section", + callId: subject.callId, + direction: subject.direction, + kind: subject.kind, + sourceHash, + modelId, + }); + } + if (subject.type === "block") { + return tokenCacheKey({ + subjectType: "block", + callId: subject.callId, + blockId: subject.blockId, + sourceHash, + modelId, + }); + } + return tokenCacheKey({ + subjectType: "selection", + callId: subject.callId, + sourceHash, + modelId, + }); +} + +function stableJson(value: unknown): string { + return JSON.stringify(sortJson(value)); +} + +function sortJson(value: unknown): unknown { + if (Array.isArray(value)) { + return value.map(sortJson); + } + if (value !== null && typeof value === "object") { + const entries = Object.entries(value as Record) + .sort(([left], [right]) => left.localeCompare(right)) + .map(([key, entryValue]) => [key, sortJson(entryValue)] as const); + return Object.fromEntries(entries); + } + return value; +} diff --git a/src/spy/ui/e2e/spy-ui.playwright.ts b/src/spy/ui/e2e/spy-ui.playwright.ts index 8ecfe2f..4581670 100644 --- a/src/spy/ui/e2e/spy-ui.playwright.ts +++ b/src/spy/ui/e2e/spy-ui.playwright.ts @@ -1,11 +1,14 @@ import { expect, type Locator, type Page, type Route, test } from "@playwright/test"; import type { NormalizedBlock, + RawPayloadRecord, SpyCallDetail, SpyCallDiff, SpyCallSummary, + SpyCompactionAssessment, SpyRequestComposition, SpyServiceHealth, + SpyTokenCountRecord, StreamEvent, } from "../src/types.ts"; @@ -195,10 +198,60 @@ test("keeps relative time ranges rolling on refresh", async ({ page }) => { expect(url.searchParams.has("since")).toBe(false); }); +test("preserves inspector scroll during rolling range refreshes", async ({ page }) => { + await page.setViewportSize({ width: 1280, height: 720 }); + await page.goto("/?since=0"); + await expect(page.getByTestId("timeline-row")).toHaveCount(5); + + await page.getByRole("button", { name: "10 min" }).click(); + await expect(page.getByTestId("timeline-row")).toHaveCount(5); + await page.getByTestId("timeline-row").first().click(); + await expect(page.getByTestId("request-composition")).toBeVisible(); + + const beforeRefresh = await page.evaluate(async () => { + const body = document.querySelector('[data-testid="inspector-scroll-body"]'); + if (body === null) { + throw new Error("missing inspector body"); + } + body.scrollTop = body.scrollHeight; + await new Promise((resolve) => { + requestAnimationFrame(() => { + resolve(); + }); + }); + return body.scrollTop; + }); + expect(beforeRefresh).toBeGreaterThan(0); + + const initialSubtitle = (await readRangeState(page)).subtitle; + await page.waitForTimeout(2100); + const refreshResponse = page.waitForResponse((response) => new URL(response.url()).pathname === "/api/calls"); + await page.getByLabel("Refresh calls").click(); + await refreshResponse; + + const afterRefresh = await page.evaluate(async () => { + await new Promise((resolve) => { + requestAnimationFrame(() => { + requestAnimationFrame(() => { + resolve(); + }); + }); + }); + return document.querySelector('[data-testid="inspector-scroll-body"]')?.scrollTop ?? -1; + }); + expect(afterRefresh).toBeGreaterThan(0); + expect((await readRangeState(page)).subtitle).not.toBe(initialSubtitle); + + const url = new URL(page.url()); + expect(url.searchParams.get("preset")).toBe("10m"); + expect(url.searchParams.has("since")).toBe(false); +}); + test("keeps timeline and inspector scroll containers inside the viewport", async ({ page }) => { await page.setViewportSize({ width: 1280, height: 720 }); await page.goto("/?since=0"); await expect(page.getByTestId("timeline-row")).toHaveCount(5); + await expect(page.getByTestId("request-composition")).toBeVisible(); const initialMetrics = await page.evaluate(() => { const rectOf = (selector: string): DOMRect => { @@ -403,6 +456,18 @@ test("shows full provider model id in the selected-call summary", async ({ page await expect(summary.getByTestId("summary-model-id")).toContainText(fullModelId); }); +test("shows compaction candidate labels in the selected-call summary", async ({ page }) => { + await installCompactionRoutes(page); + await page.goto("/?since=0"); + await timelineRow(page, DIFF_SCOPE_CALL_ID).click(); + + const candidate = page.getByTestId("compaction-candidate"); + await expect(candidate).toBeVisible(); + await expect(candidate).toContainText("Pi compaction candidate"); + await expect(candidate).toContainText("high confidence"); + await expect(candidate).toContainText("summary-like history"); +}); + test("keeps inspector summary metric values readable", async ({ page }) => { await page.setViewportSize({ width: 1280, height: 720 }); await page.goto("/?since=0"); @@ -809,6 +874,105 @@ test("bounds high-volume stream events and clears them on range changes", async expect(await page.evaluate(() => document.querySelector("main")?.scrollTop ?? -1)).toBe(0); }); +test("shows backend token provenance and counts selected block text", async ({ page }) => { + await page.setViewportSize({ width: 1280, height: 720 }); + await installHeavyStreamRoutes(page); + await page.goto("/?since=0"); + await page.getByTestId("timeline-row").click(); + + const block = page.getByTestId("block-row-block-request-user"); + await expect(block.getByText("5 tok", { exact: true })).toBeVisible(); + await expect(block.getByTestId("provider-count-block-request-user")).toHaveCount(0); + + await block.locator("pre").evaluate((element) => { + const range = document.createRange(); + range.selectNodeContents(element); + const selection = window.getSelection(); + selection?.removeAllRanges(); + selection?.addRange(range); + }); + await block.getByTestId("selection-count-block-request-user").click(); + await expect(block.getByTestId("selection-token-count")).toContainText("5 tok"); + await expect(block.getByTestId("selection-token-count")).toContainText("provider counted"); +}); + +test("virtualizes large block lists while preserving large block text selection", async ({ page }) => { + await page.setViewportSize({ width: 1280, height: 720 }); + const fixture = largeContentFixture(); + const expectedSelection = fixture.largeText.slice(0, Math.floor(fixture.largeText.length / 2)); + const tokenCapture = { count: 0, sawExpectedSelection: false, lastLength: 0 }; + await installLargeContentRoutes(page, { expectedSelection, tokenCapture }); + + await page.goto("/?since=0"); + await expect(page.getByTestId("timeline-row")).toHaveCount(1); + await page.getByTestId("inspector-nav-request-blocks").click(); + + const virtualList = page.getByTestId("virtual-block-list").first(); + await expect(virtualList).toBeVisible(); + await expect(page.getByTestId("block-row-large-request-000")).toBeVisible(); + + const initialMetrics = await page.evaluate(() => ({ + mountedRows: document.querySelectorAll('[data-testid^="block-row-"]').length, + fullTextControls: document.querySelectorAll('textarea[data-testid="block-body-full"]').length, + })); + expect(initialMetrics.mountedRows).toBeLessThan(40); + expect(initialMetrics.fullTextControls).toBe(0); + + const largeBlock = page.getByTestId("block-row-large-request-000"); + await expect(largeBlock.getByText("Preview", { exact: true })).toBeVisible(); + await largeBlock.getByRole("button", { name: "Show Full Text" }).click(); + const fullText = largeBlock.getByTestId("block-body-full"); + await expect(fullText).toBeVisible(); + + const selectionLength = expectedSelection.length; + await fullText.evaluate((element, end) => { + if (!(element instanceof HTMLTextAreaElement)) { + throw new Error("large block full text is not a textarea"); + } + element.focus(); + element.setSelectionRange(0, end); + }, selectionLength); + + await largeBlock.getByTestId("selection-count-large-request-000").click(); + await expect(largeBlock.getByTestId("selection-token-count")).toContainText("12,345 tok"); + expect(tokenCapture.count).toBe(1); + expect(tokenCapture.lastLength).toBe(expectedSelection.length); + expect(tokenCapture.sawExpectedSelection).toBe(true); +}); + +test("keeps large raw and stream payloads collapsed and bounded", async ({ page }) => { + await page.setViewportSize({ width: 1280, height: 720 }); + await installLargeContentRoutes(page); + + await page.goto("/?since=0"); + await expect(page.getByTestId("timeline-row")).toHaveCount(1); + + await page.getByTestId("inspector-nav-stream").click(); + await page.getByRole("button", { name: "Load Stream Events" }).click(); + await page.getByTestId("stream-event-card").first().getByRole("button", { name: "Show Payload" }).click(); + await expect(page.getByTestId("stream-event-payload")).toHaveCount(1); + await expect(page.getByTestId("stream-event-payload-body-preview")).toBeVisible(); + await expect(page.getByTestId("stream-event-payload-body-full")).toHaveCount(0); + await page.getByTestId("stream-event-payload").getByRole("button", { name: "Show Full Text" }).click(); + await expect(page.getByTestId("stream-event-payload-body-full")).toBeVisible(); + + await page.getByTestId("inspector-nav-raw").click(); + await expect(page.getByTestId("raw-payload-card")).toBeVisible(); + await expect(page.getByTestId("raw-payload-body")).toHaveCount(0); + await page.getByTestId("raw-payload-card").getByRole("button", { name: "Show Payload" }).click(); + await expect(page.getByTestId("raw-payload-body")).toBeVisible(); + await expect(page.getByTestId("raw-payload-text-preview")).toBeVisible(); + await page.getByTestId("raw-payload-body").getByRole("button", { name: "Show Full Text" }).click(); + await expect(page.getByTestId("raw-payload-text-full")).toBeVisible(); + + const payloadMetrics = await page.evaluate(() => ({ + streamPayloadHeight: document.querySelector('[data-testid="stream-event-payload"]')?.getBoundingClientRect().height ?? 0, + rawPayloadHeight: document.querySelector('[data-testid="raw-payload-body"]')?.getBoundingClientRect().height ?? 0, + })); + expect(payloadMetrics.streamPayloadHeight).toBeLessThan(700); + expect(payloadMetrics.rawPayloadHeight).toBeLessThan(700); +}); + test("traps focus in the clear data dialog and closes with Escape", async ({ page }) => { await page.goto("/?since=0"); await expect(page.getByTestId("timeline-row")).toHaveCount(5); @@ -845,6 +1009,66 @@ test("clears data with confirmation", async ({ page }) => { await expect(page.getByText("No provider calls in this range.")).toBeVisible(); }); +test.describe("visual regression screenshots", () => { + test("captures the selected-call desktop shell", async ({ page }) => { + await prepareVisualPage(page); + await installCacheTimelineRoutes(page); + await page.goto("/?since=0"); + await timelineRow(page, CACHE_TIMELINE_CALL_ID).click(); + await expect(page.getByTestId("inspector-section-summary")).toBeVisible(); + + await expect(page).toHaveScreenshot("spy-default-selected-call.png", SCREENSHOT_OPTIONS); + }); + + test("captures selected-call summary and composition", async ({ page }) => { + await prepareVisualPage(page); + await installCacheTimelineRoutes(page); + await page.goto("/?since=0"); + await timelineRow(page, CACHE_TIMELINE_CALL_ID).click(); + await expect(page.getByTestId("request-composition")).toBeVisible(); + + await expect(page.getByTestId("inspector")).toHaveScreenshot("spy-inspector-summary-composition.png", SCREENSHOT_OPTIONS); + }); + + test("captures the compaction candidate summary label", async ({ page }) => { + await prepareVisualPage(page); + await installCompactionRoutes(page); + await page.goto("/?since=0"); + await timelineRow(page, DIFF_SCOPE_CALL_ID).click(); + const candidate = page.getByTestId("compaction-candidate"); + await expect(candidate).toBeVisible(); + + await expect(candidate).toHaveScreenshot("spy-compaction-candidate-label.png", SCREENSHOT_OPTIONS); + }); + + test("captures large-content preview and expansion controls", async ({ page }) => { + await prepareVisualPage(page); + await installLargeContentRoutes(page); + await page.goto("/?since=0"); + await expect(page.getByTestId("timeline-row")).toHaveCount(1); + await page.getByTestId("inspector-nav-request-blocks").click(); + + const largeBlock = page.getByTestId("block-row-large-request-000"); + await expect(largeBlock.getByText("Preview", { exact: true })).toBeVisible(); + await largeBlock.getByRole("button", { name: "Show Full Text" }).click(); + await expect(largeBlock.getByTestId("block-body-full")).toBeVisible(); + + await expect(largeBlock).toHaveScreenshot("spy-large-content-expanded-block.png", SCREENSHOT_OPTIONS); + }); + + test("captures the clear-data confirmation dialog", async ({ page }) => { + await prepareVisualPage(page); + await installCacheTimelineRoutes(page); + await page.goto("/?since=0"); + await expect(page.getByTestId("timeline-row")).toHaveCount(1); + await page.getByLabel("Clear spy data").click(); + const dialog = page.getByRole("dialog", { name: "Clear Spy Data" }); + await expect(dialog).toBeVisible(); + + await expect(dialog).toHaveScreenshot("spy-clear-data-dialog.png", SCREENSHOT_OPTIONS); + }); +}); + async function readRangeState(page: Page): Promise<{ readonly activeButtons: readonly string[]; readonly subtitle: string; @@ -882,6 +1106,12 @@ const NETWORK_METADATA_RAW_PATH = "/model/us.anthropic.claude-haiku-4-5-20251001 const BLOCK_FILTER_CALL_A_ID = "call-block-filter-a"; const BLOCK_FILTER_CALL_B_ID = "call-block-filter-b"; const BLOCK_FILTER_TS = 1779563000; +const LARGE_CONTENT_CALL_ID = "call-large-content"; +const LARGE_CONTENT_TS = 1779563200; +const SCREENSHOT_OPTIONS = { + animations: "disabled" as const, + maxDiffPixelRatio: 0.01, +}; const BLOCK_KINDS: readonly NormalizedBlock["kind"][] = [ "provider-envelope", "harness-system-context", @@ -898,6 +1128,34 @@ const BLOCK_KINDS: readonly NormalizedBlock["kind"][] = [ "unknown", ]; +async function prepareVisualPage(page: Page): Promise { + await page.setViewportSize({ width: 1280, height: 720 }); + await page.addInitScript(() => { + class StableEventSource extends EventTarget { + readonly url: string; + readyState = 1; + + constructor(url: string | URL) { + super(); + this.url = String(url); + setTimeout(() => { + this.dispatchEvent(new Event("open")); + }, 0); + } + + close(): void { + this.readyState = 2; + } + } + + Object.defineProperty(window, "EventSource", { + configurable: true, + writable: true, + value: StableEventSource, + }); + }); +} + async function installHeavyStreamRoutes(page: Page): Promise { const fixture = heavyStreamFixture(); await page.route(/\/api\/health$/, async (route) => { @@ -923,6 +1181,80 @@ async function installHeavyStreamRoutes(page: Page): Promise { await page.route(/\/api\/calls\/call-heavy-stream$/, async (route) => { await fulfillJson(route, fixture.detail); }); + await page.route(/\/api\/token-count$/, async (route) => { + const body = await route.request().postDataJSON() as { + readonly subjects?: readonly { readonly type: string; readonly callId?: string; readonly blockId?: string; readonly text?: string }[]; + }; + const subject = body.subjects?.[0]; + await fulfillJson(route, { + mode: "provider", + records: [{ + subjectType: subject?.type ?? "block", + callId: subject?.callId ?? HEAVY_STREAM_CALL_ID, + ...(subject?.blockId === undefined ? {} : { blockId: subject.blockId }), + direction: "request", + kind: "current-user-input", + label: subject?.type === "selection" ? "selection" : undefined, + sourceHash: "provider-count-hash", + modelId: HEAVY_STREAM_MODEL_ID, + tokens: subject?.type === "selection" ? 5 : 77, + provenance: "provider_counted", + countedAt: HEAVY_STREAM_TS + 3, + }], + }); + }); + await page.route(/\/api\/calls(?:\?.*)?$/, async (route) => { + await fulfillJson(route, { items: [fixture.summary] }); + }); + await page.route(/\/api\/search(?:\?.*)?$/, async (route) => { + await fulfillJson(route, { items: [fixture.summary] }); + }); +} + +async function installLargeContentRoutes(page: Page, options: { + readonly expectedSelection?: string | undefined; + readonly tokenCapture?: { count: number; sawExpectedSelection: boolean; lastLength: number } | undefined; +} = {}): Promise { + const fixture = largeContentFixture(); + await page.route(/\/api\/health$/, async (route) => { + await fulfillJson(route, fixture.health); + }); + await page.route(/\/api\/calls\/call-large-content\/stream-events(?:\?.*)?$/, async (route) => { + await fulfillJson(route, { items: fixture.streamEvents }); + }); + await page.route(/\/api\/calls\/call-large-content\/diff$/, async (route) => { + await fulfillJson(route, fixture.diff); + }); + await page.route(/\/api\/calls\/call-large-content$/, async (route) => { + await fulfillJson(route, fixture.detail); + }); + await page.route(/\/api\/token-count$/, async (route) => { + const body = await route.request().postDataJSON() as { + readonly subjects?: readonly { readonly type: string; readonly callId?: string; readonly text?: string }[]; + }; + const subject = body.subjects?.[0]; + const selectedText = subject?.text ?? ""; + if (options.tokenCapture !== undefined) { + options.tokenCapture.count += 1; + options.tokenCapture.lastLength = selectedText.length; + options.tokenCapture.sawExpectedSelection = selectedText === options.expectedSelection; + } + await fulfillJson(route, { + mode: "provider", + records: [{ + subjectType: "selection", + callId: subject?.callId ?? LARGE_CONTENT_CALL_ID, + direction: "request", + kind: "current-user-input", + label: "selection", + sourceHash: "large-content-selection-hash", + modelId: HEAVY_STREAM_MODEL_ID, + tokens: 12_345, + provenance: "provider_counted", + countedAt: LARGE_CONTENT_TS + 3, + }], + }); + }); await page.route(/\/api\/calls(?:\?.*)?$/, async (route) => { await fulfillJson(route, { items: [fixture.summary] }); }); @@ -950,6 +1282,29 @@ async function installDiffScopeRoutes(page: Page): Promise { }); } +async function installCompactionRoutes(page: Page): Promise { + const fixture = diffScopeFixture(); + const detail: SpyCallDetail = { + ...fixture.detail, + compaction: compactionCandidate(fixture.summary), + }; + await page.route(/\/api\/health$/, async (route) => { + await fulfillJson(route, fixture.health); + }); + await page.route(/\/api\/calls\/call-diff-scope-current\/diff$/, async (route) => { + await fulfillJson(route, fixture.diff); + }); + await page.route(/\/api\/calls\/call-diff-scope-current$/, async (route) => { + await fulfillJson(route, detail); + }); + await page.route(/\/api\/calls(?:\?.*)?$/, async (route) => { + await fulfillJson(route, { items: [fixture.summary] }); + }); + await page.route(/\/api\/search(?:\?.*)?$/, async (route) => { + await fulfillJson(route, { items: [fixture.summary] }); + }); +} + async function installCacheTimelineRoutes(page: Page): Promise { const fixture = cacheTimelineFixture(); await page.route(/\/api\/health$/, async (route) => { @@ -1094,6 +1449,8 @@ function diffScopeFixture(): { const detail: SpyCallDetail = { summary, requestComposition: requestComposition(usage), + compaction: noCompaction(summary), + tokenCounts: tokenCountsFor(summary, blocks), httpEvents: [], blocks, usageRecords: [], @@ -1159,6 +1516,8 @@ function cacheTimelineFixture(): { totalBlockCount: 3, cacheMarkerCount: 2, }, + compaction: noCompaction(summary), + tokenCounts: [], httpEvents: [], blocks: [ { @@ -1276,6 +1635,8 @@ function networkMetadataFixture(): { const detail: SpyCallDetail = { summary, requestComposition: requestComposition(usage), + compaction: noCompaction(summary), + tokenCounts: tokenCountsFor(summary, blocks), httpEvents: [ { id: "http-request-network-metadata", @@ -1406,6 +1767,8 @@ function blockFilterDetail( return { summary, requestComposition: requestComposition(usage), + compaction: noCompaction(summary), + tokenCounts: [], httpEvents: [], blocks, usageRecords: [], @@ -1485,6 +1848,8 @@ function heavyStreamFixture(): { const detail: SpyCallDetail = { summary, requestComposition: requestComposition(usage), + compaction: noCompaction(summary), + tokenCounts: tokenCountsFor(summary, blocks), httpEvents: [ { id: "http-request-heavy-stream", @@ -1541,6 +1906,121 @@ function heavyStreamFixture(): { }; } +function largeContentFixture(): { + readonly summary: SpyCallSummary; + readonly detail: SpyCallDetail; + readonly diff: SpyCallDiff; + readonly health: SpyServiceHealth; + readonly streamEvents: readonly StreamEvent[]; + readonly largeText: string; +} { + const usage = { + inputTokens: 90_000, + outputTokens: 100, + cacheReadTokens: 0, + cacheWriteTokens: 0, + totalTokens: 90_100, + }; + const largeText = largeTextFixture("compaction-user-message", 1_600); + const rawPayloadText = largeTextFixture("raw-provider-payload", 1_000); + const streamPayloadText = largeTextFixture("stream-provider-payload", 900); + const requestBlocks = [ + largeContentBlock("large-request-000", "request", 0, "current-user-input", largeText), + ...Array.from({ length: 139 }, (_, index) => largeContentBlock( + `large-request-${String(index + 1).padStart(3, "0")}`, + "request", + index + 1, + index % 5 === 0 ? "prior-conversation-history" : "harness-system-context", + `synthetic context block ${String(index + 1)} ${"ctx ".repeat(20)}`, + )), + ]; + const responseBlocks = [ + largeContentBlock("large-response-000", "response", 0, "assistant-output", "large content fixture response"), + ]; + const blocks = [...requestBlocks, ...responseBlocks]; + const requestByteSize = requestBlocks.reduce((total, block) => total + block.byte_size, 0); + const responseByteSize = responseBlocks.reduce((total, block) => total + block.byte_size, 0); + const call = { + id: LARGE_CONTENT_CALL_ID, + provider: "bedrock" as const, + operation: "converse-stream", + model_id: HEAVY_STREAM_MODEL_ID, + status: "complete" as const, + started_at: LARGE_CONTENT_TS, + completed_at: LARGE_CONTENT_TS + 2, + status_code: 200, + request_flow_id: "large-content-flow", + response_flow_id: "large-content-flow", + request_content_hash: "large-content-request-hash", + response_content_hash: "large-content-response-hash", + }; + const summary: SpyCallSummary = { + call, + durationMs: 2000, + usage, + requestBlockCount: requestBlocks.length, + responseBlockCount: responseBlocks.length, + requestByteSize, + responseByteSize, + cacheMarkerCount: 0, + streamEventCount: 1, + rawPayloadCount: 1, + }; + const rawPayload: RawPayloadRecord = { + id: "raw-large-content-request", + call_id: LARGE_CONTENT_CALL_ID, + direction: "request", + content_type: "application/json", + body_text: rawPayloadText, + body_sha256: "raw-large-content-sha", + }; + const detail: SpyCallDetail = { + summary, + requestComposition: { + ...requestComposition(usage), + totalBlockCount: requestBlocks.length, + totalMessageCount: 1, + totalCharSize: requestBlocks.reduce((total, block) => total + block.char_size, 0), + totalByteSize: requestByteSize, + }, + compaction: noCompaction(summary), + tokenCounts: tokenCountsFor(summary, blocks), + httpEvents: [], + blocks, + usageRecords: [], + rawPayloads: [rawPayload], + }; + return { + summary, + detail, + diff: { + call: summary, + previousCall: null, + blocks: requestBlocks.map((block) => ({ block, classification: "new" as const })), + }, + health: healthFixture(1, LARGE_CONTENT_TS), + streamEvents: [{ + id: "stream-large-content-000", + call_id: LARGE_CONTENT_CALL_ID, + ordinal: 0, + event_type: "contentBlockDelta", + headers: { + ":event-type": "contentBlockDelta", + ":content-type": "application/json", + }, + payload: { + contentBlockIndex: 0, + delta: { + text: streamPayloadText, + }, + }, + payload_sha256: "stream-large-content-sha", + observed_at: LARGE_CONTENT_TS + 1, + }], + largeText, + }; +} + function healthFixture(providerCallCount: number, lastIngestAt: number): SpyServiceHealth { return { ok: true, @@ -1552,6 +2032,7 @@ function healthFixture(providerCallCount: number, lastIngestAt: number): SpyServ maxBytes: 6442450944, spoolMaxBytes: 1073741824, storeRaw: false, + tokenCountMode: "provider", staticAssets: true, }, store: { @@ -1596,6 +2077,105 @@ function requestComposition(usage: SpyCallSummary["usage"]): SpyRequestCompositi }; } +function noCompaction(summary: SpyCallSummary): SpyCompactionAssessment { + return { + status: "none", + source: "none", + confidence: "none", + label: "No compaction candidate", + reasons: ["no_previous_comparable_call"], + evidence: { + currentCallId: summary.call.id, + previousCallId: null, + currentRequestByteSize: summary.requestByteSize, + previousRequestByteSize: null, + currentInputTokens: summary.usage.inputTokens, + previousInputTokens: null, + currentContextTokens: contextTokens(summary.usage), + previousContextTokens: null, + currentPriorHistoryByteSize: 0, + previousPriorHistoryByteSize: null, + currentPriorHistoryBlockCount: 0, + previousPriorHistoryBlockCount: null, + summaryLikeBlockIds: [], + newHistoryBlockIds: [], + changedHistoryBlockIds: [], + repeatedContextBlockCount: 0, + changedContextBlockCount: 0, + }, + }; +} + +function compactionCandidate(summary: SpyCallSummary): SpyCompactionAssessment { + const currentContextTokens = contextTokens(summary.usage); + return { + status: "candidate", + source: "pi_pattern", + confidence: "high", + label: "Pi compaction candidate", + reasons: [ + "pi_request_context_profile", + "stable_request_context", + "summary_like_history_block", + "prior_history_byte_drop", + "request_byte_drop", + ], + evidence: { + currentCallId: summary.call.id, + previousCallId: DIFF_SCOPE_PREVIOUS_CALL_ID, + currentRequestByteSize: summary.requestByteSize, + previousRequestByteSize: summary.requestByteSize * 4, + currentInputTokens: summary.usage.inputTokens, + previousInputTokens: summary.usage.inputTokens === null ? null : summary.usage.inputTokens * 4, + currentContextTokens, + previousContextTokens: currentContextTokens === null ? null : currentContextTokens * 4, + currentPriorHistoryByteSize: 512, + previousPriorHistoryByteSize: 8_192, + currentPriorHistoryBlockCount: 1, + previousPriorHistoryBlockCount: 6, + summaryLikeBlockIds: ["compaction-summary-block"], + newHistoryBlockIds: ["compaction-summary-block"], + changedHistoryBlockIds: [], + repeatedContextBlockCount: 2, + changedContextBlockCount: 0, + }, + }; +} + +function contextTokens(usage: SpyCallSummary["usage"]): number | null { + const parts = [usage.inputTokens, usage.cacheReadTokens, usage.cacheWriteTokens] + .filter((value): value is number => value !== null); + return parts.length === 0 ? null : parts.reduce((total, value) => total + value, 0); +} + +function tokenCountsFor(summary: SpyCallSummary, blocks: readonly NormalizedBlock[]): SpyTokenCountRecord[] { + const requestTokens = contextTokens(summary.usage); + return [ + { + subjectType: "call", + callId: summary.call.id, + direction: "request", + sourceHash: summary.call.request_content_hash ?? `${summary.call.id}-request-token-hash`, + modelId: summary.call.model_id, + tokens: requestTokens, + provenance: requestTokens === null ? "unavailable" : "provider_counted", + countedAt: summary.call.started_at, + }, + ...blocks.map((block): SpyTokenCountRecord => ({ + subjectType: "block", + callId: summary.call.id, + blockId: block.id, + direction: block.direction, + kind: block.kind, + sourceHash: block.content_hash, + modelId: summary.call.model_id, + tokens: Math.max(1, Math.ceil(block.byte_size / 4)), + provenance: "provider_counted", + countedAt: summary.call.started_at, + })), + ]; +} + function normalizedBlock( id: string, direction: NormalizedBlock["direction"], @@ -1619,6 +2199,35 @@ function normalizedBlock( }; } +function largeContentBlock( + id: string, + direction: NormalizedBlock["direction"], + ordinal: number, + kind: NormalizedBlock["kind"], + text: string, +): NormalizedBlock { + return { + id, + call_id: LARGE_CONTENT_CALL_ID, + direction, + ordinal, + kind, + source: "synthetic-large-content", + provider_path: "$.synthetic.large", + text, + char_size: text.length, + byte_size: new TextEncoder().encode(text).length, + content_hash: `${id}-hash`, + cache_marker: false, + }; +} + +function largeTextFixture(label: string, lineCount: number): string { + return Array.from({ length: lineCount }, (_, index) => ( + `${label} line ${String(index).padStart(4, "0")} ${"ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789".repeat(3)}` + )).join("\n"); +} + function diffScopeBlock( id: string, direction: NormalizedBlock["direction"], diff --git a/src/spy/ui/e2e/spy-ui.playwright.ts-snapshots/spy-clear-data-dialog-darwin.png b/src/spy/ui/e2e/spy-ui.playwright.ts-snapshots/spy-clear-data-dialog-darwin.png new file mode 100644 index 0000000..9d17c8c Binary files /dev/null and b/src/spy/ui/e2e/spy-ui.playwright.ts-snapshots/spy-clear-data-dialog-darwin.png differ diff --git a/src/spy/ui/e2e/spy-ui.playwright.ts-snapshots/spy-compaction-candidate-label-darwin.png b/src/spy/ui/e2e/spy-ui.playwright.ts-snapshots/spy-compaction-candidate-label-darwin.png new file mode 100644 index 0000000..b185d96 Binary files /dev/null and b/src/spy/ui/e2e/spy-ui.playwright.ts-snapshots/spy-compaction-candidate-label-darwin.png differ diff --git a/src/spy/ui/e2e/spy-ui.playwright.ts-snapshots/spy-default-selected-call-darwin.png b/src/spy/ui/e2e/spy-ui.playwright.ts-snapshots/spy-default-selected-call-darwin.png new file mode 100644 index 0000000..f3afb8e Binary files /dev/null and b/src/spy/ui/e2e/spy-ui.playwright.ts-snapshots/spy-default-selected-call-darwin.png differ diff --git a/src/spy/ui/e2e/spy-ui.playwright.ts-snapshots/spy-inspector-summary-composition-darwin.png b/src/spy/ui/e2e/spy-ui.playwright.ts-snapshots/spy-inspector-summary-composition-darwin.png new file mode 100644 index 0000000..0dd15bf Binary files /dev/null and b/src/spy/ui/e2e/spy-ui.playwright.ts-snapshots/spy-inspector-summary-composition-darwin.png differ diff --git a/src/spy/ui/e2e/spy-ui.playwright.ts-snapshots/spy-large-content-expanded-block-darwin.png b/src/spy/ui/e2e/spy-ui.playwright.ts-snapshots/spy-large-content-expanded-block-darwin.png new file mode 100644 index 0000000..7ea0379 Binary files /dev/null and b/src/spy/ui/e2e/spy-ui.playwright.ts-snapshots/spy-large-content-expanded-block-darwin.png differ diff --git a/src/spy/ui/playwright.config.ts b/src/spy/ui/playwright.config.ts index 3fd33c2..8455bae 100644 --- a/src/spy/ui/playwright.config.ts +++ b/src/spy/ui/playwright.config.ts @@ -1,7 +1,7 @@ import { defineConfig, devices } from "@playwright/test"; import { resolve } from "node:path"; -const port = 4674; +const port = Number(process.env.SPY_UI_E2E_PORT ?? "4674"); const uiRoot = import.meta.dirname; const staticDir = resolve(uiRoot, "../../../dist/spy-ui"); const testServer = resolve(uiRoot, "test-server.ts"); diff --git a/src/spy/ui/src/App.tsx b/src/spy/ui/src/App.tsx index 716c856..f7cb5de 100644 --- a/src/spy/ui/src/App.tsx +++ b/src/spy/ui/src/App.tsx @@ -8,6 +8,7 @@ import { Clock, Database, Filter, + Hash, Loader2, RefreshCcw, Search, @@ -25,7 +26,6 @@ import { Select } from "./components/ui/select.tsx"; import { blockKindLabel, blockText, - clipped, formatBytes, formatCount, formatDateTime, @@ -47,7 +47,10 @@ import type { SpyCallDiff, SpyRequestComposition, SpyCallSummary, + SpyCompactionAssessment, + SpyCompactionReason, SpyServiceHealth, + SpyTokenCountRecord, StreamEvent, TimePreset, UiFilters, @@ -58,8 +61,13 @@ const api = new SpyApiClient(); const CALL_LIMIT = 100; const ALL_FILTER = "all"; const TIMELINE_ROW_ESTIMATE = 138; +const BLOCK_LIST_VIRTUALIZE_MIN_ITEMS = 24; +const BLOCK_ROW_ESTIMATE = 230; +const BLOCK_LIST_VIEWPORT_HEIGHT = 680; const BLOCK_SECTION_AUTO_OPEN_MAX_BLOCKS = 6; const BLOCK_SECTION_AUTO_OPEN_MAX_BYTES = 24 * 1024; +const BLOCK_BODY_PREVIEW_CHARS = 6_000; +const RAW_PAYLOAD_PREVIEW_CHARS = 4_000; const STREAM_EVENT_WINDOW_SIZE = 25; const STREAM_EVENT_PAYLOAD_PREVIEW_CHARS = 4_000; const BLOCK_KIND_OPTIONS: readonly NormalizedBlock["kind"][] = [ @@ -141,6 +149,7 @@ export function App(): React.ReactElement { const initialRange = React.useMemo(() => initialTimelineRangeFromLocation(window.location), []); const [preset, setPreset] = React.useState(initialRange.preset); const [since, setSince] = React.useState(initialRange.since); + const [displaySince, setDisplaySince] = React.useState(initialRange.since); const [customStart, setCustomStart] = React.useState(() => datetimeLocalValue(initialRange.since)); const [searchDraft, setSearchDraft] = React.useState(""); const [search, setSearch] = React.useState(""); @@ -283,6 +292,30 @@ export function App(): React.ReactElement { setSseError(sseErrorMessage(error)); } }; + const onTokenCountsChanged = (event: MessageEvent): void => { + try { + const payload = parseSseEventData("token-counts-changed", event.data); + setSseError(undefined); + setDetailState((current) => { + if (current?.detail == null) { + return current; + } + if (current.detail.summary.call.id !== payload.callId) { + return current; + } + return { + ...current, + detail: { + ...current.detail, + tokenCounts: mergeTokenCountRecords(current.detail.tokenCounts, payload.records), + }, + }; + }); + } catch (error) { + setSseConnected(false); + setSseError(sseErrorMessage(error)); + } + }; const onCleared = (event: MessageEvent): void => { try { parseSseEventData("cleared", event.data); @@ -305,6 +338,7 @@ export function App(): React.ReactElement { source.addEventListener("hello", onHello as EventListener); source.addEventListener("health", onHealth as EventListener); source.addEventListener("calls-changed", onCallsChanged as EventListener); + source.addEventListener("token-counts-changed", onTokenCountsChanged as EventListener); source.addEventListener("cleared", onCleared as EventListener); return () => { @@ -313,6 +347,7 @@ export function App(): React.ReactElement { source.removeEventListener("hello", onHello as EventListener); source.removeEventListener("health", onHealth as EventListener); source.removeEventListener("calls-changed", onCallsChanged as EventListener); + source.removeEventListener("token-counts-changed", onTokenCountsChanged as EventListener); source.removeEventListener("cleared", onCleared as EventListener); source.close(); }; @@ -401,20 +436,17 @@ export function App(): React.ReactElement { function setTimelineRange(nextPreset: TimePreset, nextSince: number): void { setPreset(nextPreset); setSince(nextSince); + setDisplaySince(nextSince); setCustomStart(datetimeLocalValue(nextSince)); replaceTimelineRangeUrl(nextPreset, nextSince); } function sinceForCallLoad(append: boolean): number { - if (append || !isRollingPreset(preset)) { + if (append || preset === "live" || !isRollingPreset(preset)) { return since; } const nextSince = resolveTimelineSince(preset, since); - if (nextSince !== since) { - setSince(nextSince); - setCustomStart(datetimeLocalValue(nextSince)); - replaceTimelineRangeUrl(preset, nextSince); - } + setDisplaySince((current) => current === nextSince ? current : nextSince); return nextSince; } @@ -535,7 +567,7 @@ export function App(): React.ReactElement {

Rootcell Spy

- {preset === "live" ? "Live from now" : `Since ${formatDateTime(since)}`} + {preset === "live" ? "Live from now" : `Since ${formatDateTime(displaySince)}`}

@@ -618,7 +650,7 @@ export function App(): React.ReactElement { resetKey={inspectorResetKey} pinned={selectedCallIsPinned} preset={preset} - since={since} + since={displaySince} filters={filters} health={health} onFilters={setFilters} @@ -975,9 +1007,9 @@ function timelineUsageMetricData(usage: SpyCallSummary["usage"]): UsageMetricPro function usageMetricMarker(label: string): React.ReactNode { switch (label) { case "read": - return