diff --git a/.github/APPROVED_CONTRIBUTORS b/.github/APPROVED_CONTRIBUTORS index c758d395b..23ba87df7 100644 --- a/.github/APPROVED_CONTRIBUTORS +++ b/.github/APPROVED_CONTRIBUTORS @@ -405,3 +405,5 @@ btkostner pr Jay-Madden issue lalyos pr + +Lincoln504 pr diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 302158278..a8c980e5a 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -35,6 +35,23 @@ jobs: - name: Build run: npm run build + # Previews are published for main and PRs from branches of this repo only (see preview-packages.yml). + - name: Pack preview packages + if: github.event_name == 'push' || github.event.pull_request.head.repo.full_name == github.repository + env: + # Pull request runs check out a merge commit; tarballs are keyed by the PR head commit. + SHA: ${{ github.event.pull_request.head.sha || github.sha }} + run: node scripts/preview-packages.mjs pack --sha "$SHA" --out "$RUNNER_TEMP/preview-packages" + + - name: Upload preview packages + if: github.event_name == 'push' || github.event.pull_request.head.repo.full_name == github.repository + uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 + with: + name: preview-packages + path: ${{ runner.temp }}/preview-packages + retention-days: 3 + if-no-files-found: error + - name: Check run: npm run check diff --git a/.github/workflows/pr-gate.yml b/.github/workflows/pr-gate.yml index bc6f7eef1..b50f5a109 100644 --- a/.github/workflows/pr-gate.yml +++ b/.github/workflows/pr-gate.yml @@ -18,7 +18,7 @@ jobs: script: | const APPROVED_FILE = '.github/APPROVED_CONTRIBUTORS'; const VALID_CAPABILITIES = new Set(['issue', 'pr']); - const TRUSTED_BOT_AUTHORS = new Set(['dependabot[bot]', 'sentry[bot]', 'claude[bot]']); + const TRUSTED_BOT_AUTHORS = new Set(['dependabot[bot]', 'sentry[bot]', 'claude[bot]', 'robopipi[bot]']); const prAuthor = context.payload.pull_request.user.login; const defaultBranch = context.payload.repository.default_branch; const isBotAuthor = prAuthor.endsWith('[bot]'); diff --git a/.github/workflows/preview-packages.yml b/.github/workflows/preview-packages.yml new file mode 100644 index 000000000..3b4121b79 --- /dev/null +++ b/.github/workflows/preview-packages.yml @@ -0,0 +1,117 @@ +# Publishes the preview packages that CI packed for main and for PRs from branches of this repo to pkg.earendil.com, +# moving the `main` or `pr-` ref, and deletes a PR's ref when the PR closes. It reads the CI artifact as data +# and never checks out or runs PR code. +# +# Uploads are only accepted if pkg.earendil.com allows this repository, this workflow on main, and every package it +# publishes. + +name: Preview packages + +on: + workflow_run: + workflows: [CI] + types: [completed] + pull_request_target: + types: [closed] + +permissions: {} + +jobs: + publish: + # Only CI runs for pushes to main and for PRs from branches of this repo. A branch can make its own copy of CI run + # on push; such runs are not reviewed and must not be published as push builds. + if: >- + github.event_name == 'workflow_run' && github.event.workflow_run.conclusion != 'cancelled' && + github.event.workflow_run.head_repository.full_name == github.repository && + ((github.event.workflow_run.event == 'push' && github.event.workflow_run.head_branch == 'main') || + github.event.workflow_run.event == 'pull_request') + # Publish and cleanup runs for the same branch execute one at a time, in order. The group is set per job, so runs + # whose job is skipped (forks) never wait in it. `queue: max` keeps every waiting run instead of replacing one. + concurrency: + group: preview-packages-${{ github.event.workflow_run.head_branch }} + queue: max + runs-on: ubuntu-latest + permissions: + actions: read # download the artifact from the CI run + contents: read # check out scripts/ + id-token: write # authenticate to pkg.earendil.com + pull-requests: read # check that the PR is still open + steps: + - name: Find packed packages + id: find + env: + GH_TOKEN: ${{ github.token }} + RUN_ID: ${{ github.event.workflow_run.id }} + run: | + count=$(gh api "repos/$GITHUB_REPOSITORY/actions/runs/$RUN_ID/artifacts" \ + --jq '[.artifacts[] | select(.name == "preview-packages")] | length') + if [ "$count" = 0 ]; then echo "::notice::CI produced no preview packages (it failed before packing)"; fi + echo "found=$([ "$count" != 0 ] && echo true || echo false)" >> "$GITHUB_OUTPUT" + + - name: Download packed packages + if: steps.find.outputs.found == 'true' + uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2 + with: + name: preview-packages + path: ${{ runner.temp }}/preview-packages + run-id: ${{ github.event.workflow_run.id }} + github-token: ${{ github.token }} + + - name: Check out scripts + if: steps.find.outputs.found == 'true' + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + sparse-checkout: scripts + persist-credentials: false + + - name: Publish + if: steps.find.outputs.found == 'true' + env: + GH_TOKEN: ${{ github.token }} + # Event data is passed through env, never interpolated into the script: branch names are PR-controlled. + EVENT: ${{ github.event.workflow_run.event }} + HEAD_SHA: ${{ github.event.workflow_run.head_sha }} + HEAD_BRANCH: ${{ github.event.workflow_run.head_branch }} + PULL_REQUESTS: ${{ toJSON(github.event.workflow_run.pull_requests) }} + RUN_ID: ${{ github.event.workflow_run.id }} + DIR: ${{ runner.temp }}/preview-packages + run: | + args=(--dir "$DIR" --sha "$HEAD_SHA" --run "$RUN_ID" --event "$EVENT") + if [ "$EVENT" = push ] && [ "$HEAD_BRANCH" = main ]; then + args+=(--ref main) + else + # The branch's PR into main; a branch can also have PRs into other branches. It may have closed or received + # new commits since CI ran, so the ref moves only while the PR is open with this commit as its head. + pr=$(jq -r 'map(select(.base.ref == "main")) | .[0].number // empty' <<< "$PULL_REQUESTS") + state=$([ -z "$pr" ] || gh api "repos/$GITHUB_REPOSITORY/pulls/$pr" --jq '"\(.state) \(.base.ref) \(.head.sha)"') + if [ "$state" = "open main $HEAD_SHA" ]; then + args+=(--ref "pr-$pr") + else + echo "::notice::No open PR into main has head $HEAD_SHA; publishing without a pr- ref" + fi + fi + node scripts/preview-packages.mjs publish "${args[@]}" + + cleanup: + # Only PRs into main: pull_request_target runs this file from the PR's base branch, and only main is allowed. + if: >- + github.event_name == 'pull_request_target' && github.event.pull_request.base.ref == github.event.repository.default_branch && + github.event.pull_request.head.repo.full_name == github.repository + concurrency: + group: preview-packages-${{ github.event.pull_request.head.ref }} + queue: max + runs-on: ubuntu-latest + permissions: + contents: read # check out scripts/ from the base branch, not PR code + id-token: write # authenticate to pkg.earendil.com + steps: + - name: Check out scripts + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + sparse-checkout: scripts + persist-credentials: false + + - name: Delete PR ref + env: + PR: ${{ github.event.pull_request.number }} + run: node scripts/preview-packages.mjs delete-ref --ref "pr-$PR" diff --git a/.pi/extensions/import-repro.ts b/.pi/extensions/import-repro.ts index cb11ca049..94dc4197f 100644 --- a/.pi/extensions/import-repro.ts +++ b/.pi/extensions/import-repro.ts @@ -1,16 +1,18 @@ /** * Import a pi session shared as a gist by the issue-analysis CI workflow - * (.github/workflows/issue-analysis.yml) and switch to it. + * (.github/workflows/issue-analysis.yml), or a .jsonl session file served at a URL, + * and switch to it. * - * The CI job runs in a high-entropy checkout directory; this command rewrites - * the recorded cwd to the local checkout, installs the session file into the - * current session directory, and switches to it. + * The session ran in another checkout (the CI job's high-entropy directory, or a + * research VM); this command rewrites the recorded cwd to the local checkout, + * installs the session file into the current session directory, and switches to it. * * Usage: * /ir b4d100022aefb12f25dd2d8485e0a82a * /ir https://gist.github.com/mitsuhiko/b4d100022aefb12f25dd2d8485e0a82a * /ir https://pi.dev/session/#b4d100022aefb12f25dd2d8485e0a82a * /ir https://github.com/earendil-works/pi/issues/123 + * /ir https://example.com/path/session.jsonl * * pi "/ir " */ @@ -58,11 +60,11 @@ interface IssueComment { function parseRef( ref: string, cwd: string, -): { type: "gist"; id: string } | { type: "file"; path: string } | { type: "issue"; owner: string; repo: string; issue: string } { - if (ref.endsWith(".html") || ref.endsWith(".jsonl")) { - return { type: "file", path: isAbsolute(ref) ? ref : resolve(cwd, ref) }; - } - +): + | { type: "gist"; id: string } + | { type: "file"; path: string } + | { type: "issue"; owner: string; repo: string; issue: string } + | { type: "url"; url: URL } { const shareMatch = ref.match(SHARE_URL_RE); if (shareMatch) return { type: "gist", id: shareMatch[1] }; @@ -72,9 +74,17 @@ function parseRef( const issueMatch = ref.match(ISSUE_URL_RE); if (issueMatch) return { type: "issue", owner: issueMatch[1], repo: issueMatch[2], issue: issueMatch[3] }; + if (/^https?:\/\/.*\.jsonl$/.test(ref)) return { type: "url", url: new URL(ref) }; + + if (ref.endsWith(".html") || ref.endsWith(".jsonl")) { + return { type: "file", path: isAbsolute(ref) ? ref : resolve(cwd, ref) }; + } + if (GIST_ID_RE.test(ref)) return { type: "gist", id: ref }; - throw new Error(`expected a gist ID, gist URL, pi.dev share URL, issue URL, .html file, or .jsonl file: ${ref}`); + throw new Error( + `expected a gist ID, gist URL, pi.dev share URL, issue URL, .jsonl URL, .html file, or .jsonl file: ${ref}`, + ); } function parseSessionJsonl(raw: string): { header: SessionHeader; jsonl: string } { @@ -282,11 +292,12 @@ async function fetchGistSession(gistId: string): Promise<{ header: SessionHeader export default function (pi: ExtensionAPI) { pi.registerCommand("ir", { - description: "Import a CI issue-analysis session from a gist ID, share URL, or issue URL and switch to it", + description: + "Import a CI issue-analysis session from a gist ID, share URL, issue URL, or .jsonl URL and switch to it", handler: async (args: string, ctx: ExtensionCommandContext) => { const ref = args.trim(); if (!ref) { - ctx.ui.notify("Usage: /ir ", "error"); + ctx.ui.notify("Usage: /ir ", "error"); return; } @@ -306,6 +317,9 @@ export default function (pi: ExtensionAPI) { const gistId = await findIssueGistId(parsedRef.owner, parsedRef.repo, parsedRef.issue); decoded = await fetchGistSession(gistId); sourceName = `${gistId}.jsonl`; + } else if (parsedRef.type === "url") { + decoded = parseSessionJsonl(await fetchText(parsedRef.url.href)); + sourceName = basename(parsedRef.url.pathname); } else { if (!existsSync(parsedRef.path)) throw new Error(`session file not found: ${parsedRef.path}`); const raw = readFileSync(parsedRef.path, "utf8"); diff --git a/nix/model-catalog.json b/nix/model-catalog.json index a30c08655..3c31c2cca 100644 --- a/nix/model-catalog.json +++ b/nix/model-catalog.json @@ -1,3 +1,3 @@ { - "revision": "sha256-ffa3e98626230f378f80200debb736c4a2238b5f2ba246a92cfc7d8e19604678" + "revision": "sha256-7fe1b41feaf43a7aad4bc832e6b306c7ced56e6d41bbc724b74e91eeacff6edf" } diff --git a/package-lock.json b/package-lock.json index 51984d649..62665c62f 100644 --- a/package-lock.json +++ b/package-lock.json @@ -731,15 +731,6 @@ "node": ">=16" } }, - "node_modules/@cloudflare/workers-types": { - "version": "5.20261016.1", - "resolved": "https://registry.npmjs.org/@cloudflare/workers-types/-/workers-types-5.20261016.1.tgz", - "integrity": "sha512-I8OJdPl3M6TIl9ksU3wtEyWVhSE8E8txJ/vGOv3SkhjcvN8uMjyFmMvRsNOZgzMe5xYFQ0LctmiGqTwR7DbZ6w==", - "dev": true, - "license": "MIT OR Apache-2.0", - "optional": true, - "peer": true - }, "node_modules/@cspotcode/source-map-support": { "version": "0.8.1", "resolved": "https://registry.npmjs.org/@cspotcode/source-map-support/-/source-map-support-0.8.1.tgz", @@ -767,31 +758,30 @@ "link": true }, "node_modules/@earendil-works/gondolin": { - "version": "0.12.0", - "resolved": "https://registry.npmjs.org/@earendil-works/gondolin/-/gondolin-0.12.0.tgz", - "integrity": "sha512-BXbvzQKb5QmxY5NtthRDONJTu7+IDKbzqWGrJyyNXMP7N681Tx0Q9TK8pK1ba8nUvYQTipNJyGZOsJfYiZll1A==", + "version": "0.14.0", + "resolved": "https://registry.npmjs.org/@earendil-works/gondolin/-/gondolin-0.14.0.tgz", + "integrity": "sha512-MSw+EGcqFJeyKHdXEfm+nMRr74ytT4x8DGIohav0oPCHeVQAB9AajquM94cv2xqvIU3VDAaZZVC07IXsOY6OVQ==", "license": "Apache-2.0", "dependencies": { "cbor2": "^2.3.0", - "node-forge": "^1.3.3", "ssh2": "^1.17.0", - "undici": "^6.21.0" + "undici": "^6.29.0" }, "bin": { "gondolin": "dist/bin/gondolin.js" }, "engines": { - "node": ">=23.6.0" + "node": ">=22.19.0" }, "optionalDependencies": { - "@earendil-works/gondolin-krun-runner-darwin-arm64": "0.12.0", - "@earendil-works/gondolin-krun-runner-linux-x64": "0.12.0" + "@earendil-works/gondolin-krun-runner-darwin-arm64": "0.14.0", + "@earendil-works/gondolin-krun-runner-linux-x64": "0.14.0" } }, "node_modules/@earendil-works/gondolin-krun-runner-darwin-arm64": { - "version": "0.12.0", - "resolved": "https://registry.npmjs.org/@earendil-works/gondolin-krun-runner-darwin-arm64/-/gondolin-krun-runner-darwin-arm64-0.12.0.tgz", - "integrity": "sha512-ftDlusht4PcT7Y3TuPrZIKrCXy3isiBTVMvlXYK0pcud2uXY6uwFTGeunYgP+8ND/60ddb+MImqbfmkcK8B84A==", + "version": "0.14.0", + "resolved": "https://registry.npmjs.org/@earendil-works/gondolin-krun-runner-darwin-arm64/-/gondolin-krun-runner-darwin-arm64-0.14.0.tgz", + "integrity": "sha512-fvuhz1CZPaEbmXC0luZoXd1obAb0bjaaGJz33Jo63bhMlosYunAahItakh7cU0dnTlNFzufOSnpGOPqtuYjnpQ==", "cpu": [ "arm64" ], @@ -805,9 +795,9 @@ } }, "node_modules/@earendil-works/gondolin-krun-runner-linux-x64": { - "version": "0.12.0", - "resolved": "https://registry.npmjs.org/@earendil-works/gondolin-krun-runner-linux-x64/-/gondolin-krun-runner-linux-x64-0.12.0.tgz", - "integrity": "sha512-RRYsgwe2r5ApKmFNy469QgwnyjAHpAs9XANdWpTd9ol4iUYOY3sX7e0xIooAKxd+ktxGI4N/xRWicwGen3D/Ow==", + "version": "0.14.0", + "resolved": "https://registry.npmjs.org/@earendil-works/gondolin-krun-runner-linux-x64/-/gondolin-krun-runner-linux-x64-0.14.0.tgz", + "integrity": "sha512-91iw4smoOH/yOoAuQS6N2UZOxqSFwRhtk8rTyrokXBoWiWcveAzg4kHvnLcm40ZlNrWwzIDFrere4bkZeGDQcA==", "cpu": [ "x64" ], @@ -821,9 +811,9 @@ } }, "node_modules/@earendil-works/gondolin/node_modules/undici": { - "version": "6.29.0", - "resolved": "https://registry.npmjs.org/undici/-/undici-6.29.0.tgz", - "integrity": "sha512-R+RODBqp6i2pPflGdq+xIOUkl+RNfGgHwoinecKu/JCuf2uO06cOKoDbI2P7Dn6KcswdKwrczbU6IYJ6K8X+wg==", + "version": "6.29.1", + "resolved": "https://registry.npmjs.org/undici/-/undici-6.29.1.tgz", + "integrity": "sha512-EvRlUt/QqAEvXdQeRMYUfwOQsfzpDztykrG2owGEE/rBOBoYv9uF39ZzsNDh4lTFHhTr2XOZjKkYgueD12ZmGA==", "license": "MIT", "engines": { "node": ">=18.17" @@ -869,18 +859,6 @@ "resolved": "packages/tui", "link": true }, - "node_modules/@emnapi/core": { - "version": "1.10.0", - "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.10.0.tgz", - "integrity": "sha512-yq6OkJ4p82CAfPl0u9mQebQHKPJkY7WrIuk205cTYnYe+k2Z8YBh11FrbRG/H6ihirqcacOgl2BIO8oyMQLeXw==", - "dev": true, - "license": "MIT", - "optional": true, - "dependencies": { - "@emnapi/wasi-threads": "1.2.1", - "tslib": "^2.4.0" - } - }, "node_modules/@emnapi/runtime": { "version": "1.11.3", "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.11.3.tgz", @@ -892,17 +870,6 @@ "tslib": "^2.4.0" } }, - "node_modules/@emnapi/wasi-threads": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.1.tgz", - "integrity": "sha512-uTII7OYF+/Mes/MrcIOYp5yOtSMLBWSIoLPpcgwipoiKbli6k322tcoFsxoIIxPDqW01SQGAgko4EzZi2BNv2w==", - "dev": true, - "license": "MIT", - "optional": true, - "dependencies": { - "tslib": "^2.4.0" - } - }, "node_modules/@esbuild/aix-ppc64": { "version": "0.28.2", "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.28.2.tgz", @@ -1924,33 +1891,14 @@ "@jridgewell/sourcemap-codec": "^1.4.10" } }, - "node_modules/@napi-rs/wasm-runtime": { - "version": "1.1.5", - "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.5.tgz", - "integrity": "sha512-AWPoBRJ9tsnVhor4sjO7rkni+7p+2IAEFj6cx06UgP10jkQHqay/36uRV/bFkgrh18D9vb4cr8Q0Pthskgzy+Q==", - "dev": true, - "license": "MIT", - "optional": true, - "dependencies": { - "@tybys/wasm-util": "^0.10.2" - }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/Brooooooklyn" - }, - "peerDependencies": { - "@emnapi/core": "^1.7.1", - "@emnapi/runtime": "^1.7.1" - } - }, "node_modules/@oxc-project/types": { - "version": "0.133.0", - "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.133.0.tgz", - "integrity": "sha512-KzkdCd6Uxqnf6l3HOw1xfatAlUURA0g14cvBYFyJ5SaNOQbOUvBr9PKArcPcrNIeRsBdgcUzOGrhKveVpvOIGA==", + "version": "0.154.0", + "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.154.0.tgz", + "integrity": "sha512-KYOUGQFlLWR+s4QMV2Q9ko38TZm7EdO87Qm7rvZLqk/webse/cDjtyxKhg/47l0byCbs/k46LT8qc4PKjAikzA==", "dev": true, "license": "MIT", "funding": { - "url": "https://github.com/sponsors/Boshen" + "url": "https://github.com/sponsors/oxc-project" } }, "node_modules/@pondwader/socks5-server": { @@ -2045,10 +1993,27 @@ "integrity": "sha512-oOAWABowe8EAbMyWKM0tYDKi8Yaox52D+HWZhAIJqQXbqe0xI/GV7FhLWqlEKreMkfDjshR5FKgi3mnle0h6Eg==", "license": "BSD-3-Clause" }, + "node_modules/@rolldown/binding-android-arm-eabi": { + "version": "1.2.14", + "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm-eabi/-/binding-android-arm-eabi-1.2.14.tgz", + "integrity": "sha512-aWR+XKH9+rW7m5fWq/5360J3mHycrx/o6xZM2DQFdJnTMdyP7/WzxPjhtLbVWdKo2y612/4U1bWqb3HQhiF7vg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, "node_modules/@rolldown/binding-android-arm64": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.0.3.tgz", - "integrity": "sha512-454rs7jHngixp/NMxd5srYD57OnzSlZ/eFTETjORQHLwJG1lRtmNOJcBerZlfu4GjKqeq8aCCIQrMdHyhI51Hw==", + "version": "1.2.14", + "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.2.14.tgz", + "integrity": "sha512-QiVxHofl6A8lQU7WAtOUY4QMVZXohxwz7WJJoeh8NBEUyKfIkMRKhYyq8az/1RypNiIXzNTtiS6KwHz0erxYRQ==", "cpu": [ "arm64" ], @@ -2063,9 +2028,9 @@ } }, "node_modules/@rolldown/binding-darwin-arm64": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.0.3.tgz", - "integrity": "sha512-PcAhP+ynjURNyy8SKGl5DQP94aGuB/7JrXJb/t7P+hanXvQVMWzUvRRhBAcg/lNRadBhoUPqSoP4xw5tR/KBEA==", + "version": "1.2.14", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.2.14.tgz", + "integrity": "sha512-lTQOzHuBIooh3jIr5XINQkjPGEuGy+Dsy0meXDr6Sjty0b99kYgGh4uBC/8mJX1T0kC8d6S0aPP1Y+bHkh6aBw==", "cpu": [ "arm64" ], @@ -2080,9 +2045,9 @@ } }, "node_modules/@rolldown/binding-darwin-x64": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.0.3.tgz", - "integrity": "sha512-9YpfeUvSE2RS7wysJ81uOZkXJz7f7Q55H2Gvp3VEw/EsahqDtrphrZ0EwDLK5vvKOzaCrBsjF8JmnMLcUt78Gg==", + "version": "1.2.14", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.2.14.tgz", + "integrity": "sha512-hyMKEGatjmBYCs/Q4sbIcL/GqpFEqsX3wc+HYVQ/85A4rxc1o7rPm4DUI4GV4k/h0ITddwFSGpqQunI14GXjTA==", "cpu": [ "x64" ], @@ -2097,9 +2062,9 @@ } }, "node_modules/@rolldown/binding-freebsd-x64": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.0.3.tgz", - "integrity": "sha512-yB1IlAsSNHncV6SCTL27/MVGR5htvQsoGxIv5KMGXALp+Ll1wYsn+x98M9MW7qa+NdSbvrrY7ANI4wLJ0n1e6g==", + "version": "1.2.14", + "resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.2.14.tgz", + "integrity": "sha512-u371GbXoVXrEgP20PwzoGNOdAOqAMhzo19JzzY+V4YmmnC+DTOoqdbMu+O+54emM59Mik/UdrtWgcuEDKWRPKA==", "cpu": [ "x64" ], @@ -2114,9 +2079,9 @@ } }, "node_modules/@rolldown/binding-linux-arm-gnueabihf": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.0.3.tgz", - "integrity": "sha512-Yi30IVAAfLUCy2MseFjbB1jAMDl1VMCAas5StnYp8da9+CKvMd2H2cbEjWcw5NPaPqzvYkVIaF1nNUG+b7u/sw==", + "version": "1.2.14", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.2.14.tgz", + "integrity": "sha512-BqwKsdZQf6EqVWr8fv2MrDYzAvOZ7+NYFSJESRGs87fkseEhs0TU9TXgRJTkZoRRkxTizyEg1LKUXfU3/78uqg==", "cpu": [ "arm" ], @@ -2131,13 +2096,16 @@ } }, "node_modules/@rolldown/binding-linux-arm64-gnu": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.0.3.tgz", - "integrity": "sha512-jsO7R8To+AdlYgUmN5sHSCZbfhtMBkO0WUx8iORQnPcMMdgr7qM2DQmMwgabs3GhNztdmoKkMKQFHD6DTMCIQw==", + "version": "1.2.14", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.2.14.tgz", + "integrity": "sha512-SD9kY8M009x/7s57Z1dgUVlP5+uYBgKQK0Tkr1WIWngrtUJmRCW1RJFMY7QDla2khXhmg2i5Io7MUjcmZNZ/KQ==", "cpu": [ "arm64" ], "dev": true, + "libc": [ + "glibc" + ], "license": "MIT", "optional": true, "os": [ @@ -2148,13 +2116,16 @@ } }, "node_modules/@rolldown/binding-linux-arm64-musl": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.0.3.tgz", - "integrity": "sha512-VWkUHwWriDciit80wleYwKILoR/KMvxh/IdwS/paX+ZgpuRpCrKLUdadJbc0NpBEiyhpYawsJ73j9aCvOH+f7Q==", + "version": "1.2.14", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.2.14.tgz", + "integrity": "sha512-BJ2CL19M6uuWesMgnqJzgxoI+N+eV7b0A9O9vLV42y+gitC41fNa8KElau1tHVzuLWX5GzEuxMPhAO0tQXQMkg==", "cpu": [ "arm64" ], "dev": true, + "libc": [ + "musl" + ], "license": "MIT", "optional": true, "os": [ @@ -2165,13 +2136,16 @@ } }, "node_modules/@rolldown/binding-linux-ppc64-gnu": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.0.3.tgz", - "integrity": "sha512-5f1laC0SlIR0yDbFCd8acUhvJIag6N3zC5P7oUPN6wX0aOma+uKJ0wBDH5aq7I1PVI2ttTlhJwzwRIBnLiSGEg==", + "version": "1.2.14", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.2.14.tgz", + "integrity": "sha512-4+Kl8daPV8NDHaD7mfCcc1pRM+Qg7ab8DyMm5FG5WoZgv4h2i3KFN1CybYBNbQqmuYm7ftJLvmMI1eHYJ22S/Q==", "cpu": [ "ppc64" ], "dev": true, + "libc": [ + "glibc" + ], "license": "MIT", "optional": true, "os": [ @@ -2182,13 +2156,16 @@ } }, "node_modules/@rolldown/binding-linux-s390x-gnu": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.0.3.tgz", - "integrity": "sha512-Iq4ko0r4XsgbrF/LunNgHtAGLRRVE2kXonAXQ/MV0mC6jQpMOhW1SvtZja2EhC/kd05++bP78dsqBeIQyYJ6Yg==", + "version": "1.2.14", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.2.14.tgz", + "integrity": "sha512-V/h8ePq1fKDrmtFMz6xcCN3DKvuvMRrwwD1q0PkBeeo4DpK64biaKvqWGVxqCeYKqeRk7gE7/iKPxLCn08O97A==", "cpu": [ "s390x" ], "dev": true, + "libc": [ + "glibc" + ], "license": "MIT", "optional": true, "os": [ @@ -2199,13 +2176,16 @@ } }, "node_modules/@rolldown/binding-linux-x64-gnu": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.0.3.tgz", - "integrity": "sha512-B8m6tD5+/N5FeNQFbKlLA/2yVq9ycQP1SeedyEYYKWBNR3ZQbkvIUcNnDNM03lO1l5F2roiiFJGgvoLLyZXtSg==", + "version": "1.2.14", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.2.14.tgz", + "integrity": "sha512-WxalXzgrLVpKC93xfpaJ3gCXUWzeCr+FRCbDVD2mUAy4L6Vo3lNVz0epUEUpxPWJkLNYUwM4OSIikv36OJ9mLg==", "cpu": [ "x64" ], "dev": true, + "libc": [ + "glibc" + ], "license": "MIT", "optional": true, "os": [ @@ -2216,13 +2196,16 @@ } }, "node_modules/@rolldown/binding-linux-x64-musl": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.0.3.tgz", - "integrity": "sha512-pSdpdUJHkuCxun9LE7jvgUB9qsRgaiyNNCX7m/AvHTcq67AiT/Yhoxvw5zPfhrM8k/BfP8ce/hMOpthKDpEUow==", + "version": "1.2.14", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.2.14.tgz", + "integrity": "sha512-3G3tnAmTpdk9jO3GQtyHXqeFD2SlecfTws/Y3M2mmj9nMBwwuLT4kur8sZKeu7ENUHSpVO3cIsm5A52LEWWwlg==", "cpu": [ "x64" ], "dev": true, + "libc": [ + "musl" + ], "license": "MIT", "optional": true, "os": [ @@ -2233,9 +2216,9 @@ } }, "node_modules/@rolldown/binding-openharmony-arm64": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.0.3.tgz", - "integrity": "sha512-OXXS3RKJgX2uLwM+gYyuH5omcH8fL1LJs96pZGgtetVCahON57+d4SJHzTgZiOjxgGkSnpXpOsWuPDGAKAigEg==", + "version": "1.2.14", + "resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.2.14.tgz", + "integrity": "sha512-qyr5XiNGxm2ytx17vvtcJ9nXn6v1gENETi503bJx3OGwZ1tSonj7BosxbMa/xeAfSl5iS9X6KjEWYXLu6v1L7Q==", "cpu": [ "arm64" ], @@ -2249,40 +2232,10 @@ "node": "^20.19.0 || >=22.12.0" } }, - "node_modules/@rolldown/binding-wasm32-wasi": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/@rolldown/binding-wasm32-wasi/-/binding-wasm32-wasi-1.0.3.tgz", - "integrity": "sha512-JTtb8BWFynicNSoPrehsCzBtOKjZ6jhMiPFEmOiuXg1Fl8dn2KHQob+GuPSGR0dryQa1PQJbzjF3dqO/whhjLg==", - "cpu": [ - "wasm32" - ], - "dev": true, - "license": "MIT", - "optional": true, - "dependencies": { - "@emnapi/core": "1.10.0", - "@emnapi/runtime": "1.10.0", - "@napi-rs/wasm-runtime": "^1.1.4" - }, - "engines": { - "node": "^20.19.0 || >=22.12.0" - } - }, - "node_modules/@rolldown/binding-wasm32-wasi/node_modules/@emnapi/runtime": { - "version": "1.10.0", - "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.10.0.tgz", - "integrity": "sha512-ewvYlk86xUoGI0zQRNq/mC+16R1QeDlKQy21Ki3oSYXNgLb45GV1P6A0M+/s6nyCuNDqe5VpaY84BzXGwVbwFA==", - "dev": true, - "license": "MIT", - "optional": true, - "dependencies": { - "tslib": "^2.4.0" - } - }, "node_modules/@rolldown/binding-win32-arm64-msvc": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.0.3.tgz", - "integrity": "sha512-gEdFFEN70A/jxb2svrWsN3aDL7OUtmvlOy+6fa2jxG8K0wQ1ZbdeLGnidov6Yu5/733dI5ySfzFlQ/cb0bSz1g==", + "version": "1.2.14", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.2.14.tgz", + "integrity": "sha512-oA4o5o0S2yB199YyYXDzKUv1Z56Rc+mgOjwm7Z/vJ9Z8igiDPYvoOuDyo6g7AK/ZaF09nPhDKVnGBcYHO11GXA==", "cpu": [ "arm64" ], @@ -2297,9 +2250,9 @@ } }, "node_modules/@rolldown/binding-win32-x64-msvc": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.0.3.tgz", - "integrity": "sha512-eXB7CHuaQdqmJcc3koCNtNPmT/bj2gc999kUFgBxG8Ac0NdgXc4rkCHhqrgrhN3zddvvvrgzj1e90SuSfmyIXA==", + "version": "1.2.14", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.2.14.tgz", + "integrity": "sha512-9mE94bf2GZUaR/fUSXCMpBoWkctu421Mi0ORpm4zqfbkjKep7RxzPFa5etcGs5h+2KApoWBOA/hAJa/7saGgEQ==", "cpu": [ "x64" ], @@ -2446,17 +2399,6 @@ "dev": true, "license": "MIT" }, - "node_modules/@tybys/wasm-util": { - "version": "0.10.2", - "resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.2.tgz", - "integrity": "sha512-RoBvJ2X0wuKlWFIjrwffGw1IqZHKQqzIchKaadZZfnNpsAYp2mM0h36JtPCjNDAHGgYez/15uMBpfGwchhiMgg==", - "dev": true, - "license": "MIT", - "optional": true, - "dependencies": { - "tslib": "^2.4.0" - } - }, "node_modules/@types/chai": { "version": "5.2.3", "resolved": "https://registry.npmjs.org/@types/chai/-/chai-5.2.3.tgz", @@ -2925,16 +2867,16 @@ } }, "node_modules/@vitest/expect": { - "version": "4.1.11", - "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.1.11.tgz", - "integrity": "sha512-VX2x5vNJXET47KAFzwERI+KRMtTTCSWTfSMKsW7JsUsXV4psq++e3DvZpuTDOpHcxytiDs6p2nhVb2tVDiiUYw==", + "version": "4.1.12", + "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.1.12.tgz", + "integrity": "sha512-vFlTt+YEu9WBpNjdGayNmlMVzrWPi1+sv853/7t2FU7hpO5gAFBKHzQmkCq5Xjpr+evegF2994LQT3hF3mRlqQ==", "dev": true, "license": "MIT", "dependencies": { "@standard-schema/spec": "^1.1.0", "@types/chai": "^5.2.2", - "@vitest/spy": "4.1.11", - "@vitest/utils": "4.1.11", + "@vitest/spy": "4.1.12", + "@vitest/utils": "4.1.12", "chai": "^6.2.2", "tinyrainbow": "^3.1.0" }, @@ -2943,13 +2885,13 @@ } }, "node_modules/@vitest/mocker": { - "version": "4.1.11", - "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.1.11.tgz", - "integrity": "sha512-2XJVD55d1o5AZous5CCGKS74g/riOj9odEt2bQpCVZeblHyHdnMeFl4jl0XjU21stf4mbjUkew2eXQZt65g5CQ==", + "version": "4.1.12", + "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.1.12.tgz", + "integrity": "sha512-fKShNpKZzupWZuY0z7egydvL0sqW4INEZBEExMBMAa6izlENEVHwoXgdaTCaBZ5fwlr4xf2o1wXC83n9RpO+NA==", "dev": true, "license": "MIT", "dependencies": { - "@vitest/spy": "4.1.11", + "@vitest/spy": "4.1.12", "estree-walker": "^3.0.3", "magic-string": "^0.30.21" }, @@ -2970,9 +2912,9 @@ } }, "node_modules/@vitest/pretty-format": { - "version": "4.1.11", - "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.1.11.tgz", - "integrity": "sha512-yiZzPbGTS9Sr/JpFl8zHrcIkAofNbFV6k21vIgQN/cY/oxZeXhJv5sc/MBJ5jFKWmWs+oJHw0UXLZjmf931+Vw==", + "version": "4.1.12", + "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.1.12.tgz", + "integrity": "sha512-XW9O8+Mxx4VpCtkYdMJLZQQwsVryP9wk7cPKDxtXFfwSak6yRh5+2fep/Y0XF/ElBvJHq8bcYdi6aeuai0opZA==", "dev": true, "license": "MIT", "dependencies": { @@ -2983,13 +2925,13 @@ } }, "node_modules/@vitest/runner": { - "version": "4.1.11", - "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.1.11.tgz", - "integrity": "sha512-LztvUgdwMNJMIkj3hQnnxiC2Xy1zNxq928W/xhjCLaNCzqTZOudjwbQf6v9IntZGPw132i2Lq2rgTRZHD3JHNw==", + "version": "4.1.12", + "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.1.12.tgz", + "integrity": "sha512-NKlVKCr5QnCe8jijdMnmkpHHU+WTqiLFR6G0pHji9uRjXK9xJl1k1nTw6F3pOOj2PDcsDt2GKQMI7wIsaT491w==", "dev": true, "license": "MIT", "dependencies": { - "@vitest/utils": "4.1.11", + "@vitest/utils": "4.1.12", "pathe": "^2.0.3" }, "funding": { @@ -2997,14 +2939,14 @@ } }, "node_modules/@vitest/snapshot": { - "version": "4.1.11", - "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.1.11.tgz", - "integrity": "sha512-pN7ikn1ON7h8ee4gIAp4AzyK+zBtJPzVbqOgu5LCEh4VaJVbPQcgYQYJIMGQPXVeJJq1fnfazis7a5pFNPahog==", + "version": "4.1.12", + "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.1.12.tgz", + "integrity": "sha512-ko1y1Qgb/slzQ9mr+b89Cuqud8WG1TKtsPD8r7ZUBdvVOGt85Af2HwCqHujrhwJq95hs/9RM7fcAPg57o1vlzw==", "dev": true, "license": "MIT", "dependencies": { - "@vitest/pretty-format": "4.1.11", - "@vitest/utils": "4.1.11", + "@vitest/pretty-format": "4.1.12", + "@vitest/utils": "4.1.12", "magic-string": "^0.30.21", "pathe": "^2.0.3" }, @@ -3013,9 +2955,9 @@ } }, "node_modules/@vitest/spy": { - "version": "4.1.11", - "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.1.11.tgz", - "integrity": "sha512-apNa/prQy2qCeywhnixOHPRCgGNhvg7T4Dapfl1GahLp/R+uhBm5cPyFoNVyqsNd2h1nJxL6BqqdIjiABL60YA==", + "version": "4.1.12", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.1.12.tgz", + "integrity": "sha512-RGq9x4JNGNTZHEJxvN0oSiJjeSCExm0n2gZKKJZHY9cX5AVA6M7VJILBT3ZJ/tJ/0J9uWT7fETV9/btoi//vSg==", "dev": true, "license": "MIT", "funding": { @@ -3023,13 +2965,13 @@ } }, "node_modules/@vitest/utils": { - "version": "4.1.11", - "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.1.11.tgz", - "integrity": "sha512-zTCVGpyFsGWBhllOyKlTw/vnr6D9qxsfSDyfbyZmTyjHw5N/VuvzHpHoQjm2ZJzn4RJgx5w4r7V0er69CmLgPQ==", + "version": "4.1.12", + "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.1.12.tgz", + "integrity": "sha512-Q3awXrwOfEV+qc3WmO5ONyE0yO7Q1gQ9/oyHxGLZ/pl7N3NO/ZztqQwKLuwMkV+CA1JYSIeO3gkHnRDIo4AVaA==", "dev": true, "license": "MIT", "dependencies": { - "@vitest/pretty-format": "4.1.11", + "@vitest/pretty-format": "4.1.12", "convert-source-map": "^2.0.0", "tinyrainbow": "^3.1.0" }, @@ -3053,30 +2995,6 @@ "node": ">= 14" } }, - "node_modules/ajv": { - "version": "8.20.0", - "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.20.0.tgz", - "integrity": "sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA==", - "dev": true, - "license": "MIT", - "dependencies": { - "fast-deep-equal": "^3.1.3", - "fast-uri": "^3.0.1", - "json-schema-traverse": "^1.0.0", - "require-from-string": "^2.0.2" - }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/epoberezkin" - } - }, - "node_modules/argparse": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", - "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==", - "dev": true, - "license": "Python-2.0" - }, "node_modules/asn1": { "version": "0.2.6", "resolved": "https://registry.npmjs.org/asn1/-/asn1-0.2.6.tgz", @@ -3096,32 +3014,6 @@ "node": ">=12" } }, - "node_modules/autoevals": { - "version": "0.3.0", - "resolved": "https://registry.npmjs.org/autoevals/-/autoevals-0.3.0.tgz", - "integrity": "sha512-4CEzBVhjVBHvk46s+DBcgmEfOdM+zXEoCkvvDqYO2IWdVpZKUJANfX8BvfE5vfcGy24on9zW21Zf7V9cUJdbfg==", - "dev": true, - "hasInstallScript": true, - "license": "MIT", - "dependencies": { - "ajv": "^8.17.1", - "compute-cosine-similarity": "^1.1.0", - "js-levenshtein": "^1.1.6", - "js-yaml": "^4.1.0", - "linear-sum-assignment": "^1.0.7", - "mustache": "^4.2.0", - "openai": "^6.7.0", - "zod-to-json-schema": "3.25.0" - }, - "engines": { - "npm": "please-use-pnpm", - "pnpm": ">=10.27.0", - "yarn": "please-use-pnpm" - }, - "peerDependencies": { - "zod": "^3.25.0 || ^4.0.0" - } - }, "node_modules/balanced-match": { "version": "4.0.4", "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz", @@ -3169,13 +3061,6 @@ "node": "*" } }, - "node_modules/binary-search": { - "version": "1.3.6", - "resolved": "https://registry.npmjs.org/binary-search/-/binary-search-1.3.6.tgz", - "integrity": "sha512-nbE1WxOTTrUWIfsfZ4aHGYu5DOuNkbxGokjV6Z2kxfJK3uaAb8zNK1muzOeipoLHZjInT4Br88BHpzevc681xA==", - "dev": true, - "license": "CC0-1.0" - }, "node_modules/bl": { "version": "4.1.0", "resolved": "https://registry.npmjs.org/bl/-/bl-4.1.0.tgz", @@ -3281,9 +3166,9 @@ } }, "node_modules/chai": { - "version": "6.2.2", - "resolved": "https://registry.npmjs.org/chai/-/chai-6.2.2.tgz", - "integrity": "sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==", + "version": "6.3.0", + "resolved": "https://registry.npmjs.org/chai/-/chai-6.3.0.tgz", + "integrity": "sha512-XWAtwJ6OHO+tj0EKCs0Y2UamnyOxseZWltU4x2U2wh8g4AigdjwvtUjvLP2tqkA/avxHEtzxNaqGq/YGNwckKg==", "dev": true, "license": "MIT", "engines": { @@ -3302,13 +3187,6 @@ "url": "https://github.com/chalk/chalk?sponsor=1" } }, - "node_modules/cheminfo-types": { - "version": "1.16.0", - "resolved": "https://registry.npmjs.org/cheminfo-types/-/cheminfo-types-1.16.0.tgz", - "integrity": "sha512-j0MiXFnLRVEZg3vUr7SXm6F3HuDNVE5arfQyDMiy1w2fAcuOs3QRgVsw7aOIACvr0aqbvn85cucwt+yOu64IiQ==", - "dev": true, - "license": "MIT" - }, "node_modules/chownr": { "version": "1.1.4", "resolved": "https://registry.npmjs.org/chownr/-/chownr-1.1.4.tgz", @@ -3332,38 +3210,6 @@ "node": ">=18" } }, - "node_modules/compute-cosine-similarity": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/compute-cosine-similarity/-/compute-cosine-similarity-1.1.0.tgz", - "integrity": "sha512-FXhNx0ILLjGi9Z9+lglLzM12+0uoTnYkHm7GiadXDAr0HGVLm25OivUS1B/LPkbzzvlcXz/1EvWg9ZYyJSdhTw==", - "dev": true, - "dependencies": { - "compute-dot": "^1.1.0", - "compute-l2norm": "^1.1.0", - "validate.io-array": "^1.0.5", - "validate.io-function": "^1.0.2" - } - }, - "node_modules/compute-dot": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/compute-dot/-/compute-dot-1.1.0.tgz", - "integrity": "sha512-L5Ocet4DdMrXboss13K59OK23GXjiSia7+7Ukc7q4Bl+RVpIXK2W9IHMbWDZkh+JUEvJAwOKRaJDiFUa1LTnJg==", - "dev": true, - "dependencies": { - "validate.io-array": "^1.0.3", - "validate.io-function": "^1.0.2" - } - }, - "node_modules/compute-l2norm": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/compute-l2norm/-/compute-l2norm-1.1.0.tgz", - "integrity": "sha512-6EHh1Elj90eU28SXi+h2PLnTQvZmkkHWySpoFz+WOlVNLz3DQoC4ISUHSV9n5jMxPHtKGJ01F4uu2PsXBB8sSg==", - "dev": true, - "dependencies": { - "validate.io-array": "^1.0.3", - "validate.io-function": "^1.0.2" - } - }, "node_modules/concat-map": { "version": "0.0.1", "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", @@ -3625,35 +3471,29 @@ "node": ">=18" } }, - "node_modules/fast-deep-equal": { - "version": "3.1.3", - "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", - "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", - "dev": true, - "license": "MIT" - }, "node_modules/fast-sha256": { "version": "1.3.0", "resolved": "https://registry.npmjs.org/fast-sha256/-/fast-sha256-1.3.0.tgz", "integrity": "sha512-n11RGP/lrWEFI/bWdygLxhI+pVeo1ZYIVwvvPkW7azl/rOy+F3HYRZ2K5zeE9mmkhQppyv9sQFx0JM9UabnpPQ==", "license": "Unlicense" }, - "node_modules/fast-uri": { - "version": "3.1.8", - "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.8.tgz", - "integrity": "sha512-GZMtZUTNRpOVIECoXwLNZS5xUGE+mVNbTB8h/7Rwh2TFWcBQiPzTgyZi05BF9UMZKkLJv8XBRJTlU7zg8+ZfMg==", + "node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", "dev": true, - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/fastify" - }, - { - "type": "opencollective", - "url": "https://opencollective.com/fastify" + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true } - ], - "license": "BSD-3-Clause" + } }, "node_modules/fetch-blob": { "version": "3.2.0", @@ -3678,13 +3518,6 @@ "node": "^12.20 || >= 14.13" } }, - "node_modules/fft.js": { - "version": "4.0.4", - "resolved": "https://registry.npmjs.org/fft.js/-/fft.js-4.0.4.tgz", - "integrity": "sha512-f9c00hphOgeQTlDyavwTtu6RiK8AIFjD6+jvXkNkpeQ7rirK3uFWVpalkoS4LAwbdX7mfZ8aoBfFVQX1Re/8aw==", - "dev": true, - "license": "MIT" - }, "node_modules/formdata-polyfill": { "version": "4.0.10", "resolved": "https://registry.npmjs.org/formdata-polyfill/-/formdata-polyfill-4.0.10.tgz", @@ -4029,13 +3862,6 @@ "node": ">= 0.10" } }, - "node_modules/is-any-array": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/is-any-array/-/is-any-array-3.0.0.tgz", - "integrity": "sha512-o4h+tylWykC4BD1vaejp6gDxoM13bwW8FGuNs4yIKpj8xbBJcRxJx8vZpq0dCr7ZDEfeKjmsi/euolKhX6f/ww==", - "dev": true, - "license": "MIT" - }, "node_modules/is-core-module": { "version": "2.17.0", "resolved": "https://registry.npmjs.org/is-core-module/-/is-core-module-2.17.0.tgz", @@ -4067,39 +3893,6 @@ "jiti": "lib/jiti-cli.mjs" } }, - "node_modules/js-levenshtein": { - "version": "1.1.6", - "resolved": "https://registry.npmjs.org/js-levenshtein/-/js-levenshtein-1.1.6.tgz", - "integrity": "sha512-X2BB11YZtrRqY4EnQcLX5Rh373zbK4alC1FW7D7MBhL2gtcC17cTnr6DmfHZeS0s2rTHjUTMMHfG7gO8SSdw+g==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/js-yaml": { - "version": "4.3.2", - "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.3.2.tgz", - "integrity": "sha512-SFNOvSJ+Dgf/9An904Yx+CgSlIPCkIpao4qo51lpee25TIRejdH3rhR4EZMGoNx3/TP3O+wzWuiTFl4sqbltzA==", - "dev": true, - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/puzrin" - }, - { - "type": "github", - "url": "https://github.com/sponsors/nodeca" - } - ], - "license": "MIT", - "dependencies": { - "argparse": "^2.0.1" - }, - "bin": { - "js-yaml": "bin/js-yaml.js" - } - }, "node_modules/json-bigint": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/json-bigint/-/json-bigint-1.0.0.tgz", @@ -4122,13 +3915,6 @@ "node": ">=16" } }, - "node_modules/json-schema-traverse": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", - "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==", - "dev": true, - "license": "MIT" - }, "node_modules/jwa": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/jwa/-/jwa-2.0.1.tgz", @@ -4161,9 +3947,9 @@ } }, "node_modules/lightningcss": { - "version": "1.32.0", - "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.32.0.tgz", - "integrity": "sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ==", + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.33.0.tgz", + "integrity": "sha512-WkUDrojuJs0xkgGf2udWxa3yGBRxPtxUkB79i6aCZLRgc7PM8fZe9TosfPDcvEpQZbuFASnHYmRLBLUbmLOIIA==", "dev": true, "license": "MPL-2.0", "dependencies": { @@ -4177,23 +3963,23 @@ "url": "https://opencollective.com/parcel" }, "optionalDependencies": { - "lightningcss-android-arm64": "1.32.0", - "lightningcss-darwin-arm64": "1.32.0", - "lightningcss-darwin-x64": "1.32.0", - "lightningcss-freebsd-x64": "1.32.0", - "lightningcss-linux-arm-gnueabihf": "1.32.0", - "lightningcss-linux-arm64-gnu": "1.32.0", - "lightningcss-linux-arm64-musl": "1.32.0", - "lightningcss-linux-x64-gnu": "1.32.0", - "lightningcss-linux-x64-musl": "1.32.0", - "lightningcss-win32-arm64-msvc": "1.32.0", - "lightningcss-win32-x64-msvc": "1.32.0" + "lightningcss-android-arm64": "1.33.0", + "lightningcss-darwin-arm64": "1.33.0", + "lightningcss-darwin-x64": "1.33.0", + "lightningcss-freebsd-x64": "1.33.0", + "lightningcss-linux-arm-gnueabihf": "1.33.0", + "lightningcss-linux-arm64-gnu": "1.33.0", + "lightningcss-linux-arm64-musl": "1.33.0", + "lightningcss-linux-x64-gnu": "1.33.0", + "lightningcss-linux-x64-musl": "1.33.0", + "lightningcss-win32-arm64-msvc": "1.33.0", + "lightningcss-win32-x64-msvc": "1.33.0" } }, "node_modules/lightningcss-android-arm64": { - "version": "1.32.0", - "resolved": "https://registry.npmjs.org/lightningcss-android-arm64/-/lightningcss-android-arm64-1.32.0.tgz", - "integrity": "sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg==", + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-android-arm64/-/lightningcss-android-arm64-1.33.0.tgz", + "integrity": "sha512-gEpRTalKdosp4Bb8qWtc2iOgE5SeIHlpS1up9bFq2wAyYhl1UdTObYiHe98zEM9SQvSoqQZ1IQD0JNpg3Ml5pg==", "cpu": [ "arm64" ], @@ -4212,9 +3998,9 @@ } }, "node_modules/lightningcss-darwin-arm64": { - "version": "1.32.0", - "resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.32.0.tgz", - "integrity": "sha512-RzeG9Ju5bag2Bv1/lwlVJvBE3q6TtXskdZLLCyfg5pt+HLz9BqlICO7LZM7VHNTTn/5PRhHFBSjk5lc4cmscPQ==", + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.33.0.tgz", + "integrity": "sha512-Sciaz8eenNTKn9b3t7+xr0ipTp9YxKQY4npwQ3mrRuL0BAVHBLyZxofhaKBAVtzmtRZ/zTyo0/to4B1uWG/Djg==", "cpu": [ "arm64" ], @@ -4233,9 +4019,9 @@ } }, "node_modules/lightningcss-darwin-x64": { - "version": "1.32.0", - "resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.32.0.tgz", - "integrity": "sha512-U+QsBp2m/s2wqpUYT/6wnlagdZbtZdndSmut/NJqlCcMLTWp5muCrID+K5UJ6jqD2BFshejCYXniPDbNh73V8w==", + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.33.0.tgz", + "integrity": "sha512-Z5UPAxzrjlWNNyGy6i65cJzzvgJ5D3T6wMvs+gWpY9d7qRhANrxqAp6LhxIgZhWEw18RfJTGcRxjuLIBr+m8XQ==", "cpu": [ "x64" ], @@ -4254,9 +4040,9 @@ } }, "node_modules/lightningcss-freebsd-x64": { - "version": "1.32.0", - "resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.32.0.tgz", - "integrity": "sha512-JCTigedEksZk3tHTTthnMdVfGf61Fky8Ji2E4YjUTEQX14xiy/lTzXnu1vwiZe3bYe0q+SpsSH/CTeDXK6WHig==", + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.33.0.tgz", + "integrity": "sha512-QQM/Ti/hQajJwCY+RiWuCZ9sdtI/XQk7nDK5vC8kkdwixezOlDgvDx7+RT+QjK6FcFT4MpsuoBnHIo/O3StRRg==", "cpu": [ "x64" ], @@ -4275,9 +4061,9 @@ } }, "node_modules/lightningcss-linux-arm-gnueabihf": { - "version": "1.32.0", - "resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.32.0.tgz", - "integrity": "sha512-x6rnnpRa2GL0zQOkt6rts3YDPzduLpWvwAF6EMhXFVZXD4tPrBkEFqzGowzCsIWsPjqSK+tyNEODUBXeeVHSkw==", + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.33.0.tgz", + "integrity": "sha512-N7FVBe6iS24MlM6R/4RBTxGhQheZGs7tiQ9U32UtF75NzP5Q7xWPRqLBCKxlRQRk3rY1jCIPLzx7WzOhuUIRLQ==", "cpu": [ "arm" ], @@ -4296,13 +4082,16 @@ } }, "node_modules/lightningcss-linux-arm64-gnu": { - "version": "1.32.0", - "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.32.0.tgz", - "integrity": "sha512-0nnMyoyOLRJXfbMOilaSRcLH3Jw5z9HDNGfT/gwCPgaDjnx0i8w7vBzFLFR1f6CMLKF8gVbebmkUN3fa/kQJpQ==", + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.33.0.tgz", + "integrity": "sha512-j2v/itmy4HlNxlc6voKXYgBqNi0Ng2LShg4z7GufpEgs05P+2suBVyi9I6YHq5uoVFx9ETin3eCEhLVyXGQnKg==", "cpu": [ "arm64" ], "dev": true, + "libc": [ + "glibc" + ], "license": "MPL-2.0", "optional": true, "os": [ @@ -4317,13 +4106,16 @@ } }, "node_modules/lightningcss-linux-arm64-musl": { - "version": "1.32.0", - "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.32.0.tgz", - "integrity": "sha512-UpQkoenr4UJEzgVIYpI80lDFvRmPVg6oqboNHfoH4CQIfNA+HOrZ7Mo7KZP02dC6LjghPQJeBsvXhJod/wnIBg==", + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.33.0.tgz", + "integrity": "sha512-yiO5ROMuYQgXbC60yjZU5CYSFZGKXL0HFATXt9mHJn1+zW55oCtMI9NfcVhYLMFDL7gV7oBPon/EmMMGg2OvtQ==", "cpu": [ "arm64" ], "dev": true, + "libc": [ + "musl" + ], "license": "MPL-2.0", "optional": true, "os": [ @@ -4338,13 +4130,16 @@ } }, "node_modules/lightningcss-linux-x64-gnu": { - "version": "1.32.0", - "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.32.0.tgz", - "integrity": "sha512-V7Qr52IhZmdKPVr+Vtw8o+WLsQJYCTd8loIfpDaMRWGUZfBOYEJeyJIkqGIDMZPwPx24pUMfwSxxI8phr/MbOA==", + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.33.0.tgz", + "integrity": "sha512-ar+Ju7LmcN0Jo4FpL4hpFybwNG9/3A/Br5KW2n2jyODg3MEZXaDYADdemoNS+BDNfMgKvylJLj4S5tyRActuAg==", "cpu": [ "x64" ], "dev": true, + "libc": [ + "glibc" + ], "license": "MPL-2.0", "optional": true, "os": [ @@ -4359,13 +4154,16 @@ } }, "node_modules/lightningcss-linux-x64-musl": { - "version": "1.32.0", - "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.32.0.tgz", - "integrity": "sha512-bYcLp+Vb0awsiXg/80uCRezCYHNg1/l3mt0gzHnWV9XP1W5sKa5/TCdGWaR/zBM2PeF/HbsQv/j2URNOiVuxWg==", + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.33.0.tgz", + "integrity": "sha512-RYiYbkokw0trfKqqzfF55lginwEPrD3OJDfTuJzFs1MK6iFnDenaz1fqLLtX4ITG3OktJQXOeTaw1awrBAlZPw==", "cpu": [ "x64" ], "dev": true, + "libc": [ + "musl" + ], "license": "MPL-2.0", "optional": true, "os": [ @@ -4380,9 +4178,9 @@ } }, "node_modules/lightningcss-win32-arm64-msvc": { - "version": "1.32.0", - "resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.32.0.tgz", - "integrity": "sha512-8SbC8BR40pS6baCM8sbtYDSwEVQd4JlFTOlaD3gWGHfThTcABnNDBda6eTZeqbofalIJhFx0qKzgHJmcPTnGdw==", + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.33.0.tgz", + "integrity": "sha512-1K+MPfLSFVpphzpdbfkhlWk6wBrTObBzS2T6db10PNOZgR9GoVsAWzwNyuhUYYbTp23j+4RrncfujZ4uAzXvwA==", "cpu": [ "arm64" ], @@ -4401,9 +4199,9 @@ } }, "node_modules/lightningcss-win32-x64-msvc": { - "version": "1.32.0", - "resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.32.0.tgz", - "integrity": "sha512-Amq9B/SoZYdDi1kFrojnoqPLxYhQ4Wo5XiL8EVJrVsB8ARoC1PWW6VGtT0WKCemjy8aC+louJnjS7U18x3b06Q==", + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.33.0.tgz", + "integrity": "sha512-OlEICDx/Xl0FqSp4bry8zFnCvGpig3Gl4gCquvYwHuqJKEC1+n9NgDniFvqHGmMv1ZkqDJrDqKKSykTDX+ehuA==", "cpu": [ "x64" ], @@ -4421,18 +4219,6 @@ "url": "https://opencollective.com/parcel" } }, - "node_modules/linear-sum-assignment": { - "version": "1.0.9", - "resolved": "https://registry.npmjs.org/linear-sum-assignment/-/linear-sum-assignment-1.0.9.tgz", - "integrity": "sha512-1T2Ek3sxpt2mBHeBFMRJEikiIK/yIOwf+mrxv/DkAU/5ddnCMndZL//hFH7QuHa1tbaQADzsf9t7rkGZKqoFfQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "cheminfo-types": "^1.8.1", - "ml-matrix": "^6.12.1", - "ml-spectra-processing": "^14.18.0" - } - }, "node_modules/lodash-es": { "version": "4.18.1", "resolved": "https://registry.npmjs.org/lodash-es/-/lodash-es-4.18.1.tgz", @@ -4561,87 +4347,12 @@ "dev": true, "license": "MIT" }, - "node_modules/ml-array-max": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/ml-array-max/-/ml-array-max-2.0.0.tgz", - "integrity": "sha512-QQZ4kENwpWmyNb98UXRDFXrmtIXuXtt1+bSbda/2KA85+F+rrJP8hZk6QOkCQXM2Th9mUDYdq/PNByPdT9ID4A==", - "dev": true, - "license": "MIT", - "dependencies": { - "is-any-array": "^3.0.0" - } - }, - "node_modules/ml-array-min": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/ml-array-min/-/ml-array-min-2.0.0.tgz", - "integrity": "sha512-GRj6Ky6sW9vGL6yIjgsHmXZ9YgrdmcQ8nCxPqEGeKc6dkfYg1XDYxGFxADUjNuZyoCd5PUscWAS4N+cFaX6hFg==", - "dev": true, - "license": "MIT", - "dependencies": { - "is-any-array": "^3.0.0" - } - }, - "node_modules/ml-array-rescale": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/ml-array-rescale/-/ml-array-rescale-2.0.0.tgz", - "integrity": "sha512-2GGtKfSno94/kIloWGvpp/U5Q5vLvLrza+SAaGsLeo6Xj4mEbA6Gqx+oTfZFkxnd1grT2X007HfJNs3T5BsiVg==", - "dev": true, - "license": "MIT", - "dependencies": { - "is-any-array": "^3.0.0", - "ml-array-max": "^2.0.0", - "ml-array-min": "^2.0.0" - } - }, - "node_modules/ml-matrix": { - "version": "6.15.0", - "resolved": "https://registry.npmjs.org/ml-matrix/-/ml-matrix-6.15.0.tgz", - "integrity": "sha512-wFa1v6KP8bKp+fj0nYmRs1Pb5K4zRkXGKsOvLinvILENFIADncm4XlOI+S1M7yuACMGfI6cfk0IifDgd4j5xmw==", - "dev": true, - "license": "MIT", - "dependencies": { - "is-any-array": "^3.0.0", - "ml-array-rescale": "^2.0.0" - } - }, - "node_modules/ml-spectra-processing": { - "version": "14.35.1", - "resolved": "https://registry.npmjs.org/ml-spectra-processing/-/ml-spectra-processing-14.35.1.tgz", - "integrity": "sha512-yxTB9YpgNmo53U+c2tG1/uKKUl+CNO3hlp+3utuBlhAqDw3AD9c78gI+1rYz+sykOXV76QtAXuAhFZFZjMvgDw==", - "dev": true, - "license": "MIT", - "dependencies": { - "binary-search": "^1.3.6", - "cheminfo-types": "^1.16.0", - "fft.js": "^4.0.4", - "is-any-array": "^3.0.0", - "ml-matrix": "^6.14.0", - "ml-xsadd": "^3.0.1" - } - }, - "node_modules/ml-xsadd": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/ml-xsadd/-/ml-xsadd-3.0.1.tgz", - "integrity": "sha512-Fz2q6dwgzGM8wYKGArTUTZDGa4lQFA2Vi6orjGeTVRy22ZnQFKlJuwS9n8NRviqz1KHAHAzdKJwbnYhdo38uYg==", - "dev": true, - "license": "MIT" - }, "node_modules/ms": { "version": "2.1.3", "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", "license": "MIT" }, - "node_modules/mustache": { - "version": "4.2.0", - "resolved": "https://registry.npmjs.org/mustache/-/mustache-4.2.0.tgz", - "integrity": "sha512-71ippSywq5Yb7/tVYyGbkBggbU8H3u5Rz56fH60jGFgr8uHwxs+aSKeqmluIVzM0m0kB7xQjKS6qPfd0b2ZoqQ==", - "dev": true, - "license": "MIT", - "bin": { - "mustache": "bin/mustache" - } - }, "node_modules/nan": { "version": "2.27.0", "resolved": "https://registry.npmjs.org/nan/-/nan-2.27.0.tgz", @@ -4650,9 +4361,9 @@ "optional": true }, "node_modules/nanoid": { - "version": "3.3.18", - "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.18.tgz", - "integrity": "sha512-DTg4MJbGMWkfi6VZFdNt2/caMbQy4Ou+Op/hJQvGEWcnVfoA1QA+xzRKAzw9jD6+GVOOeYr/mIcuDSdug6F6+w==", + "version": "3.3.20", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.20.tgz", + "integrity": "sha512-uKdg2G3GNCKQn9byYOpxbGqrT2fGO5KRt5J/8b3pok8rT6qxGWF6hxMyJiEYtAf+FVyYuD9hRaDqX5uPFYJ4ZQ==", "dev": true, "funding": [ { @@ -4733,15 +4444,6 @@ "url": "https://opencollective.com/node-fetch" } }, - "node_modules/node-forge": { - "version": "1.4.0", - "resolved": "https://registry.npmjs.org/node-forge/-/node-forge-1.4.0.tgz", - "integrity": "sha512-LarFH0+6VfriEhqMMcLX2F7SwSXeWwnEAJEsYm5QKWchiVYVvJyV9v7UDvUv+w5HO23ZpQTXDv/GxdDdMyOuoQ==", - "license": "(BSD-3-Clause OR GPL-2.0)", - "engines": { - "node": ">= 6.13.0" - } - }, "node_modules/obug": { "version": "2.1.3", "resolved": "https://registry.npmjs.org/obug/-/obug-2.1.3.tgz", @@ -4766,25 +4468,6 @@ "wrappy": "1" } }, - "node_modules/openai": { - "version": "6.40.0", - "resolved": "https://registry.npmjs.org/openai/-/openai-6.40.0.tgz", - "integrity": "sha512-MWtTjd/gQt4jpbji61NTgFWJLoY/PdRJ6wG9/ZDRMYNMlBKrCrSlkLI+KgHP1vR1qT6LKSAyAqIxno6lcK9JiA==", - "dev": true, - "license": "Apache-2.0", - "peerDependencies": { - "ws": "^8.18.0", - "zod": "^3.25 || ^4.0" - }, - "peerDependenciesMeta": { - "ws": { - "optional": true - }, - "zod": { - "optional": true - } - } - }, "node_modules/p-retry": { "version": "4.6.2", "resolved": "https://registry.npmjs.org/p-retry/-/p-retry-4.6.2.tgz", @@ -4877,6 +4560,19 @@ "dev": true, "license": "ISC" }, + "node_modules/picomatch": { + "version": "4.0.7", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.7.tgz", + "integrity": "sha512-qcJu88Q2IWqJsDD529JKMdwGm/dvInW4HvQnRwiH9JtihJvzGOscDtHE3x1pBKeUOTysQ8kVmLnJ2kJu7yhcGA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, "node_modules/pnpm": { "version": "11.15.1", "resolved": "https://registry.npmjs.org/pnpm/-/pnpm-11.15.1.tgz", @@ -4897,9 +4593,9 @@ } }, "node_modules/postcss": { - "version": "8.5.24", - "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.24.tgz", - "integrity": "sha512-8RyVklq0owXUTa4xlpzu4l9AaVKIdQvAcOHZWaMh98HgySsUtxRVf/chRe3dsSLqb6i40BzGRzEUddRaI+9TSw==", + "version": "8.5.31", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.31.tgz", + "integrity": "sha512-YUbWYCgErwnMAVkjUnYa+KlMBzGpHwiIt/DE7f0G33AuX2LH9UcTk6KgWxd3zRU+pN3u/C+Xl3EnMLM4Hea7Sg==", "dev": true, "funding": [ { @@ -4917,9 +4613,9 @@ ], "license": "MIT", "dependencies": { - "nanoid": "^3.3.16", + "nanoid": "^3.3.20", "picocolors": "^1.1.1", - "source-map-js": "^1.2.1" + "source-map-js": "^1.2.2" }, "engines": { "node": "^10 || ^12 || >=14" @@ -5073,16 +4769,6 @@ "node": ">= 0.10" } }, - "node_modules/require-from-string": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz", - "integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=0.10.0" - } - }, "node_modules/resolve": { "version": "1.22.12", "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.12.tgz", @@ -5115,13 +4801,13 @@ } }, "node_modules/rolldown": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.0.3.tgz", - "integrity": "sha512-i00lAJ2ks1BYr7rjNjKC7BcqAS7nVfiT3QX1SI5aY+AFHblCmaUf9OE9dbdzDvW6dJxbi2ZCZiy9v3CcwOiX3g==", + "version": "1.2.14", + "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.2.14.tgz", + "integrity": "sha512-eTBTTqMR6uHKLgcUqpJP8ntzDI6V4LYA8negAMBEI7gkgVvOdrx6VsvS2Ym+83KhOPdLCHWJu0w7UQFJZhHKNw==", "dev": true, "license": "MIT", "dependencies": { - "@oxc-project/types": "=0.133.0", + "@oxc-project/types": "=0.154.0", "@rolldown/pluginutils": "^1.0.0" }, "bin": { @@ -5131,21 +4817,21 @@ "node": "^20.19.0 || >=22.12.0" }, "optionalDependencies": { - "@rolldown/binding-android-arm64": "1.0.3", - "@rolldown/binding-darwin-arm64": "1.0.3", - "@rolldown/binding-darwin-x64": "1.0.3", - "@rolldown/binding-freebsd-x64": "1.0.3", - "@rolldown/binding-linux-arm-gnueabihf": "1.0.3", - "@rolldown/binding-linux-arm64-gnu": "1.0.3", - "@rolldown/binding-linux-arm64-musl": "1.0.3", - "@rolldown/binding-linux-ppc64-gnu": "1.0.3", - "@rolldown/binding-linux-s390x-gnu": "1.0.3", - "@rolldown/binding-linux-x64-gnu": "1.0.3", - "@rolldown/binding-linux-x64-musl": "1.0.3", - "@rolldown/binding-openharmony-arm64": "1.0.3", - "@rolldown/binding-wasm32-wasi": "1.0.3", - "@rolldown/binding-win32-arm64-msvc": "1.0.3", - "@rolldown/binding-win32-x64-msvc": "1.0.3" + "@rolldown/binding-android-arm-eabi": "1.2.14", + "@rolldown/binding-android-arm64": "1.2.14", + "@rolldown/binding-darwin-arm64": "1.2.14", + "@rolldown/binding-darwin-x64": "1.2.14", + "@rolldown/binding-freebsd-x64": "1.2.14", + "@rolldown/binding-linux-arm-gnueabihf": "1.2.14", + "@rolldown/binding-linux-arm64-gnu": "1.2.14", + "@rolldown/binding-linux-arm64-musl": "1.2.14", + "@rolldown/binding-linux-ppc64-gnu": "1.2.14", + "@rolldown/binding-linux-s390x-gnu": "1.2.14", + "@rolldown/binding-linux-x64-gnu": "1.2.14", + "@rolldown/binding-linux-x64-musl": "1.2.14", + "@rolldown/binding-openharmony-arm64": "1.2.14", + "@rolldown/binding-win32-arm64-msvc": "1.2.14", + "@rolldown/binding-win32-x64-msvc": "1.2.14" } }, "node_modules/safe-buffer": { @@ -5525,37 +5211,6 @@ "url": "https://github.com/sponsors/SuperchupuDev" } }, - "node_modules/tinyglobby/node_modules/fdir": { - "version": "6.5.0", - "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", - "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=12.0.0" - }, - "peerDependencies": { - "picomatch": "^3 || ^4" - }, - "peerDependenciesMeta": { - "picomatch": { - "optional": true - } - } - }, - "node_modules/tinyglobby/node_modules/picomatch": { - "version": "4.0.4", - "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz", - "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/sponsors/jonschlinkert" - } - }, "node_modules/tinyrainbow": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-3.1.0.tgz", @@ -5670,30 +5325,17 @@ "dev": true, "license": "MIT" }, - "node_modules/validate.io-array": { - "version": "1.0.6", - "resolved": "https://registry.npmjs.org/validate.io-array/-/validate.io-array-1.0.6.tgz", - "integrity": "sha512-DeOy7CnPEziggrOO5CZhVKJw6S3Yi7e9e65R1Nl/RTN1vTQKnzjfvks0/8kQ40FP/dsjRAOd4hxmJ7uLa6vxkg==", - "dev": true, - "license": "MIT" - }, - "node_modules/validate.io-function": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/validate.io-function/-/validate.io-function-1.0.2.tgz", - "integrity": "sha512-LlFybRJEriSuBnUhQyG5bwglhh50EpTL2ul23MPIuR1odjO7XaMLFV8vHGwp7AZciFxtYOeiSCT5st+XSPONiQ==", - "dev": true - }, "node_modules/vite": { - "version": "8.0.16", - "resolved": "https://registry.npmjs.org/vite/-/vite-8.0.16.tgz", - "integrity": "sha512-h9bXPmJichP5fLmVQo3PyaGSDE2n3aPuomeAlVRm0JLmt4rY6zmPKd59HYI4LNW8oTK7tlTsuC7l/m7awx9Jcw==", + "version": "8.3.4", + "resolved": "https://registry.npmjs.org/vite/-/vite-8.3.4.tgz", + "integrity": "sha512-IftLE8phJhdNRIGNEV4JCCvfNScoZQVfcXaN8TQropWGJtOHcu+iAB9pHcuGvmEb/kddw0LBe/jS8ztdC6xdiQ==", "dev": true, "license": "MIT", "dependencies": { - "lightningcss": "^1.32.0", - "picomatch": "^4.0.4", - "postcss": "^8.5.15", - "rolldown": "1.0.3", + "lightningcss": "^1.33.0", + "picomatch": "^4.0.7", + "postcss": "^8.5.29", + "rolldown": "~1.2.12", "tinyglobby": "^0.2.17" }, "bin": { @@ -5710,7 +5352,7 @@ }, "peerDependencies": { "@types/node": "^20.19.0 || >=22.12.0", - "@vitejs/devtools": "^0.1.18", + "@vitejs/devtools": "^0.7.1", "esbuild": "^0.27.0 || ^0.28.0", "jiti": ">=1.21.0", "less": "^4.0.0", @@ -5761,33 +5403,20 @@ } } }, - "node_modules/vite/node_modules/picomatch": { - "version": "4.0.4", - "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz", - "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/sponsors/jonschlinkert" - } - }, "node_modules/vitest": { - "version": "4.1.11", - "resolved": "https://registry.npmjs.org/vitest/-/vitest-4.1.11.tgz", - "integrity": "sha512-fhACrNXUidIbGSBr5FlbuBkO7VWC1ZyLl0DO4CU2DrQoAPxX84Ysxs+HeGQpii5lZWV1Q4gBZTTu49mF+A6Edw==", + "version": "4.1.12", + "resolved": "https://registry.npmjs.org/vitest/-/vitest-4.1.12.tgz", + "integrity": "sha512-5eWdz2yb3ajxWF3C8C4TjkXl7FGm3n2E/VlazALrQHzK7nYMSGFnCZto0bbPNl77AwkOZVLk0YnBAejUu0NiWA==", "dev": true, "license": "MIT", "dependencies": { - "@vitest/expect": "4.1.11", - "@vitest/mocker": "4.1.11", - "@vitest/pretty-format": "4.1.11", - "@vitest/runner": "4.1.11", - "@vitest/snapshot": "4.1.11", - "@vitest/spy": "4.1.11", - "@vitest/utils": "4.1.11", + "@vitest/expect": "4.1.12", + "@vitest/mocker": "4.1.12", + "@vitest/pretty-format": "4.1.12", + "@vitest/runner": "4.1.12", + "@vitest/snapshot": "4.1.12", + "@vitest/spy": "4.1.12", + "@vitest/utils": "4.1.12", "es-module-lexer": "^2.0.0", "expect-type": "^1.3.0", "magic-string": "^0.30.21", @@ -5815,12 +5444,12 @@ "@edge-runtime/vm": "*", "@opentelemetry/api": "^1.9.0", "@types/node": "^20.0.0 || ^22.0.0 || >=24.0.0", - "@vitest/browser-playwright": "4.1.11", - "@vitest/browser-preview": "4.1.11", - "@vitest/browser-webdriverio": "4.1.11", - "@vitest/coverage-istanbul": "4.1.11", - "@vitest/coverage-v8": "4.1.11", - "@vitest/ui": "4.1.11", + "@vitest/browser-playwright": "4.1.12", + "@vitest/browser-preview": "4.1.12", + "@vitest/browser-webdriverio": "^4.1.11", + "@vitest/coverage-istanbul": "4.1.12", + "@vitest/coverage-v8": "4.1.12", + "@vitest/ui": "4.1.12", "happy-dom": "*", "jsdom": "*", "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" @@ -5892,19 +5521,6 @@ } } }, - "node_modules/vitest/node_modules/picomatch": { - "version": "4.0.5", - "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.5.tgz", - "integrity": "sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/sponsors/jonschlinkert" - } - }, "node_modules/web-streams-polyfill": { "version": "3.3.3", "resolved": "https://registry.npmjs.org/web-streams-polyfill/-/web-streams-polyfill-3.3.3.tgz", @@ -6080,27 +5696,17 @@ "url": "https://github.com/sponsors/colinhacks" } }, - "node_modules/zod-to-json-schema": { - "version": "3.25.0", - "resolved": "https://registry.npmjs.org/zod-to-json-schema/-/zod-to-json-schema-3.25.0.tgz", - "integrity": "sha512-HvWtU2UG41LALjajJrML6uQejQhNJx+JBO9IflpSja4R03iNWfKXrj6W2h7ljuLyc1nKS+9yDyL/9tD1U/yBnQ==", - "dev": true, - "license": "ISC", - "peerDependencies": { - "zod": "^3.25 || ^4" - } - }, "packages/agent": { "name": "@earendil-works/pi-agent-core", - "version": "1.3.0", + "version": "1.4.0", "license": "MIT", "dependencies": { - "@earendil-works/pi-ai": "1.3.0", + "@earendil-works/pi-ai": "1.4.0", "typebox": "1.3.27" }, "devDependencies": { "@types/node": "22.19.19", - "vitest": "4.1.11" + "vitest": "4.1.12" }, "engines": { "node": ">=22.19.0" @@ -6115,12 +5721,12 @@ }, "packages/ai": { "name": "@earendil-works/pi-ai", - "version": "1.3.0", + "version": "1.4.0", "license": "MIT", "dependencies": { "@anthropic-ai/sdk": "0.129.0", "@aws-sdk/client-bedrock-runtime": "3.1127.0", - "@earendil-works/pi-telemetry": "1.3.0", + "@earendil-works/pi-telemetry": "1.4.0", "@google/genai": "2.21.0", "@smithy/node-http-handler": "4.12.1", "http-proxy-agent": "9.1.0", @@ -6135,7 +5741,7 @@ "devDependencies": { "@types/node": "22.19.19", "canvas": "3.2.3", - "vitest": "4.1.11" + "vitest": "4.1.12" }, "engines": { "node": ">=22.19.0" @@ -6203,16 +5809,16 @@ }, "packages/chord": { "name": "@earendil-works/chord", - "version": "1.3.0", + "version": "1.4.0", "license": "MIT", "dependencies": { - "@earendil-works/pi-telemetry": "1.3.0", + "@earendil-works/pi-telemetry": "1.4.0", "typebox": "1.3.27" }, "devDependencies": { "@types/ws": "8.18.2", "shx": "0.3.4", - "vitest": "4.1.11", + "vitest": "4.1.12", "ws": "8.21.0" }, "engines": { @@ -6221,14 +5827,14 @@ }, "packages/codemode": { "name": "@earendil-works/pi-codemode", - "version": "1.3.0", + "version": "1.4.0", "license": "MIT", "dependencies": { "quickjs-wasi": "3.6.2" }, "devDependencies": { "shx": "0.3.4", - "vitest": "4.1.11" + "vitest": "4.1.12" }, "engines": { "node": ">=22.19.0" @@ -6236,14 +5842,14 @@ }, "packages/coding-agent": { "name": "@earendil-works/pi-coding-agent", - "version": "1.3.0", + "version": "1.4.0", "license": "MIT", "dependencies": { - "@earendil-works/pi-agent-core": "1.3.0", - "@earendil-works/pi-ai": "1.3.0", - "@earendil-works/pi-codemode": "1.3.0", - "@earendil-works/pi-mcp": "1.3.0", - "@earendil-works/pi-tui": "1.3.0", + "@earendil-works/pi-agent-core": "1.4.0", + "@earendil-works/pi-ai": "1.4.0", + "@earendil-works/pi-codemode": "1.4.0", + "@earendil-works/pi-mcp": "1.4.0", + "@earendil-works/pi-tui": "1.4.0", "@silvia-odwyer/photon-node": "0.3.4", "brace-expansion": "5.0.12", "chalk": "6.0.0", @@ -6273,7 +5879,7 @@ "@types/semver": "7.7.1", "marked": "18.0.11", "shx": "0.3.4", - "vitest": "4.1.11" + "vitest": "4.1.12" }, "engines": { "node": ">=22.19.0" @@ -6281,32 +5887,32 @@ }, "packages/coding-agent/examples/extensions/custom-provider-anthropic": { "name": "pi-extension-custom-provider-anthropic", - "version": "1.3.0", + "version": "1.4.0", "dependencies": { "@anthropic-ai/sdk": "0.52.0" } }, "packages/coding-agent/examples/extensions/custom-provider-gitlab-duo": { "name": "pi-extension-custom-provider-gitlab-duo", - "version": "1.3.0" + "version": "1.4.0" }, "packages/coding-agent/examples/extensions/gondolin": { "name": "pi-extension-gondolin", - "version": "1.3.0", + "version": "1.4.0", "dependencies": { - "@earendil-works/gondolin": "0.12.0" + "@earendil-works/gondolin": "0.14.0" } }, "packages/coding-agent/examples/extensions/sandbox": { "name": "pi-extension-sandbox", - "version": "1.3.0", + "version": "1.4.0", "dependencies": { "@anthropic-ai/sandbox-runtime": "0.0.26" } }, "packages/coding-agent/examples/extensions/with-deps": { "name": "pi-extension-with-deps", - "version": "1.3.0", + "version": "1.4.0", "dependencies": { "ms": "2.1.3" }, @@ -6360,13 +5966,13 @@ }, "packages/durable": { "name": "@earendil-works/pi-durable", - "version": "1.3.0", + "version": "1.4.0", "license": "MIT", "dependencies": { - "@earendil-works/chord": "1.3.0", - "@earendil-works/pi-ai": "1.3.0", - "@earendil-works/pi-codemode": "1.3.0", - "@earendil-works/pi-telemetry": "1.3.0", + "@earendil-works/chord": "1.4.0", + "@earendil-works/pi-ai": "1.4.0", + "@earendil-works/pi-codemode": "1.4.0", + "@earendil-works/pi-telemetry": "1.4.0", "@silvia-odwyer/photon": "0.3.3", "diff": "8.0.4", "typebox": "1.3.27" @@ -6375,7 +5981,7 @@ "@cloudflare/vitest-plugin": "1.4.4", "fake-indexeddb": "6.2.6", "shx": "0.3.4", - "vitest": "4.1.11" + "vitest": "4.1.12" }, "engines": { "node": ">=22.19.0" @@ -6383,15 +5989,15 @@ }, "packages/env": { "name": "@earendil-works/pi-env", - "version": "1.3.0", + "version": "1.4.0", "license": "MIT", "dependencies": { - "@earendil-works/chord": "1.3.0", - "@earendil-works/pi-durable": "1.3.0" + "@earendil-works/chord": "1.4.0", + "@earendil-works/pi-durable": "1.4.0" }, "devDependencies": { "shx": "0.3.4", - "vitest": "4.1.11" + "vitest": "4.1.12" }, "engines": { "node": ">=22.19.0" @@ -6399,21 +6005,20 @@ }, "packages/evals": { "name": "@earendil-works/pi-evals", - "version": "1.3.0", + "version": "1.4.0", "devDependencies": { - "@earendil-works/pi-ai": "1.3.0", - "@earendil-works/pi-coding-agent": "1.3.0", + "@earendil-works/pi-ai": "1.4.0", + "@earendil-works/pi-coding-agent": "1.4.0", "@types/node": "22.19.19", "@vitest-evals/core": "0.15.0", - "autoevals": "0.3.0", "shx": "0.3.4", - "vitest": "4.1.11", + "vitest": "4.1.12", "vitest-evals": "0.15.0" } }, "packages/mcp": { "name": "@earendil-works/pi-mcp", - "version": "1.3.0", + "version": "1.4.0", "license": "MIT", "dependencies": { "cross-spawn": "7.0.6" @@ -6421,7 +6026,7 @@ "devDependencies": { "@types/cross-spawn": "6.0.6", "shx": "0.3.4", - "vitest": "4.1.11" + "vitest": "4.1.12" }, "engines": { "node": ">=22.19.0" @@ -6429,11 +6034,11 @@ }, "packages/telemetry": { "name": "@earendil-works/pi-telemetry", - "version": "1.3.0", + "version": "1.4.0", "license": "MIT", "devDependencies": { "@types/node": "22.19.19", - "vitest": "4.1.11" + "vitest": "4.1.12" }, "engines": { "node": ">=22.19.0" @@ -6441,7 +6046,7 @@ }, "packages/tui": { "name": "@earendil-works/pi-tui", - "version": "1.3.0", + "version": "1.4.0", "license": "MIT", "dependencies": { "get-east-asian-width": "1.6.0", diff --git a/packages/agent/CHANGELOG.md b/packages/agent/CHANGELOG.md index 0cf8c6a48..b5367030f 100644 --- a/packages/agent/CHANGELOG.md +++ b/packages/agent/CHANGELOG.md @@ -2,6 +2,8 @@ ## [Unreleased] +## [1.4.0] - 2026-10-21 + ### Added - Added `AgentOptions.identity` and `Agent.identity` (a pi-ai `ClientIdentity`) to set the `User-Agent` and provider attribution of agent loop requests. diff --git a/packages/agent/package.json b/packages/agent/package.json index 30943697d..d1231d95d 100644 --- a/packages/agent/package.json +++ b/packages/agent/package.json @@ -1,6 +1,6 @@ { "name": "@earendil-works/pi-agent-core", - "version": "1.3.0", + "version": "1.4.0", "description": "General-purpose agent with transport abstraction, state management, and attachment support", "type": "module", "main": "./dist/index.js", @@ -23,7 +23,7 @@ "prepublishOnly": "npm run build" }, "dependencies": { - "@earendil-works/pi-ai": "1.3.0", + "@earendil-works/pi-ai": "1.4.0", "typebox": "1.3.27" }, "keywords": [ @@ -45,6 +45,6 @@ }, "devDependencies": { "@types/node": "22.19.19", - "vitest": "4.1.11" + "vitest": "4.1.12" } } diff --git a/packages/ai/CHANGELOG.md b/packages/ai/CHANGELOG.md index c9caf6a17..74aa15c4d 100644 --- a/packages/ai/CHANGELOG.md +++ b/packages/ai/CHANGELOG.md @@ -2,6 +2,12 @@ ## [Unreleased] +### Fixed + +- Fixed Claude models that accept mid-conversation system messages rejecting a session with a 400 (`role 'system' must precede an 'assistant' message`) when a system update was followed by an assistant reply with nothing to send to Anthropic, such as an empty reply or another model's reasoning-only reply ([#11091](https://github.com/earendil-works/pi/issues/11091)) + +## [1.4.0] - 2026-10-21 + ### Breaking Changes - Renamed provider request option `telemetryContext` to `telemetry?: TelemetrySpan`, without a compatibility alias. Pass a parent span, not an adapter. @@ -17,6 +23,9 @@ - Failed model request spans report `provider_error` status with up to 500 error-message characters; cancelled requests keep `ok` status with `stop: "aborted"`. - Added `MiniMax-M3.1-Flash` to the `minimax` and `minimax-cn` providers, with thinking levels low through max sent as `output_config.effort`. Thinking cannot be turned off for this model. New MiniMax models from models.dev now appear without a manual allowlist entry; only the older M2, M2.1, M2.5, and M2.5-highspeed models stay hidden ([#11121](https://github.com/earendil-works/pi/issues/11121)) - Sign in with ChatGPT: ChatGPT Business workspace usage-limit errors are not retried and tell the user to ask their workspace admin to increase the limit. +- Added image input to System One classifiers: `ClassifierContext.images` now works with Jev on `typesafe` (experimental there, opted in with the `TypeSafe-Danger-Zone-Images` header) and `cloudflare-workers-ai`, with Clef on `cloudflare-workers-ai`, and with OpenRouter decision models whose catalog lists image input. Each service gets its own wire form: TypeSafe `assets`, Clef `images` data URLs, or OpenRouter `image_url` parts in the state. +- Added `compat` to System One classifier models (`SystemOneCompat`, with `imageInput` and `sendImageOptInHeader`). Built-in catalog entries set it explicitly; models without it detect the form from the provider, `baseUrl`, and model ID. +- Added the `@cf/cloudflare/clef-omni` and `typesafe/jev-1.14.0` classifier models to `cloudflare-workers-ai`. ### Changed @@ -24,11 +33,15 @@ ### Fixed +- Fixed Cloudflare Workers AI classifier catalog data: Clef Flash now costs $0.038 per million input tokens with a 24,576-token context window, and Jev costs $0.042 per million input tokens instead of being listed as free. - Fixed whitespace-only tool output (for example a bare `\r\n` from bash) being sent as-is to OpenAI Completions, OpenAI Responses, and Google providers; it now uses the same placeholder as empty output, since some providers reject whitespace-only tool content with a 400 ([#8720](https://github.com/earendil-works/pi/issues/8720)) - Fixed automatic retry not triggering for gateway upstream failures such as `upstream_error: Upstream request failed`, `upstream_stream_read_error`, and `Upstream HTTP/2 stream failed` ([#10920](https://github.com/earendil-works/pi/issues/10920)) +- Fixed automatic retry not triggering when the Google Vertex OAuth token refresh fails with a transient network error such as `request to https://oauth2.googleapis.com/token failed, reason:` ([#10996](https://github.com/earendil-works/pi/issues/10996)) - Fixed automatic retry not triggering for `stream disconnected before completion` errors relayed by proxies that wrap the Codex CLI ([#11057](https://github.com/earendil-works/pi/issues/11057)) - Fixed automatic retry treating digits inside request ids or other numbers (for example `req_011CU503xyz` or `5242880 bytes`) as retryable HTTP status codes ([#11057](https://github.com/earendil-works/pi/issues/11057)) - Fixed OpenAI Responses deferred tool loads rewriting the top-level tool list, and losing the prompt cache, on every load after a tool removal. Models with `additional_tools` now declare all tools in `additional_tools` items, starting with one at the head of the input, so removals (announced as text) and redefinitions no longer rewrite the prefix. Tool search and Kimi tool messages rewrite the top-level tools once per removal or redeclaration and keep later loads anchored ([#10940](https://github.com/earendil-works/pi/issues/10940)) +- Fixed GitHub Copilot requests whose body exceeds Copilot's size limit (HTTP 413 `failed to parse request`, for example sessions with many large images) failing instead of being detected as context overflow. Any HTTP 413 now counts as context overflow, so callers can compact and retry ([#11016](https://github.com/earendil-works/pi/pull/11016) by [@petrroll](https://github.com/petrroll)) +- Fixed the `openai-codex` provider listing `gpt-5.5` and `gpt-5.3-codex-spark`, which the Codex backend rejects for ChatGPT accounts. Both models are removed ([#11173](https://github.com/earendil-works/pi/pull/11173) by [@lucasmeijer](https://github.com/lucasmeijer)) ## [1.3.0] - 2026-10-15 @@ -53,7 +66,6 @@ - Fixed OpenCode Go MiniMax M2.7 failing with "Model does not support this protocol" by using the Anthropic Messages API - Fixed automatic retry not triggering for gateway `Unable to forward upstream stream` errors ([#10920](https://github.com/earendil-works/pi/issues/10920)) - Fixed automatic retry not triggering for `The service is temporarily unavailable` errors, which also made compaction fail on the first attempt ([#10965](https://github.com/earendil-works/pi/issues/10965)) -- Fixed automatic retry not triggering when the Google Vertex OAuth token refresh fails with a transient network error such as `request to https://oauth2.googleapis.com/token failed, reason:` ([#10996](https://github.com/earendil-works/pi/issues/10996)) - Fixed OpenAI-compatible responses showing thinking after the answer when a proxy such as LiteLLM sends reasoning and content in the same stream chunk ([#10926](https://github.com/earendil-works/pi/issues/10926)) - Fixed Gemini 4 Argon requests on Google Vertex failing with "Thinking budget is not supported", and its thinking level being ignored on the Gemini API, by sending `thinkingLevel` for Gemini 4 and later models ([#10890](https://github.com/earendil-works/pi/issues/10890)) - Fixed Xiaomi MiMo models failing with a 400 at the `minimal` thinking level by sending only the documented thinking on/off toggle and exposing only the `off` and `high` levels ([#10818](https://github.com/earendil-works/pi/issues/10818)) diff --git a/packages/ai/README.md b/packages/ai/README.md index 88a76366e..61038c7cf 100644 --- a/packages/ai/README.md +++ b/packages/ai/README.md @@ -233,8 +233,12 @@ for (const block of response.content) { } ``` +`usage.cost.totalFromProvider` is `true` when `usage.cost.total` is the billed cost reported by the provider and `false` when it is estimated from the model's catalog rates. A missing value, as in sessions recorded before this field existed, also means `false`. + Snippets in the rest of this README assume a `models` collection set up like this (with the relevant providers registered). +`usage.cost.total` is the best available total cost. For OpenRouter chat responses, it uses the cost reported by OpenRouter, including both the OpenRouter charge and upstream inference cost for BYOK requests. When provider accounting is unavailable or invalid, it falls back to the model's catalog rates. The `input`, `output`, `cacheRead`, and `cacheWrite` cost components remain catalog estimates and may not sum to a provider-reported total. + ## Providers and Models A **provider** is the runtime unit: it owns its model catalog, its auth (API key resolution, OAuth flows), and its stream behavior. A `Models` collection holds providers and routes every request to the provider that owns the model. @@ -895,8 +899,8 @@ Classifier models consume structured JSON state, and optionally images, and answ | Provider | Model IDs | Auth | | --- | --- | --- | | `typesafe` | `jev-latest` | `TYPESAFE_API_KEY` | -| `openrouter` | `typesafe/jev-1.13`, `~typesafe/jev-latest` | `OPENROUTER_API_KEY` or OpenRouter OAuth | -| `cloudflare-workers-ai` | `typesafe/jev`, `@cf/cloudflare/clef`, `@cf/cloudflare/clef-flash` | `CLOUDFLARE_API_KEY` and `CLOUDFLARE_ACCOUNT_ID` | +| `openrouter` | `typesafe/jev-1.13`, `typesafe/jev-1.14`, `~typesafe/jev-latest`, `cloudflare/clef`, `cloudflare/clef-flash`, `cloudflare/clef-omni` | `OPENROUTER_API_KEY` or OpenRouter OAuth | +| `cloudflare-workers-ai` | `typesafe/jev`, `typesafe/jev-1.14.0`, `@cf/cloudflare/clef`, `@cf/cloudflare/clef-flash`, `@cf/cloudflare/clef-omni` | `CLOUDFLARE_API_KEY` and `CLOUDFLARE_ACCOUNT_ID` | | `vercel-ai-gateway` | `typesafe-ai/jev` | `AI_GATEWAY_API_KEY` | | `opencode` | `jev-1.13`, `jev-1.13-free` | `OPENCODE_API_KEY` | | `openai` | `gpt-6-luna` | `OPENAI_API_KEY` | @@ -941,6 +945,18 @@ When the service reports token counts, `result.usage` carries them with their co `ClassifierContext.images` holds image blocks (`{ type: "image", data, mimeType }`) judged together with the state. Only models whose `input` includes `"image"` accept them; `classify()` returns an error result for other models. +### System One images + +System One services take images in three forms. The model's `compat.imageInput` chooses the form (`"assets"`, `"images"`, or `"state-image-urls"`), and `compat.sendImageOptInHeader` controls TypeSafe's opt-in header: + +- **TypeSafe** (`typesafe`, and Jev on Workers AI) takes PNG and JPEG images as `assets`, `{ "": { type: "image", format: "png", data_base64 } }`. Every asset must be referenced from the request, so the state is sent as `["", ..., state]`. Images are experimental on TypeSafe's own API and need the `TypeSafe-Danger-Zone-Images: true` header, which the API sends with requests that carry images. +- **Clef on Workers AI** takes up to four PNG, JPEG, or WebP images as an `images` list of data URLs, next to the unchanged state. +- **OpenRouter** takes images as `{ type: "image_url", image_url: { url } }` parts before the state: `[part, ..., state]`. It answers requests with `assets` but drops the images, without an error. + +Built-in catalog entries set `compat` explicitly, so they keep their form when `baseUrl` points at a proxy. For models without it, the APIs detect the form: OpenRouter by provider `openrouter` or a `baseUrl` containing `openrouter.ai`, Clef by a model ID starting with `@cf/cloudflare/clef`, and TypeSafe's form with the opt-in header otherwise. A custom provider that reaches OpenRouter through a proxy matches neither check and must set `compat.imageInput: "state-image-urls"` and `compat.sendImageOptInHeader: false`; otherwise OpenRouter drops its images. + +Vercel AI Gateway and OpenCode list their Jev models with text input only. + ### OpenAI Decisions The `openai-decisions` API calls OpenAI's [Decisions API](https://developers.openai.com/api/docs/guides/decisions) (`POST /v1/decisions`). GPT-6 Luna is its only model. The state is sent as JSON text; with images, the input is one user message with the state followed by the images as data URLs (at most 128). `choice` and `score` questions map to the Decisions types of the same name. `bool` questions become `predicate` questions; predicates have no criteria field, so the meanings of true and false are appended to the instructions. If OpenAI refuses to answer a question, the whole result is an error. Usage reports input tokens only, priced at the catalog input rate with OpenAI's long-context multiplier. diff --git a/packages/ai/package.json b/packages/ai/package.json index 07f546f97..ac6f865c6 100644 --- a/packages/ai/package.json +++ b/packages/ai/package.json @@ -1,6 +1,6 @@ { "name": "@earendil-works/pi-ai", - "version": "1.3.0", + "version": "1.4.0", "description": "Unified LLM API with automatic model discovery and provider configuration", "type": "module", "main": "./dist/index.js", @@ -69,7 +69,7 @@ "dependencies": { "@anthropic-ai/sdk": "0.129.0", "@aws-sdk/client-bedrock-runtime": "3.1127.0", - "@earendil-works/pi-telemetry": "1.3.0", + "@earendil-works/pi-telemetry": "1.4.0", "@google/genai": "2.21.0", "@smithy/node-http-handler": "4.12.1", "http-proxy-agent": "9.1.0", @@ -101,6 +101,6 @@ "devDependencies": { "@types/node": "22.19.19", "canvas": "3.2.3", - "vitest": "4.1.11" + "vitest": "4.1.12" } } diff --git a/packages/ai/scripts/generate-models.ts b/packages/ai/scripts/generate-models.ts index e6527b4b7..f5efb94bf 100644 --- a/packages/ai/scripts/generate-models.ts +++ b/packages/ai/scripts/generate-models.ts @@ -2631,11 +2631,15 @@ async function loadModelsDevClassifierModels(): Promise[] = [ }, ]; +// Workers AI has no unauthenticated catalog, so its System One models are listed here from +// Cloudflare's model pages. const CLOUDFLARE_WORKERS_AI_CLASSIFIER_MODELS: ClassifierModel<"cloudflare-workers-ai-system-one">[] = [ - // Cloudflare-hosted Clef decision models. They accept images, but classifier - // contexts carry text or JSON state only, so the catalog advertises text. - // Pricing: https://developers.cloudflare.com/workers-ai/models/clef/ - // and https://developers.cloudflare.com/workers-ai/models/clef-flash/ + // Cloudflare-hosted Clef decision models. They take up to four PNG, JPEG, or WebP images as data URLs. + // Clef Omni also takes audio and video, which classifier contexts do not carry. + // Pricing and context: https://developers.cloudflare.com/workers-ai/models/clef/, + // https://developers.cloudflare.com/workers-ai/models/clef-flash/, + // and https://developers.cloudflare.com/workers-ai/models/clef-omni/ { type: "classifier", id: "@cf/cloudflare/clef", @@ -2688,9 +2692,10 @@ const CLOUDFLARE_WORKERS_AI_CLASSIFIER_MODELS: ClassifierModel<"cloudflare-worke api: "cloudflare-workers-ai-system-one", provider: "cloudflare-workers-ai", baseUrl: CLOUDFLARE_WORKERS_AI_REST_BASE_URL, - input: ["text"], + input: ["text", "image"], cost: { input: 0.24, output: 0, cacheRead: 0, cacheWrite: 0 }, contextWindow: 65536, + compat: { imageInput: "images" }, }, { type: "classifier", @@ -2699,10 +2704,26 @@ const CLOUDFLARE_WORKERS_AI_CLASSIFIER_MODELS: ClassifierModel<"cloudflare-worke api: "cloudflare-workers-ai-system-one", provider: "cloudflare-workers-ai", baseUrl: CLOUDFLARE_WORKERS_AI_REST_BASE_URL, - input: ["text"], - cost: { input: 0.09, output: 0, cacheRead: 0, cacheWrite: 0 }, - contextWindow: 65536, + input: ["text", "image"], + cost: { input: 0.038, output: 0, cacheRead: 0, cacheWrite: 0 }, + contextWindow: 24576, + compat: { imageInput: "images" }, }, + { + type: "classifier", + id: "@cf/cloudflare/clef-omni", + name: "Clef Omni", + api: "cloudflare-workers-ai-system-one", + provider: "cloudflare-workers-ai", + baseUrl: CLOUDFLARE_WORKERS_AI_REST_BASE_URL, + input: ["text", "image"], + cost: { input: 0.15, output: 0, cacheRead: 0, cacheWrite: 0 }, + contextWindow: 64000, + compat: { imageInput: "images" }, + }, + // TypeSafe's Jev models take PNG and JPEG images as System One assets. + // https://developers.cloudflare.com/ai/models/typesafe/jev/ (jev-latest) + // and https://developers.cloudflare.com/ai/models/typesafe/jev-1.14.0/ { type: "classifier", id: "typesafe/jev", @@ -2710,9 +2731,22 @@ const CLOUDFLARE_WORKERS_AI_CLASSIFIER_MODELS: ClassifierModel<"cloudflare-worke api: "cloudflare-workers-ai-system-one", provider: "cloudflare-workers-ai", baseUrl: CLOUDFLARE_WORKERS_AI_REST_BASE_URL, - input: ["text"], - cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, + input: ["text", "image"], + cost: { input: 0.042, output: 0, cacheRead: 0, cacheWrite: 0 }, contextWindow: 32000, + compat: { imageInput: "assets" }, + }, + { + type: "classifier", + id: "typesafe/jev-1.14.0", + name: "Jev 1.14", + api: "cloudflare-workers-ai-system-one", + provider: "cloudflare-workers-ai", + baseUrl: CLOUDFLARE_WORKERS_AI_REST_BASE_URL, + input: ["text", "image"], + cost: { input: 0.042, output: 0, cacheRead: 0, cacheWrite: 0 }, + contextWindow: 32000, + compat: { imageInput: "assets" }, }, ]; diff --git a/packages/ai/scripts/openrouter-catalog.ts b/packages/ai/scripts/openrouter-catalog.ts index 0517e98f6..8f01a14e6 100644 --- a/packages/ai/scripts/openrouter-catalog.ts +++ b/packages/ai/scripts/openrouter-catalog.ts @@ -98,6 +98,8 @@ export function buildOpenRouterCatalog( input: input.length > 0 ? input : ["text"], cost: openRouterCost(model.pricing), contextWindow: model.top_provider?.context_length || model.context_length || 4096, + // OpenRouter answers TypeSafe `assets` with 200 but drops the images. + compat: { imageInput: "state-image-urls", sendImageOptInHeader: false }, }); } diff --git a/packages/ai/src/api/anthropic-messages.ts b/packages/ai/src/api/anthropic-messages.ts index 2754794c3..b43b11c0e 100644 --- a/packages/ai/src/api/anthropic-messages.ts +++ b/packages/ai/src/api/anthropic-messages.ts @@ -42,6 +42,11 @@ import { AssistantMessageEventStream } from "../utils/event-stream.ts"; import { headersToRecord } from "../utils/headers.ts"; import { type ClientIdentity, getIdentityHeaders } from "../utils/identity.ts"; import { parseJsonWithRepair, parseStreamingJson, parseToolCallArguments } from "../utils/json-parse.ts"; +import { + applyOpenRouterReportedCost, + isOpenRouterModel, + type OpenRouterUsageAccounting, +} from "../utils/openrouter-cost.ts"; import { getProviderEnvValue } from "../utils/provider-env.ts"; import { retryProviderRequest } from "../utils/provider-retry.ts"; import { sanitizeSurrogates } from "../utils/sanitize-unicode.ts"; @@ -888,6 +893,14 @@ export const stream: StreamFunction<"anthropic-messages", AnthropicOptions> = ( output.usage.totalTokens = output.usage.input + output.usage.output + output.usage.cacheRead + output.usage.cacheWrite; calculateCost(usageModel, output.usage); + if (isOpenRouterModel(model) && event.usage) { + // OpenRouter adds billing fields to streamed message_delta usage at runtime, + // although its OpenAPI schema and the Anthropic SDK do not type those fields. + applyOpenRouterReportedCost( + output.usage, + event.usage as typeof event.usage & OpenRouterUsageAccounting, + ); + } } } @@ -1362,7 +1375,9 @@ function convertMessages( const params: MessageParam[] = []; const assistantLevels = new Map(); // Later system messages are held back and emitted directly before the next assistant - // message (or at the end of the transcript). Anthropic requires `tool_result` blocks to + // message that is sent (or at the end of the transcript). Assistant messages that convert to + // no blocks are dropped, so holding past them keeps an update from landing before a user + // message (#11091). Anthropic requires `tool_result` blocks to // immediately follow their `tool_use`, so a system message between them is rejected; this // also mirrors where the managed-effort system messages are inserted. As a result an // update placed before a user message in the transcript lands after it on the wire. @@ -1429,7 +1444,6 @@ function convertMessages( }); } } else if (msg.role === "assistant") { - flushPendingSystemMessages(); const blocks: ContentBlockParam[] = []; for (const block of msg.content) { @@ -1484,6 +1498,7 @@ function convertMessages( } } if (blocks.length === 0) continue; + flushPendingSystemMessages(); const messageIndex = params.length; params.push({ role: "assistant", diff --git a/packages/ai/src/api/cloudflare-workers-ai-system-one.ts b/packages/ai/src/api/cloudflare-workers-ai-system-one.ts index 1f08d3f5e..ec1aefc4a 100644 --- a/packages/ai/src/api/cloudflare-workers-ai-system-one.ts +++ b/packages/ai/src/api/cloudflare-workers-ai-system-one.ts @@ -23,10 +23,18 @@ function cloudflareErrorMessage(errors: unknown): string { * Cloudflare-hosted models such as `@cf/cloudflare/clef` return the output directly: * `{ success, result: { model, answers, usage } }`. * https://developers.cloudflare.com/workers-ai/models/clef/ + * + * Clef models take images as an `images` list of data URLs. TypeSafe's models take them as + * System One `assets`, without TypeSafe's opt-in header. The catalog sets `compat.imageInput`; + * the model ID check only covers models without it. */ const transport: SystemOneTransport = { api: "cloudflare-workers-ai-system-one", label: LABEL, + detectCompat: (model) => ({ + imageInput: model.id.startsWith("@cf/cloudflare/clef") ? "images" : "assets", + sendImageOptInHeader: false, + }), url: (model) => new URL("run", `${model.baseUrl.replace(/\/+$/u, "")}/`), payload: (model, request) => ({ model: model.id, input: request }), output: (body) => { diff --git a/packages/ai/src/api/openai-completions.ts b/packages/ai/src/api/openai-completions.ts index 083b6dc8f..a752d304e 100644 --- a/packages/ai/src/api/openai-completions.ts +++ b/packages/ai/src/api/openai-completions.ts @@ -41,6 +41,11 @@ import { shortHash } from "../utils/hash.ts"; import { headersToRecord } from "../utils/headers.ts"; import { type ClientIdentity, getIdentityHeaders } from "../utils/identity.ts"; import { parseStreamingJson, parseToolCallArguments } from "../utils/json-parse.ts"; +import { + applyOpenRouterReportedCost, + isOpenRouterModel, + type OpenRouterUsageAccounting, +} from "../utils/openrouter-cost.ts"; import { getProviderEnvValue } from "../utils/provider-env.ts"; import { retryProviderRequest } from "../utils/provider-retry.ts"; import { sanitizeSurrogates } from "../utils/sanitize-unicode.ts"; @@ -1527,6 +1532,8 @@ function convertTools( } function parseChunkUsage( + // OpenRouter adds billing fields to streamed usage at runtime, + // although the OpenAI SDK does not type those fields. rawUsage: { prompt_tokens?: number; completion_tokens?: number; @@ -1566,6 +1573,9 @@ function parseChunkUsage( cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 }, }; calculateCost(model, usage); + if (isOpenRouterModel(model)) { + applyOpenRouterReportedCost(usage, rawUsage as typeof rawUsage & OpenRouterUsageAccounting); + } return usage; } diff --git a/packages/ai/src/api/openrouter-images.ts b/packages/ai/src/api/openrouter-images.ts index be9c37007..6054c7238 100644 --- a/packages/ai/src/api/openrouter-images.ts +++ b/packages/ai/src/api/openrouter-images.ts @@ -193,6 +193,7 @@ function parseUsage( cacheRead: (model.cost.cacheRead / 1000000) * cacheReadTokens, cacheWrite: (model.cost.cacheWrite / 1000000) * cacheWriteTokens, total: 0, + totalFromProvider: false, }, }; usage.cost.total = usage.cost.input + usage.cost.output + usage.cost.cacheRead + usage.cost.cacheWrite; diff --git a/packages/ai/src/api/system-one-shared.ts b/packages/ai/src/api/system-one-shared.ts index 6f15f044f..96abf494c 100644 --- a/packages/ai/src/api/system-one-shared.ts +++ b/packages/ai/src/api/system-one-shared.ts @@ -5,22 +5,62 @@ import type { ClassifierModel, ClassifierOptions, ClassifierResult, + ImageContent, + SystemOneCompat, } from "../types.ts"; import { formatProviderError, normalizeProviderError } from "../utils/error-body.ts"; import { isRecord, parseClassifierUsage, postClassifierRequest, requiredNumber } from "./classifier-shared.ts"; +/** A System One image asset: base64 file bytes, referenced by its name from the state. */ +interface SystemOneImageAsset { + type: "image"; + format: "png" | "jpeg"; + data_base64: string; +} + +/** An image in the state, OpenRouter's form. */ +interface SystemOneImageUrlPart { + type: "image_url"; + image_url: { url: string }; +} + /** TypeSafe System One request body without the transport-specific envelope. */ export interface SystemOneWireRequest { - state: ClassifierContext["state"]; + /** The context state, or with images a list of the images or their asset names followed by the state. */ + state: unknown; questions: Record; + /** Images as System One assets, keyed by the names the state references. */ + assets?: Record; + /** Images as data URLs, Clef's extension to System One. */ + images?: string[]; } +/** + * How a service takes images (`SystemOneCompat.imageInput`): + * - `assets`: TypeSafe's `assets` map, `{ "": { type: "image", format, data_base64 } }`, + * with the names placed before the state: `state: ["", state]`. PNG and JPEG only. + * - `images`: Clef's `images` list of data URLs, which the model reads before the state. + * PNG, JPEG, and WebP, at most four. + * - `state-image-urls`: OpenRouter's form, data URL parts placed before the state: + * `state: [{ type: "image_url", image_url: { url } }, state]`. OpenRouter accepts this for every + * model, and drops `assets` silently. + */ +export type SystemOneImageInput = NonNullable; + +/** System One compatibility settings with every field resolved. */ +export type ResolvedSystemOneCompat = Required; + +/** TypeSafe's opt-in header for its experimental image input. */ +const IMAGE_OPT_IN_HEADERS = { "TypeSafe-Danger-Zone-Images": "true" }; + /** Differences between services that serve System One models. */ export interface SystemOneTransport { /** Classifier API implemented by this transport. */ api: ClassifierApi; /** Service name used in error messages. */ label: string; + /** Compatibility settings for models that do not set them in `compat`. */ + detectCompat(model: ClassifierModel): ResolvedSystemOneCompat; /** Absolute request URL. */ url(model: ClassifierModel): URL; /** Wraps the System One request in the service's request envelope. */ @@ -82,6 +122,56 @@ function parseAnswers(label: string, value: unknown, context: ClassifierContext) return Object.fromEntries(answers); } +const ASSET_FORMATS: Record = { "image/png": "png", "image/jpeg": "jpeg" }; +const DATA_URL_TYPES = new Set(["image/png", "image/jpeg", "image/webp"]); +/** Clef accepts at most this many images per request. */ +const MAX_DATA_URL_IMAGES = 4; + +/** Adds the context images to `request` in the service's form. */ +function addImages( + label: string, + model: ClassifierModel, + input: SystemOneImageInput, + images: ImageContent[], + request: SystemOneWireRequest, +): void { + if (input === "images") { + if (images.length > MAX_DATA_URL_IMAGES) { + throw new Error( + `${label} accepts at most ${MAX_DATA_URL_IMAGES} images for ${model.id}, got ${images.length}`, + ); + } + request.images = images.map((image) => { + if (!DATA_URL_TYPES.has(image.mimeType)) { + throw new Error(`${label} accepts PNG, JPEG, and WebP images for ${model.id}, got ${image.mimeType}`); + } + return `data:${image.mimeType};base64,${image.data}`; + }); + return; + } + if (input === "state-image-urls") { + // OpenRouter validates the images against the model that serves the request. + const parts = images.map( + (image): SystemOneImageUrlPart => ({ + type: "image_url", + image_url: { url: `data:${image.mimeType};base64,${image.data}` }, + }), + ); + request.state = [...parts, request.state]; + return; + } + const assets: Record = {}; + images.forEach((image, index) => { + const format = ASSET_FORMATS[image.mimeType]; + if (!format) throw new Error(`${label} accepts PNG and JPEG images for ${model.id}, got ${image.mimeType}`); + assets[``] = { type: "image", format, data_base64: image.data }; + }); + request.assets = assets; + // Every asset must be referenced from the request. The names go before the state, where Clef + // also places its images. + request.state = [...Object.keys(assets), request.state]; +} + /** Maps public `bool` questions to TypeSafe's wire-level `noul` type. */ function wireRequest(context: ClassifierContext): SystemOneWireRequest { return { @@ -113,13 +203,25 @@ export async function classifySystemOne( try { if (model.api !== transport.api) throw new Error(`Unsupported classifier API: ${model.api}`); - if (context.images?.length) throw new Error(`${transport.label} does not support image input`); + const request = wireRequest(context); + let requestOptions = options; + if (context.images?.length) { + if (!model.input.includes("image")) { + throw new Error(`${transport.label} does not support image input for ${model.id}`); + } + const detected = transport.detectCompat(model); + const imageInput = model.compat?.imageInput ?? detected.imageInput; + addImages(transport.label, model, imageInput, context.images, request); + if (model.compat?.sendImageOptInHeader ?? detected.sendImageOptInHeader) { + requestOptions = { ...options, headers: { ...IMAGE_OPT_IN_HEADERS, ...options?.headers } }; + } + } const body = await postClassifierRequest( transport.label, transport.url(model), model, - transport.payload(model, wireRequest(context)), - options, + transport.payload(model, request), + requestOptions, ); const result = transport.output(body); // Set before parsing answers: a request with malformed answers was still billed. diff --git a/packages/ai/src/api/typesafe-system-one.ts b/packages/ai/src/api/typesafe-system-one.ts index 542fc476b..e9fa13c28 100644 --- a/packages/ai/src/api/typesafe-system-one.ts +++ b/packages/ai/src/api/typesafe-system-one.ts @@ -5,10 +5,21 @@ import { classifySystemOne, type SystemOneTransport } from "./system-one-shared. /** * TypeSafe's native System One protocol. OpenRouter serves the same protocol, * so both providers use this API with different base URLs. + * + * TypeSafe takes images as `assets`. They are experimental and need the + * `TypeSafe-Danger-Zone-Images` opt-in header; without it the API answers 400. + * OpenRouter drops `assets` silently and takes images as `image_url` parts in the state instead. + * Models behind a proxy that hides both the provider ID and the OpenRouter host must set + * `compat.imageInput`. */ const transport: SystemOneTransport = { api: "typesafe-system-one", label: "System One API", + // OpenRouter by provider ID, which survives baseUrl overrides, or by host, which covers regional endpoints. + detectCompat: (model) => + model.provider === "openrouter" || model.baseUrl.includes("openrouter.ai") + ? { imageInput: "state-image-urls", sendImageOptInHeader: false } + : { imageInput: "assets", sendImageOptInHeader: true }, url: (model) => new URL("systemone", `${model.baseUrl.replace(/\/+$/u, "")}/`), payload: (model, request) => ({ model: model.id, ...request }), output: (body) => { diff --git a/packages/ai/src/auth/types.ts b/packages/ai/src/auth/types.ts index 60fe4ff85..86a926809 100644 --- a/packages/ai/src/auth/types.ts +++ b/packages/ai/src/auth/types.ts @@ -141,6 +141,10 @@ export type AuthEvent = type: "device_code"; userCode: string; verificationUri: string; + /** Offer best-effort browser opening on user confirmation. Defaults to true; clients may ignore it. */ + openBrowserOnConfirm?: boolean; + /** Offer copying of userCode on user confirmation, replacing the clipboard. Defaults to true. */ + copyCodeOnConfirm?: boolean; intervalSeconds?: number; expiresInSeconds?: number; } diff --git a/packages/ai/src/compat/extension-oauth-types.ts b/packages/ai/src/compat/extension-oauth-types.ts index f1bf4e5d6..f11a98932 100644 --- a/packages/ai/src/compat/extension-oauth-types.ts +++ b/packages/ai/src/compat/extension-oauth-types.ts @@ -17,6 +17,10 @@ export interface OAuthAuthInfo { export interface OAuthDeviceCodeInfo { userCode: string; verificationUri: string; + /** Offer best-effort browser opening on user confirmation. Defaults to true; clients may ignore it. */ + openBrowserOnConfirm?: boolean; + /** Offer copying of userCode on user confirmation, replacing the clipboard. Defaults to true. */ + copyCodeOnConfirm?: boolean; intervalSeconds?: number; expiresInSeconds?: number; } diff --git a/packages/ai/src/models.ts b/packages/ai/src/models.ts index ac0396f90..3d26dcb5f 100644 --- a/packages/ai/src/models.ts +++ b/packages/ai/src/models.ts @@ -1252,6 +1252,7 @@ export function calculateCost(model: AnyModel, usage: Usage): Usage["cost"] { usage.cost.cacheRead = (rates.cacheRead / 1000000) * usage.cacheRead; usage.cost.cacheWrite = (rates.cacheWrite * shortWrite + rates.input * 2 * longWrite) / 1000000; usage.cost.total = usage.cost.input + usage.cost.output + usage.cost.cacheRead + usage.cost.cacheWrite; + usage.cost.totalFromProvider = false; return usage.cost; } diff --git a/packages/ai/src/providers/compat-schema.ts b/packages/ai/src/providers/compat-schema.ts index ca54cc054..b3ba8198e 100644 --- a/packages/ai/src/providers/compat-schema.ts +++ b/packages/ai/src/providers/compat-schema.ts @@ -521,6 +521,22 @@ export const MistralConversationsCompatSchema = Type.Object( { description: "Compatibility settings for the Mistral chat API.", additionalProperties: true }, ); +export const SystemOneCompatSchema = Type.Object( + { + imageInput: Type.Optional( + Type.Union([Type.Literal("assets"), Type.Literal("images"), Type.Literal("state-image-urls")], { + description: + 'How the service takes classifier images. "assets" sends TypeSafe\'s assets map (PNG and JPEG) and references the images from the state, as TypeSafe and Jev on Workers AI expect. "images" sends Clef\'s images list of data URLs (PNG, JPEG, and WebP, at most four), as Clef on Workers AI expects. "state-image-urls" places image_url parts before the state, as OpenRouter expects; OpenRouter drops assets without an error. Auto-detected from the provider, baseUrl, and model ID. Classifier models read it from the provider-level compat; models and modelOverrides entries apply to chat models only.', + }), + ), + sendImageOptInHeader: optionalCompatBoolean({ + description: + "Whether requests with images send the TypeSafe-Danger-Zone-Images: true header, which TypeSafe's own API requires for its experimental image input. Defaults to true for the typesafe-system-one API unless the model is detected as OpenRouter.", + }), + }, + { description: "Compatibility settings for System One classifier APIs.", additionalProperties: true }, +); + const ProviderCompatPropertyOverrides = { supportsDeveloperRole: optionalCompatBoolean({ description: "Whether the provider supports the developer role instead of system. Defaults are API-specific.", @@ -609,6 +625,8 @@ export const CompatSchemasByApi = { "anthropic-messages": AnthropicMessagesCompatSchema, "bedrock-converse-stream": BedrockCompatSchema, "mistral-conversations": MistralConversationsCompatSchema, + "typesafe-system-one": SystemOneCompatSchema, + "cloudflare-workers-ai-system-one": SystemOneCompatSchema, } as const; const ProviderCompatProperties = mergeCompatProperties( @@ -642,3 +660,5 @@ export interface AnthropicMessagesCompat extends Static {} /** Compatibility settings for the Mistral chat API. */ export interface MistralConversationsCompat extends Static {} +/** Compatibility settings for System One classifier APIs. */ +export interface SystemOneCompat extends Static {} diff --git a/packages/ai/src/types.ts b/packages/ai/src/types.ts index a04b9d212..d1fcee314 100644 --- a/packages/ai/src/types.ts +++ b/packages/ai/src/types.ts @@ -15,6 +15,7 @@ import type { MistralConversationsCompat, OpenAICompletionsCompat, OpenAIResponsesCompat, + SystemOneCompat, } from "./providers/compat-schema.ts"; import type { CacheRetention, @@ -40,6 +41,7 @@ export type { OpenAIResponsesCompat, OpenRouterRouting, SessionAffinityFormat, + SystemOneCompat, ThinkingTokenBudgetField, VercelGatewayRouting, } from "./providers/compat-schema.ts"; @@ -469,11 +471,18 @@ export interface Usage { reasoning?: number; totalTokens: number; cost: { + /** Catalog-derived input cost. */ input: number; + /** Catalog-derived output cost. */ output: number; + /** Catalog-derived cache-read cost. */ cacheRead: number; + /** Catalog-derived cache-write cost. */ cacheWrite: number; + /** Best available total: provider-reported billed cost when supported, otherwise the catalog estimate. */ total: number; + /** True when `total` is the billed cost reported by the provider. False when it is a catalog estimate; absent also means false. */ + totalFromProvider?: boolean; }; } @@ -888,6 +897,8 @@ export interface ImageModel extends BaseModel { export interface ClassifierModel extends BaseModel { type: "classifier"; contextWindow: number; + /** Compatibility overrides for System One APIs. If not set, auto-detected from provider, baseUrl, and id. */ + compat?: TApi extends "typesafe-system-one" | "cloudflare-workers-ai-system-one" ? SystemOneCompat : never; } /** Model shape for each model type. */ diff --git a/packages/ai/src/utils/openrouter-cost.ts b/packages/ai/src/utils/openrouter-cost.ts new file mode 100644 index 000000000..0eb12613e --- /dev/null +++ b/packages/ai/src/utils/openrouter-cost.ts @@ -0,0 +1,55 @@ +import type { Usage } from "../types.ts"; + +export interface OpenRouterUsageAccounting { + /** Amount charged to the user's OpenRouter account. */ + cost: number | null; + /** Whether OpenRouter used the user's upstream provider key. */ + is_byok: boolean; + cost_details: { + /** Total inference cost billed through the user's upstream provider account, when available. */ + upstream_inference_cost: number | null; + /** Upstream cost for prompt processing, including any provider-side cache pricing. */ + upstream_inference_prompt_cost: number; + /** Upstream cost for completion generation. */ + upstream_inference_completions_cost: number; + /** Metered server-tool cost already included in OpenRouter's charged `cost`. */ + server_tool_cost?: number | null; + } | null; +} + +function isValidCost(value: unknown): value is number { + return typeof value === "number" && Number.isFinite(value) && value >= 0; +} + +export function isOpenRouterModel(model: { provider: string; baseUrl: string }): boolean { + return model.provider === "openrouter" || model.baseUrl.includes("openrouter.ai"); +} + +/** Replace the catalog total with OpenRouter's billed cost when the response contains complete, valid accounting. */ +export function applyOpenRouterReportedCost(usage: Usage, reported: OpenRouterUsageAccounting): void { + if (!isValidCost(reported.cost)) return; + + if (reported.is_byok === false) { + usage.cost.total = reported.cost; + usage.cost.totalFromProvider = true; + return; + } + + if (reported.is_byok === true) { + const details = reported.cost_details; + if (!details) return; + let upstreamCost: unknown = details.upstream_inference_cost; + if (upstreamCost === null) { + if ( + !isValidCost(details.upstream_inference_prompt_cost) || + !isValidCost(details.upstream_inference_completions_cost) + ) { + return; + } + upstreamCost = details.upstream_inference_prompt_cost + details.upstream_inference_completions_cost; + } + if (!isValidCost(upstreamCost)) return; + usage.cost.total = reported.cost + upstreamCost; + usage.cost.totalFromProvider = true; + } +} diff --git a/packages/ai/src/utils/version.ts b/packages/ai/src/utils/version.ts index e2741c484..18fe5a582 100644 --- a/packages/ai/src/utils/version.ts +++ b/packages/ai/src/utils/version.ts @@ -1,2 +1,2 @@ // The @earendil-works/pi-ai package version. scripts/sync-versions.js keeps it in sync with package.json. -export const PI_AI_VERSION = "1.3.0"; +export const PI_AI_VERSION = "1.4.0"; diff --git a/packages/ai/test/anthropic-sse-parsing.test.ts b/packages/ai/test/anthropic-sse-parsing.test.ts index 66f4b3192..9f65f831a 100644 --- a/packages/ai/test/anthropic-sse-parsing.test.ts +++ b/packages/ai/test/anthropic-sse-parsing.test.ts @@ -139,6 +139,42 @@ describe("Anthropic raw SSE parsing", () => { expect(eventModels).toEqual([model, model, model, model, model, model]); }); + // Regression test for #9980. + it("uses the total reported by OpenRouter's Anthropic Messages endpoint", async () => { + const events = minimalAnthropicEvents.map((event) => ({ ...event })); + const delta = JSON.parse(events[4].data) as { + usage: Record; + }; + // These fields were captured from a production OpenRouter stream even though its + // MessagesDeltaEvent OpenAPI schema and the Anthropic SDK omit them. + delta.usage = { + ...delta.usage, + cost: 0.004, + is_byok: false, + cost_details: { upstream_inference_cost: 0.003 }, + }; + events[4].data = JSON.stringify(delta); + + const model = { + ...getModel("openrouter", "anthropic/claude-haiku-4.5"), + provider: "openrouter-work", + }; + const result = await streamAnthropic( + model, + normalizeContext({ messages: [{ role: "user", content: "Hello", timestamp: 1 }] }), + { client: createFakeAnthropicClient(createSseResponse(events)) }, + ).result(); + + expect(result.usage.cost.total).toBe(0.004); + expect(result.usage.cost.totalFromProvider).toBe(true); + expect( + result.usage.cost.input + + result.usage.cost.output + + result.usage.cost.cacheRead + + result.usage.cost.cacheWrite, + ).not.toBe(result.usage.cost.total); + }); + it("keeps signed thinking replayable when a proxy relabels the model", async () => { // Regression test for earendil-works/pi#9188. const model = getModel("anthropic", "claude-opus-5"); diff --git a/packages/ai/test/classifier-images-live.test.ts b/packages/ai/test/classifier-images-live.test.ts new file mode 100644 index 000000000..b806e9fa4 --- /dev/null +++ b/packages/ai/test/classifier-images-live.test.ts @@ -0,0 +1,86 @@ +/** + * Live image classification through System One services. Each provider's block runs only when its + * credentials are set. Each case sends a solid red and a solid blue PNG in separate requests, so a + * service that drops the image cannot pass by chance. + */ +import { describe, expect, it } from "vitest"; +import { createModels, type ModelsClassifierOptions } from "../src/models.ts"; +import { cloudflareWorkersAIProvider } from "../src/providers/cloudflare-workers-ai.ts"; +import { openrouterProvider } from "../src/providers/openrouter.ts"; +import { typesafeProvider } from "../src/providers/typesafe.ts"; +import type { ClassifierContext } from "../src/types.ts"; +import { hasCloudflareWorkersAICredentials } from "./cloudflare-utils.ts"; + +// 512x512 solid PNGs from the TypeSafe SDK's live image tests. +const RED = + "iVBORw0KGgoAAAANSUhEUgAAAgAAAAIAAQMAAADOtka5AAAABlBMVEX/AAAAAABBoxIDAAAANklEQVR42u3BAQEAAACCIP+vbkhAAQAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAB8G4IAAAFjdVCkAAAAAElFTkSuQmCC"; +const BLUE = + "iVBORw0KGgoAAAANSUhEUgAAAgAAAAIAAQMAAADOtka5AAAABlBMVEUAAP8AAAB7Yr8+AAAANklEQVR42u3BAQEAAACCIP+vbkhAAQAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAB8G4IAAAFjdVCkAAAAAElFTkSuQmCC"; + +function colorContext(data: string): ClassifierContext { + return { + state: { task: "Name the color of the attached image" }, + images: [{ type: "image", data, mimeType: "image/png" }], + questions: { + color: { + type: "choice", + instructions: "What color is the attached image?", + criteria: { red: "Red", blue: "Blue", green: "Green" }, + }, + }, + }; +} + +const models = createModels(); +models.setProvider(typesafeProvider()); +models.setProvider(cloudflareWorkersAIProvider()); +models.setProvider(openrouterProvider()); + +async function expectColors(provider: string, id: string, options: ModelsClassifierOptions): Promise { + const model = models.getModelOfType("classifier", provider, id); + if (!model) throw new Error(`missing ${provider}/${id}`); + expect(model.input).toContain("image"); + for (const [data, color] of [ + [RED, "red"], + [BLUE, "blue"], + ] as const) { + const result = await models.classify(model, colorContext(data), options); + expect(result.errorMessage).toBeUndefined(); + expect(result.answers.color).toMatchObject({ type: "choice", choice: color }); + } +} + +describe.skipIf(!process.env.TYPESAFE_API_KEY)("TypeSafe image classification", () => { + it("classifies images with jev-latest", async () => { + await expectColors("typesafe", "jev-latest", { apiKey: process.env.TYPESAFE_API_KEY }); + }, 60_000); +}); + +describe.skipIf(!hasCloudflareWorkersAICredentials())("Cloudflare Workers AI image classification", () => { + it.each([ + "@cf/cloudflare/clef", + "@cf/cloudflare/clef-flash", + "@cf/cloudflare/clef-omni", + "typesafe/jev", + "typesafe/jev-1.14.0", + ])( + "classifies images with %s", + async (id) => { + await expectColors("cloudflare-workers-ai", id, { + apiKey: process.env.CLOUDFLARE_API_KEY, + env: { CLOUDFLARE_ACCOUNT_ID: process.env.CLOUDFLARE_ACCOUNT_ID! }, + }); + }, + 60_000, + ); +}); + +describe.skipIf(!process.env.OPENROUTER_API_KEY)("OpenRouter image classification", () => { + it.each(["typesafe/jev-1.14", "cloudflare/clef-flash"])( + "classifies images with %s", + async (id) => { + await expectColors("openrouter", id, { apiKey: process.env.OPENROUTER_API_KEY }); + }, + 60_000, + ); +}); diff --git a/packages/ai/test/classifier-models.test.ts b/packages/ai/test/classifier-models.test.ts index 5f0cb2cd5..4a5592501 100644 --- a/packages/ai/test/classifier-models.test.ts +++ b/packages/ai/test/classifier-models.test.ts @@ -248,7 +248,12 @@ describe("Models with classifier models", () => { it("routes OpenRouter classifier models through the System One API", () => { const models = builtinModels(); for (const model of getBuiltinClassifierModels("openrouter")) { - expect(model).toMatchObject({ api: "typesafe-system-one", baseUrl: "https://openrouter.ai/api/v1" }); + expect(model).toMatchObject({ + api: "typesafe-system-one", + baseUrl: "https://openrouter.ai/api/v1", + // Explicit, so images keep OpenRouter's form when the baseUrl is overridden. + compat: { imageInput: "state-image-urls", sendImageOptInHeader: false }, + }); expect(models.getModel("openrouter", model.id)).toBeUndefined(); expect(models.getModelOfType("classifier", "openrouter", model.id)).toEqual(model); } diff --git a/packages/ai/test/cloudflare-workers-ai-system-one.test.ts b/packages/ai/test/cloudflare-workers-ai-system-one.test.ts index d7ec82e49..eb338c083 100644 --- a/packages/ai/test/cloudflare-workers-ai-system-one.test.ts +++ b/packages/ai/test/cloudflare-workers-ai-system-one.test.ts @@ -105,7 +105,8 @@ describe("Cloudflare Workers AI System One", () => { it.each([ ["@cf/cloudflare/clef", 0.24], - ["@cf/cloudflare/clef-flash", 0.09], + ["@cf/cloudflare/clef-flash", 0.038], + ["@cf/cloudflare/clef-omni", 0.15], ])("runs %s through /ai/run and parses its direct output", async (id, inputPrice) => { const { models } = setup(); const clef = models.getModelOfType("classifier", "cloudflare-workers-ai", id); @@ -131,6 +132,104 @@ describe("Cloudflare Workers AI System One", () => { expect(result.usage?.cost.input).toBeCloseTo((222 * inputPrice) / 1_000_000); }); + it("sends images to Clef as data URLs", async () => { + const { models } = setup(); + const clef = models.getModelOfType("classifier", "cloudflare-workers-ai", "@cf/cloudflare/clef"); + if (!clef) throw new Error("missing Cloudflare Clef model"); + const bodies: Array<{ model: string; input: Record }> = []; + const fetch = vi.fn(async (_input: string | URL | Request, init?: RequestInit) => { + bodies.push(JSON.parse(String(init?.body)) as { model: string; input: Record }); + return Response.json({ result: clefOutput, success: true, errors: [], messages: [] }); + }); + + const result = await models.classify( + clef, + { + ...context, + images: [ + { type: "image", data: "cG5n", mimeType: "image/png" }, + { type: "image", data: "d2VicA==", mimeType: "image/webp" }, + ], + }, + { ...auth, fetch }, + ); + + expect(result.stopReason).toBe("stop"); + // Clef rejects `assets` with a 422, so the state stays as is. + expect(bodies[0]?.input).toEqual({ + state: context.state, + questions: expect.any(Object), + images: ["data:image/png;base64,cG5n", "data:image/webp;base64,d2VicA=="], + }); + expect(new Headers(fetch.mock.calls[0]?.[1]?.headers).has("typesafe-danger-zone-images")).toBe(false); + }); + + it("reads the image form from compat before the model ID", async () => { + const { models, jev } = setup(); + const clef = models.getModelOfType("classifier", "cloudflare-workers-ai", "@cf/cloudflare/clef"); + expect(clef?.compat).toEqual({ imageInput: "images" }); + expect(jev.compat).toEqual({ imageInput: "assets" }); + const bodies: Array<{ input: Record }> = []; + const fetch = vi.fn(async (_input: string | URL | Request, init?: RequestInit) => { + bodies.push(JSON.parse(String(init?.body)) as { input: Record }); + return Response.json({ result: clefOutput, success: true, errors: [], messages: [] }); + }); + + // A Clef deployment under an ID the prefix check does not recognize. + await models.classify( + { ...jev, id: "@cf/acme/clef-finetune", input: ["text", "image"], compat: { imageInput: "images" } }, + { ...context, images: [{ type: "image", data: "cG5n", mimeType: "image/png" }] }, + { ...auth, fetch }, + ); + + expect(bodies[0]?.input.images).toEqual(["data:image/png;base64,cG5n"]); + expect(bodies[0]?.input).not.toHaveProperty("assets"); + }); + + it("rejects more than four images for Clef before sending", async () => { + const { models } = setup(); + const clef = models.getModelOfType("classifier", "cloudflare-workers-ai", "@cf/cloudflare/clef-flash"); + if (!clef) throw new Error("missing Cloudflare Clef Flash model"); + const fetch = vi.fn(async () => Response.json({ result: clefOutput, success: true })); + const image = { type: "image" as const, data: "cG5n", mimeType: "image/png" }; + + const result = await models.classify(clef, { ...context, images: Array(5).fill(image) }, { ...auth, fetch }); + + expect(fetch).not.toHaveBeenCalled(); + expect(result.stopReason).toBe("error"); + expect(result.errorMessage).toBe( + "Cloudflare Workers AI accepts at most 4 images for @cf/cloudflare/clef-flash, got 5", + ); + }); + + it.each(["typesafe/jev", "typesafe/jev-1.14.0"])("sends images to %s as System One assets", async (id) => { + const { models } = setup(); + const jev = models.getModelOfType("classifier", "cloudflare-workers-ai", id); + if (!jev) throw new Error(`missing Cloudflare ${id} model`); + const bodies: Array<{ model: string; input: Record }> = []; + const fetch = vi.fn(async (_input: string | URL | Request, init?: RequestInit) => { + bodies.push(JSON.parse(String(init?.body)) as { model: string; input: Record }); + return Response.json(restResponse("Completed")); + }); + + const result = await models.classify( + jev, + { ...context, images: [{ type: "image", data: "cG5n", mimeType: "image/png" }] }, + { ...auth, fetch }, + ); + + expect(result.stopReason).toBe("stop"); + expect(bodies[0]).toMatchObject({ + model: id, + input: { + state: ["", context.state], + assets: { "": { type: "image", format: "png", data_base64: "cG5n" } }, + }, + }); + // Workers AI needs no TypeSafe opt-in header. + expect(new Headers(fetch.mock.calls[0]?.[1]?.headers).has("typesafe-danger-zone-images")).toBe(false); + }); + it("reports runs that did not complete", async () => { const { models, jev } = setup(); const result = await models.classify(jev, context, { diff --git a/packages/ai/test/image-model-data.test.ts b/packages/ai/test/image-model-data.test.ts index 9b40486bb..21545fe8f 100644 --- a/packages/ai/test/image-model-data.test.ts +++ b/packages/ai/test/image-model-data.test.ts @@ -93,6 +93,7 @@ describe("OpenRouter catalog parsing", () => { input: ["text"], cost: { input: 0.042, output: 0, cacheRead: 0, cacheWrite: 0 }, contextWindow: 32000, + compat: { imageInput: "state-image-urls", sendImageOptInHeader: false }, }, ]); }); diff --git a/packages/ai/test/models-runtime.test.ts b/packages/ai/test/models-runtime.test.ts index 0789f940c..fab8d11f4 100644 --- a/packages/ai/test/models-runtime.test.ts +++ b/packages/ai/test/models-runtime.test.ts @@ -159,6 +159,19 @@ describe("Models runtime", () => { expect(long.cacheWrite).toBe(0.0000125); }); + it("marks catalog-derived totals as not provider-reported", () => { + const usage: Usage = { + input: 1_000_000, + output: 0, + cacheRead: 0, + cacheWrite: 0, + totalTokens: 1_000_000, + cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 9, totalFromProvider: true }, + }; + calculateCost(testModel("openai", "gpt-5.6-sol"), usage); + expect(usage.cost.totalFromProvider).toBe(false); + }); + it("registers, replaces, and deletes providers", () => { const models = createModels(); models.setProvider(testProvider({ id: "p1" })); diff --git a/packages/ai/test/openai-completions-provider-stream-event.test.ts b/packages/ai/test/openai-completions-provider-stream-event.test.ts index b025599d3..bc7415f4a 100644 --- a/packages/ai/test/openai-completions-provider-stream-event.test.ts +++ b/packages/ai/test/openai-completions-provider-stream-event.test.ts @@ -34,12 +34,12 @@ vi.mock("openai", () => { return { default: FakeOpenAI }; }); -function openRouterModel(): Model<"openai-completions"> { +function openRouterModel(provider = "openrouter"): Model<"openai-completions"> { return { id: "openrouter/auto", name: "OpenRouter Auto", api: "openai-completions", - provider: "openrouter", + provider, baseUrl: "https://openrouter.ai/api/v1", reasoning: false, input: ["text"], @@ -91,4 +91,65 @@ describe("openai-completions provider stream events", () => { expect(message.content).toEqual([{ type: "text", text: "hello" }]); expect(events).toEqual([firstChunk, finalChunk]); }); + + // Regression test for #9980. + it("uses OpenRouter's reported total without double-counting non-BYOK upstream cost", async () => { + mockState.chunks = [ + { + id: "chatcmpl-cost", + model: "openrouter/auto", + choices: [{ index: 0, delta: { content: "done" }, finish_reason: "stop" }], + // These billing fields were captured from a production OpenRouter stream + // even though the OpenAI SDK omits them from CompletionUsage. + usage: { + prompt_tokens: 100, + completion_tokens: 20, + cost: 0.004, + is_byok: false, + cost_details: { upstream_inference_cost: 0.003 }, + }, + }, + ]; + + const message = await completeSimple( + openRouterModel("openrouter-work"), + { messages: [{ role: "user", content: "hi", timestamp: Date.now() }] }, + { apiKey: "test" }, + ); + + expect(message.usage.cost).toEqual({ + input: 0, + output: 0, + cacheRead: 0, + cacheWrite: 0, + total: 0.004, + totalFromProvider: true, + }); + }); + + it("includes upstream inference cost for OpenRouter BYOK requests", async () => { + mockState.chunks = [ + { + id: "chatcmpl-byok", + model: "openrouter/auto", + choices: [{ index: 0, delta: { content: "done" }, finish_reason: "stop" }], + usage: { + prompt_tokens: 100, + completion_tokens: 20, + cost: 0.0002, + is_byok: true, + cost_details: { upstream_inference_cost: 0.003 }, + }, + }, + ]; + + const message = await completeSimple( + openRouterModel(), + { messages: [{ role: "user", content: "hi", timestamp: Date.now() }] }, + { apiKey: "test" }, + ); + + expect(message.usage.cost.total).toBeCloseTo(0.0032, 10); + expect(message.usage.cost.totalFromProvider).toBe(true); + }); }); diff --git a/packages/ai/test/openrouter-cost.test.ts b/packages/ai/test/openrouter-cost.test.ts new file mode 100644 index 000000000..dd9bc5069 --- /dev/null +++ b/packages/ai/test/openrouter-cost.test.ts @@ -0,0 +1,78 @@ +import { describe, expect, it } from "vitest"; +import type { Usage } from "../src/types.ts"; +import { applyOpenRouterReportedCost, isOpenRouterModel } from "../src/utils/openrouter-cost.ts"; + +function usage(total = 1): Usage { + return { + input: 1, + output: 1, + cacheRead: 0, + cacheWrite: 0, + totalTokens: 2, + cost: { input: 0.25, output: 0.75, cacheRead: 0, cacheWrite: 0, total }, + }; +} + +describe("isOpenRouterModel", () => { + it("recognizes both the built-in provider and custom providers using OpenRouter's endpoint", () => { + expect(isOpenRouterModel({ provider: "openrouter", baseUrl: "https://example.com" })).toBe(true); + expect(isOpenRouterModel({ provider: "openrouter-work", baseUrl: "https://openrouter.ai/api/v1" })).toBe(true); + expect(isOpenRouterModel({ provider: "custom", baseUrl: "https://example.com/v1" })).toBe(false); + }); +}); + +describe("applyOpenRouterReportedCost", () => { + it("uses OpenRouter's reported cost for non-BYOK requests", () => { + const value = usage(); + applyOpenRouterReportedCost(value, { + cost: 0, + is_byok: false, + cost_details: { + upstream_inference_cost: 0, + upstream_inference_prompt_cost: 0, + upstream_inference_completions_cost: 0, + }, + }); + expect(value.cost.total).toBe(0); + expect(value.cost.totalFromProvider).toBe(true); + }); + + it("adds the upstream inference cost for BYOK requests", () => { + const value = usage(); + applyOpenRouterReportedCost(value, { + cost: 0.1, + is_byok: true, + cost_details: { + upstream_inference_cost: 0.9, + upstream_inference_prompt_cost: 0.6, + upstream_inference_completions_cost: 0.3, + }, + }); + expect(value.cost.total).toBe(1); + expect(value.cost.totalFromProvider).toBe(true); + }); + + it("uses upstream cost components when OpenRouter omits their total", () => { + const value = usage(); + applyOpenRouterReportedCost(value, { + cost: 0.1, + is_byok: true, + cost_details: { + upstream_inference_cost: null, + upstream_inference_prompt_cost: 0.6, + upstream_inference_completions_cost: 0.3, + }, + }); + expect(value.cost.total).toBeCloseTo(1); + expect(value.cost.totalFromProvider).toBe(true); + }); + + it("keeps the catalog total when OpenRouter's accounting is incomplete", () => { + const value = usage(); + value.cost.totalFromProvider = false; + applyOpenRouterReportedCost(value, { cost: null, is_byok: false, cost_details: null }); + applyOpenRouterReportedCost(value, { cost: 0.1, is_byok: true, cost_details: null }); + expect(value.cost.total).toBe(1); + expect(value.cost.totalFromProvider).toBe(false); + }); +}); diff --git a/packages/ai/test/transcript-tool-changes.test.ts b/packages/ai/test/transcript-tool-changes.test.ts index 03127cd24..caaa7fe12 100644 --- a/packages/ai/test/transcript-tool-changes.test.ts +++ b/packages/ai/test/transcript-tool-changes.test.ts @@ -1,7 +1,7 @@ import { Type } from "typebox"; import { describe, expect, test } from "vitest"; import { getModel, streamSimple } from "../src/compat.ts"; -import type { Api, Context, Message, Model, SystemMessage, Tool } from "../src/types.ts"; +import type { Api, AssistantMessage, Context, Message, Model, SystemMessage, Tool } from "../src/types.ts"; import { resolveTranscriptTools } from "../src/utils/transcript.ts"; class PayloadCaptured extends Error {} @@ -244,6 +244,50 @@ describe("transcript system messages", () => { expect(payload.messages.at(-1)?.content.map((block) => block.type)).toEqual(["text"]); }); + // #11091 + test("holds an Anthropic update past an assistant message that converts to no blocks", async () => { + const reply = (text: string, provider: string, api: Api, model: string): AssistantMessage => ({ + role: "assistant", + content: [{ type: "text", text }], + api, + provider, + model, + usage: { + input: 0, + output: 0, + cacheRead: 0, + cacheWrite: 0, + totalTokens: 0, + cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 }, + }, + stopReason: "stop", + timestamp: 3, + }); + const messages: Message[] = [ + { role: "system", content: "base prompt", timestamp: 0 }, + { role: "user", content: "hi", timestamp: 1 }, + { role: "system", content: "updated guidance", timestamp: 2 }, + // Another model's empty reply: Anthropic rejects empty text blocks, so it is dropped. + reply("", "openai-codex", "openai-codex-responses", "gpt-x"), + { role: "user", content: "again", timestamp: 4 }, + ]; + const roles = (payload: AnthropicPayload) => payload.messages.map((message) => message.role); + + // No later assistant: the update goes at the end. + const atEnd = await capturePayload(anthropicNativeModel, { messages }); + expect(roles(atEnd)).toEqual(["user", "user", "system"]); + + // A later assistant that is sent: the update goes directly before it. + const beforeAssistant = await capturePayload(anthropicNativeModel, { + messages: [ + ...messages, + reply("answer", anthropicNativeModel.provider, anthropicNativeModel.api, anthropicNativeModel.id), + { role: "user", content: "third", timestamp: 5 }, + ], + }); + expect(roles(beforeAssistant)).toEqual(["user", "user", "system", "assistant", "user"]); + }); + test("folds Anthropic updates into the system prompt without native support", async () => { const model: Model<"anthropic-messages"> = { ...modelBase, diff --git a/packages/ai/test/typesafe-system-one.test.ts b/packages/ai/test/typesafe-system-one.test.ts index 97e56f0aa..a35908884 100644 --- a/packages/ai/test/typesafe-system-one.test.ts +++ b/packages/ai/test/typesafe-system-one.test.ts @@ -120,17 +120,160 @@ describe("TypeSafe System One", () => { expect(result.errorMessage).toContain("Unsupported classifier API: cloudflare-workers-ai-system-one"); }); - it("rejects image input before sending", async () => { + it("rejects image input for text-only models before sending", async () => { const fetch = vi.fn(async () => Response.json({ answers: wireAnswers })); const result = await classify( - { ...model, input: ["text", "image"] }, + model, { ...context, images: [{ type: "image", data: "aW1hZ2U=", mimeType: "image/png" }] }, { apiKey: "secret", fetch }, ); expect(fetch).not.toHaveBeenCalled(); expect(result.stopReason).toBe("error"); - expect(result.errorMessage).toBe("System One API does not support image input"); + expect(result.errorMessage).toBe("System One API does not support image input for jev-latest"); + }); + + it("sends images to TypeSafe as assets with the opt-in header", async () => { + const requests: Array<{ body: Record; headers: Headers }> = []; + const fetch = vi.fn(async (_input: string | URL | Request, init?: RequestInit) => { + requests.push({ + body: JSON.parse(String(init?.body)) as Record, + headers: new Headers(init?.headers), + }); + return Response.json({ answers: wireAnswers }); + }); + const imageModel = { ...model, input: ["text", "image"] as ("text" | "image")[] }; + + const result = await classify( + imageModel, + { + ...context, + images: [ + { type: "image", data: "cG5n", mimeType: "image/png" }, + { type: "image", data: "anBlZw==", mimeType: "image/jpeg" }, + ], + }, + { apiKey: "secret", fetch }, + ); + const withoutImages = await classify(imageModel, context, { apiKey: "secret", fetch }); + + expect(result.stopReason).toBe("stop"); + expect(requests[0]?.body).toMatchObject({ + state: ["", "", context.state], + assets: { + "": { type: "image", format: "png", data_base64: "cG5n" }, + "": { type: "image", format: "jpeg", data_base64: "anBlZw==" }, + }, + }); + expect(requests[0]?.headers.get("typesafe-danger-zone-images")).toBe("true"); + // Requests without images keep the plain state and send no opt-in header. + expect(withoutImages.stopReason).toBe("stop"); + expect(requests[1]?.body.state).toEqual(context.state); + expect(requests[1]?.body).not.toHaveProperty("assets"); + expect(requests[1]?.headers.has("typesafe-danger-zone-images")).toBe(false); + }); + + it("rejects image formats TypeSafe does not accept before sending", async () => { + const fetch = vi.fn(async () => Response.json({ answers: wireAnswers })); + const result = await classify( + { ...model, input: ["text", "image"] }, + { ...context, images: [{ type: "image", data: "d2VicA==", mimeType: "image/webp" }] }, + { apiKey: "secret", fetch }, + ); + + expect(fetch).not.toHaveBeenCalled(); + expect(result.stopReason).toBe("error"); + expect(result.errorMessage).toBe("System One API accepts PNG and JPEG images for jev-latest, got image/webp"); + }); + + it("sends images to OpenRouter as image_url parts in the state", async () => { + const requests: Array<{ body: Record; headers: Headers }> = []; + const fetch = vi.fn(async (_input: string | URL | Request, init?: RequestInit) => { + requests.push({ + body: JSON.parse(String(init?.body)) as Record, + headers: new Headers(init?.headers), + }); + return Response.json({ answers: wireAnswers }); + }); + const openRouterModel: ClassifierModel<"typesafe-system-one"> = { + ...model, + id: "typesafe/jev-1.14", + provider: "openrouter", + baseUrl: "https://openrouter.ai/api/v1", + input: ["text", "image"], + }; + + const result = await classify( + openRouterModel, + { ...context, images: [{ type: "image", data: "cG5n", mimeType: "image/png" }] }, + { apiKey: "secret", fetch }, + ); + + expect(result.stopReason).toBe("stop"); + // OpenRouter answers 200 for `assets` but drops them, and rejects a top-level `images` list. + expect(requests[0]?.body).toEqual({ + model: "typesafe/jev-1.14", + state: [{ type: "image_url", image_url: { url: "data:image/png;base64,cG5n" } }, context.state], + questions: expect.any(Object), + }); + expect(requests[0]?.headers.has("typesafe-danger-zone-images")).toBe(false); + }); + + it.each([ + ["OpenRouter behind a proxy", { provider: "openrouter", baseUrl: "https://proxy.example.com/v1" }], + ["a regional OpenRouter endpoint", { provider: "custom", baseUrl: "https://us.openrouter.ai/api/v1" }], + [ + "a proxy declared through compat", + { + provider: "custom", + baseUrl: "https://proxy.example.com/v1", + compat: { imageInput: "state-image-urls" as const, sendImageOptInHeader: false }, + }, + ], + ])("sends OpenRouter's image form to %s", async (_name, overrides) => { + const requests: Array<{ body: Record; headers: Headers }> = []; + const fetch = vi.fn(async (_input: string | URL | Request, init?: RequestInit) => { + requests.push({ + body: JSON.parse(String(init?.body)) as Record, + headers: new Headers(init?.headers), + }); + return Response.json({ answers: wireAnswers }); + }); + + const result = await classify( + { ...model, id: "typesafe/jev-1.14", input: ["text", "image"], ...overrides }, + { ...context, images: [{ type: "image", data: "cG5n", mimeType: "image/png" }] }, + { apiKey: "secret", fetch }, + ); + + expect(result.stopReason).toBe("stop"); + expect(requests[0]?.body.state).toEqual([ + { type: "image_url", image_url: { url: "data:image/png;base64,cG5n" } }, + context.state, + ]); + expect(requests[0]?.body).not.toHaveProperty("assets"); + expect(requests[0]?.headers.has("typesafe-danger-zone-images")).toBe(false); + }); + + it("lets compat override detected image settings per field", async () => { + const requests: Array<{ body: Record; headers: Headers }> = []; + const fetch = vi.fn(async (_input: string | URL | Request, init?: RequestInit) => { + requests.push({ + body: JSON.parse(String(init?.body)) as Record, + headers: new Headers(init?.headers), + }); + return Response.json({ answers: wireAnswers }); + }); + + // Only the header is overridden; the detected `assets` form stays. + await classify( + { ...model, input: ["text", "image"], compat: { sendImageOptInHeader: false } }, + { ...context, images: [{ type: "image", data: "cG5n", mimeType: "image/png" }] }, + { apiKey: "secret", fetch }, + ); + + expect(requests[0]?.body).toHaveProperty("assets"); + expect(requests[0]?.headers.has("typesafe-danger-zone-images")).toBe(false); }); it("merges headers case-insensitively and supports null suppression", async () => { diff --git a/packages/chord/CHANGELOG.md b/packages/chord/CHANGELOG.md index 59ea796d3..5d2dda138 100644 --- a/packages/chord/CHANGELOG.md +++ b/packages/chord/CHANGELOG.md @@ -4,6 +4,18 @@ ### Breaking Changes +- `traceIn(options, fn, context)` takes the context last (was `traceIn(context, options, fn)`), like the rest of the API. + +### Added + +- `WatchHandle` and `WatchEnd`, the one watch API, moved from Durable: a serialized exact-frame observation with `value`, `start(listener)`, `stop()`, and `closed`. `WatchEnd` reasons are `stopped`, `cancelled`, `retired`, `ended`, `listener_error`, and `failed`. +- `Watch`, the one watch handle implementation, moved from Durable: a source creates one per watch and feeds it with `advance(value, ops, context)`, `retire(context)` (last frame `[["r", null]]`, then `retired`), `end()` or `end({ error })`, and `observeCancellation(signal)`. More than 100 undelivered frames collapse to one root replacement `[["r", newest]]`; termination drops the listener and every reference into the source. +- `NativeWatchHandle` and its implementation `NativeWatch`: the same lifecycle for in-process values, with listener `(value, context)` and no ops; an overflow collapses to the newest value. + +## [1.4.0] - 2026-10-21 + +### Breaking Changes + - Custom `Context` implementations must provide `telemetry: TelemetrySpan`; use the no-op span `NOOP_TELEMETRY_CONTEXT` when disabled. `withTelemetry()` accepts spans, not adapters. ### Added diff --git a/packages/chord/README.md b/packages/chord/README.md index 20f595ccf..f39bea749 100644 --- a/packages/chord/README.md +++ b/packages/chord/README.md @@ -14,7 +14,7 @@ Contracts are runtime values described with [TypeBox](https://github.com/sinclai | Import | Contents | Runs in | |--------|----------|---------| -| `@earendil-works/chord` | Contracts (`local`, `remote`, `method`, `opaque`), declarations (through the factory's `self`: `state`, `derive`, `resource`, `on`, `task`), hosts (`createHost`, `defineFacet`, `defineRegistry`), links (`memoryLinkPair`, `ipcLink`, `webSocketLink`, `acceptWebSockets`), errors (`ChordError`, `isChordError`, `forbidden`, `invalid`). Also the shared base: `Context`, `JsonValue`, `JsonRepresentation`, `Draft`, `copyJson`, `isJsonValue`, and `replicatedState` with its types. | Node and browsers | +| `@earendil-works/chord` | Contracts (`local`, `remote`, `method`, `opaque`), declarations (through the factory's `self`: `state`, `derive`, `resource`, `on`, `task`), hosts (`createHost`, `defineFacet`, `defineRegistry`), links (`memoryLinkPair`, `ipcLink`, `webSocketLink`, `acceptWebSockets`), errors (`ChordError`, `isChordError`, `forbidden`, `invalid`). Also the shared base: `Context`, `JsonValue`, `JsonRepresentation`, `Draft`, `copyJson`, `isJsonValue`, `replicatedState` with its types, and the watch types `WatchHandle`, `NativeWatchHandle`, and `WatchEnd` with their implementations `Watch` and `NativeWatch`. | Node and browsers | | `@earendil-works/chord/node` | Child host processes: `createHostManager`, `forkDriver`, `childControl`, `processLock`; the facet module loader `nodeLoader`. | Node | | `@earendil-works/chord/context` | Explicit cancellation, telemetry, and invocation values: `BACKGROUND_CONTEXT`, `withCancel`, `withAbortSignal`, `withoutAbortSignal`, `withTelemetry`, `traceIn`, `withContextValue`, `awaitWithContext`. | Node and browsers | | `@earendil-works/chord/delta` | The transactional JSON tracker, structural diff, immutable apply, and the wire encoder/decoder for operation batches. | Node and browsers | @@ -81,14 +81,18 @@ import { InMemoryTelemetryContext } from '@earendil-works/pi-telemetry'; const telemetry = new InMemoryTelemetryContext(); await telemetry.startSpan({ name: 'app.request' }, async (span) => { const context = withTelemetry(span, BACKGROUND_CONTEXT); - await traceIn(context, { name: 'app.load' }, async (child) => { - child.telemetry.addEvent('cache.miss'); - return traceIn(child, { name: 'app.read' }, (nested) => readAccount(nested)); - }); + await traceIn( + { name: 'app.load' }, + async (child) => { + child.telemetry.addEvent('cache.miss'); + return traceIn({ name: 'app.read' }, (nested) => readAccount(nested), child); + }, + context, + ); }); ``` -`withTelemetry(span, context)` derives a new context; it never changes its parent, cancellation, or typed values. `traceIn(context, options, fn)` creates a callback-managed child span, and `fn` receives its derived context. Every context already supports `telemetry.addEvent()` and `telemetry.addLink()`. Values, cancellation, callback results, and rejection identities are preserved. Concurrent calls keep independent parents; there is no ambient current span. `value()`, `ContextKey`, and `withContextValue()` remain supported. +`withTelemetry(span, context)` derives a new context; it never changes its parent, cancellation, or typed values. `traceIn(options, fn, context)` creates a callback-managed child span, and `fn` receives its derived context. Every context already supports `telemetry.addEvent()` and `telemetry.addLink()`. Values, cancellation, callback results, and rejection identities are preserved. Concurrent calls keep independent parents; there is no ambient current span. `value()`, `ContextKey`, and `withContextValue()` remain supported. These helpers operate on the standalone `Context` API. They do not yet instrument Chord hosts, methods, or network frames. @@ -152,8 +156,7 @@ their containers with the provider; mutating a consumed value violates the contr and can corrupt authority. Clone or serialize at any mutable trust boundary. Replicated state provides the same model through a callback. `replicatedState` is a -standalone observable value, independent of hosts; Durable uses it for document, -view, and task-graph states: +standalone observable value, independent of hosts: ```ts import { replicatedState } from "@earendil-works/chord"; @@ -200,3 +203,30 @@ running callback. Cells of Chord objects and their remote readables have their own subscription model; see [State](docs/guide.md#state) in the usage guide. + +### Watches + +`WatchHandle` is the one watch interface, implemented by watch sources such as +Durable's documents, conversation views, and task graph. `watch.value` is the value +at acquisition until `start(listener)` installs the sole asynchronous listener; each +later change arrives as `(value, ops, context)`, one call at a time, and `value` +advances to it. A source keeps at most 100 undelivered frames and then replaces them +with one root replacement `[["r", newest]]`. `stop()` is idempotent, drops pending +frames, and neither aborts nor awaits a running call; it and `closed` give the +`WatchEnd`: + +- `stopped`: `stop()` was called; +- `cancelled`: the context the watch was acquired with was cancelled; +- `retired`: the source was retired, an explicit event after a last frame `[["r", null]]`; +- `ended`: the source closed (its owner ended or closed); +- `listener_error` with `error`: the listener threw or rejected; +- `failed` with `error`: the source failed. + +An ended watch drops every reference into its source, so a stopped watch that is +still referenced keeps only its last value alive. + +Sources implement handles with `Watch`, fed through `advance(value, ops, context)`, +`retire(context)`, `end()` or `end({ error })`, and `observeCancellation(signal)`. +`NativeWatch` implements `NativeWatchHandle`, the same lifecycle for +in-process values: the listener gets `(value, context)` without ops, and an +overflow collapses to the newest value. diff --git a/packages/chord/package.json b/packages/chord/package.json index 99878e741..57dbb5406 100644 --- a/packages/chord/package.json +++ b/packages/chord/package.json @@ -1,6 +1,6 @@ { "name": "@earendil-works/chord", - "version": "1.3.0", + "version": "1.4.0", "description": "Typed host runtime for plugins, contracts, replicated state, and RPC over JSON links", "type": "module", "main": "./dist/index.js", @@ -85,13 +85,13 @@ "node": ">=22.19.0" }, "dependencies": { - "@earendil-works/pi-telemetry": "1.3.0", + "@earendil-works/pi-telemetry": "1.4.0", "typebox": "1.3.27" }, "devDependencies": { "@types/ws": "8.18.2", "shx": "0.3.4", - "vitest": "4.1.11", + "vitest": "4.1.12", "ws": "8.21.0" } } diff --git a/packages/chord/src/context/index.ts b/packages/chord/src/context/index.ts index 7e8da6688..7318e08e6 100644 --- a/packages/chord/src/context/index.ts +++ b/packages/chord/src/context/index.ts @@ -97,9 +97,9 @@ export function withTelemetry( /** Run a child span with a derived context whose telemetry member is that span. */ export function traceIn( - context: Context, options: SpanOptions, fn: (context: Context) => T | Promise, + context: Context, ): Promise { return context.telemetry.startSpan(options, (span) => fn(withTelemetry(span, context))); } diff --git a/packages/chord/src/index.ts b/packages/chord/src/index.ts index 6fb71ae10..d81b621b5 100644 --- a/packages/chord/src/index.ts +++ b/packages/chord/src/index.ts @@ -78,5 +78,6 @@ export { replicatedState, } from "./state.ts"; export type { Ctx, GetOptions, Readable, RefStatus, Shape, StateEvent } from "./types.ts"; +export { NativeWatch, type NativeWatchHandle, Watch, type WatchEnd, type WatchHandle } from "./watch.ts"; export { acceptWebSockets, type WebSocketAcceptOptions, type WebSocketServerLike } from "./websocket.ts"; export { type Frame, PROTOCOL_VERSION } from "./wire.ts"; diff --git a/packages/chord/src/watch.ts b/packages/chord/src/watch.ts new file mode 100644 index 000000000..3b8a19044 --- /dev/null +++ b/packages/chord/src/watch.ts @@ -0,0 +1,289 @@ +// Watch handles: the watch types and the one implementation of their lifecycle, for JSON and native values. +import { type Context, withoutAbortSignal } from "./context/index.ts"; +import type { Op } from "./delta/index.ts"; +import type { JsonValue } from "./json.ts"; + +/** + * Terminal result of one watch. + * + * - `stopped`: `stop()` was called. + * - `cancelled`: the context the watch was acquired with was cancelled. + * - `retired`: the source was retired; its last frame was `[["r", null]]`. + * - `ended`: the source closed (its owner ended or closed). + * - `listener_error`: the listener threw or rejected with `error`. + * - `failed`: the source failed with `error`. + */ +export type WatchEnd = + | { readonly reason: "stopped" | "cancelled" | "retired" | "ended" } + | { readonly reason: "listener_error"; readonly error: Error } + | { readonly reason: "failed"; readonly error: unknown }; + +/** Serialized exact-frame observation of an immutable value with bounded pending delivery. */ +export interface WatchHandle { + /** Acquisition revision before start; latest delivered immutable revision afterward. */ + readonly value: T; + /** Install the sole asynchronous listener. Never invokes it inline. */ + start(listener: (value: T, ops: readonly Op[], context: Context) => Promise): void; + /** Idempotently stop future callbacks and return this watch's terminal result. */ + stop(): Promise; + /** Settle when the watch terminates; an already-running callback remains caller-owned. */ + readonly closed: Promise; +} + +/** Serialized observation of an in-process value: as `WatchHandle`, but a change is a new value without ops. */ +export interface NativeWatchHandle { + /** Acquisition value before start; latest delivered value afterward. */ + readonly value: T; + /** Install the sole asynchronous listener. Never invokes it inline. */ + start(listener: (value: T, context: Context) => Promise): void; + /** Idempotently stop future callbacks and return this watch's terminal result. */ + stop(): Promise; + /** Settle when the watch terminates; an already-running callback remains caller-owned. */ + readonly closed: Promise; +} + +type WatchListener = (value: T, ops: readonly Op[], context: Context) => Promise; + +type NativeWatchListener = (value: T, context: Context) => Promise; + +type WatchFrame = { + readonly value: T; + readonly ops: readonly Op[]; + readonly context: Context; + /** The retirement frame: the watch ends `retired` once it is delivered. */ + readonly retires?: true; +}; + +/** Maximum frames retained behind one unavailable watch listener. */ +const MAX_PENDING_WATCH_FRAMES = 100; + +/** + * The lifecycle both handles share: pending frames, serialized delivery, and termination. A source feeds it and ends + * it explicitly; termination drops every reference the watch holds into its source, so a stopped watch that is still + * referenced keeps nothing but its last value alive. Subclasses supply only how a listener is called and the ops of an + * overflow frame. + */ +abstract class WatchLifecycle { + #detach: (() => void) | undefined; + #report: ((error: unknown) => void) | undefined; + #replace: (() => T) | undefined; + readonly #closed = Promise.withResolvers(); + readonly #pending: WatchFrame[] = []; + #value: T; + #listener: L | undefined; + #started = false; + #scheduled = false; + #running = false; + #retired = false; + /** Set once, by `#terminate()`. */ + #end: WatchEnd | undefined; + #cancellationSignal: AbortSignal | undefined; + #cancellationListener: (() => void) | undefined; + + constructor(value: T, detach: () => void, report: (error: unknown) => void, replace?: () => T) { + this.#value = value; + this.#detach = detach; + this.#report = report; + this.#replace = replace; + } + + get value(): T { + return this.#value; + } + + get closed(): Promise { + return this.#closed.promise; + } + + start(listener: L): void { + if (this.#started) throw new Error("Watch is already started"); + if (this.#end !== undefined) throw new Error("Watch is stopped"); + this.#started = true; + this.#listener = listener; + if (this.#pending.length > 0) this.#schedule(); + } + + stop(): Promise { + this.#terminate({ reason: "stopped" }); + return this.#closed.promise; + } + + /** End the watch `cancelled` when `signal` aborts; installed once. */ + observeCancellation(signal: AbortSignal): void { + if (this.#cancellationSignal !== undefined) throw new Error("Watch cancellation is already installed"); + if (this.#end !== undefined) return; + this.#cancellationSignal = signal; + this.#cancellationListener = () => this.cancel(); + signal.addEventListener("abort", this.#cancellationListener, { once: true }); + if (signal.aborted) this.cancel(); + } + + /** End the watch `cancelled`: its acquisition context was cancelled. */ + cancel(): void { + this.#terminate({ reason: "cancelled" }); + } + + /** The source closed: end with `ended`, or with `failed` and the error when the source failed. */ + end(failure?: { readonly error: unknown }): void { + this.#terminate(failure === undefined ? { reason: "ended" } : { reason: "failed", error: failure.error }); + } + + /** Call `listener` with one frame. */ + protected abstract invoke(listener: L, frame: WatchFrame, context: Context): Promise; + + /** The ops of the frame an overflow delivers instead of the backlog, whose value is `value`. */ + protected abstract overflowOps(value: T): readonly Op[]; + + /** Queue one frame; past the bound the backlog collapses to one frame of `replace()` or the newest value. */ + protected enqueue(value: T, ops: readonly Op[], context: Context): void { + if (this.#end !== undefined || this.#retired) return; + if (this.#pending.length >= MAX_PENDING_WATCH_FRAMES) { + this.#pending.length = 0; + const replacement = this.#replace?.() ?? value; + this.#pending.push({ value: replacement, ops: this.overflowOps(replacement), context }); + } else { + this.#pending.push({ value, ops, context }); + } + if (this.#started) this.#schedule(); + } + + /** + * Queue the last frame, after which the watch ends `retired`. An overflowing backlog is dropped, since the frame + * replaces the root anyway. + */ + protected enqueueRetirement(value: T, ops: readonly Op[], context: Context): void { + if (this.#end !== undefined || this.#retired) return; + this.#retired = true; + if (this.#pending.length >= MAX_PENDING_WATCH_FRAMES) this.#pending.length = 0; + this.#pending.push({ value, ops, context, retires: true }); + if (this.#started) this.#schedule(); + } + + #schedule(): void { + if (this.#scheduled || this.#running || this.#end !== undefined) return; + this.#scheduled = true; + queueMicrotask(() => { + this.#scheduled = false; + void this.#drain(); + }); + } + + async #drain(): Promise { + if (this.#running || this.#end !== undefined || !this.#started) return; + this.#running = true; + try { + while (this.#end === undefined) { + const frame = this.#pending.shift(); + const listener = this.#listener; + if (frame === undefined || listener === undefined) break; + this.#value = frame.value; + const deliveryContext = withoutAbortSignal(frame.context); + try { + await this.invoke(listener, frame, deliveryContext); + } catch (error) { + if (this.#end === undefined) { + this.#report?.(error); + this.#terminate({ reason: "listener_error", error: toError(error) }); + } + break; + } + if (frame.retires === true) { + this.#terminate({ reason: "retired" }); + break; + } + } + } finally { + this.#running = false; + if (this.#end === undefined && this.#pending.length > 0) this.#schedule(); + } + } + + /** + * End the watch, once: detach, drop pending frames and every reference into the source, and settle `closed`. A + * callback already running keeps running. + */ + #terminate(end: WatchEnd): void { + if (this.#end !== undefined) return; + this.#end = end; + const detach = this.#detach; + this.#detach = undefined; + this.#report = undefined; + this.#replace = undefined; + this.#listener = undefined; + this.#pending.length = 0; + if (this.#cancellationListener !== undefined) { + this.#cancellationSignal?.removeEventListener("abort", this.#cancellationListener); + } + this.#cancellationSignal = undefined; + this.#cancellationListener = undefined; + detach?.(); + this.#closed.resolve(end); + } +} + +function toError(error: unknown): Error { + if (error instanceof Error) return error; + try { + return new Error(String(error)); + } catch { + return new Error(Object.prototype.toString.call(error)); + } +} + +/** Canonical terminal frame of a retired source. */ +const RETIREMENT_OPERATIONS: readonly Op[] = [["r", null]]; + +/** + * The watch handle of a JSON source: exact frames in order, an overflow becomes one root replacement `[["r", newest]]`. + * `detach` unsubscribes it from its source on termination; `report` gets a listener's throw, which also ends the + * watch; `replace` gives the value an overflow delivers, by default the newest value. + */ +export class Watch extends WatchLifecycle> implements WatchHandle { + /** Queue the frame `ops`, which turn the previous value into `value`. */ + advance(value: T, ops: readonly Op[], context: Context): void { + this.enqueue(value, ops, context); + } + + /** + * The source retired: queue the last frame, `[["r", null]]` with value `null`, then end `retired`. Typed for watches + * whose values include `null`. + */ + retire(this: Watch, context: Context): void { + this.enqueueRetirement(null, RETIREMENT_OPERATIONS, context); + } + + protected invoke(listener: WatchListener, frame: WatchFrame, context: Context): Promise { + return listener(frame.value, frame.ops, context); + } + + protected overflowOps(value: T): readonly Op[] { + // Values of a JSON watch are JSON; `T` is the source's narrower or wider view of them (e.g. a typed document). + return [["r", value as JsonValue]]; + } +} + +const NO_OPS: readonly Op[] = []; + +/** + * The watch handle of a native source: values in order, an overflow collapses to the newest value. `detach` and + * `report` as for `Watch`. + */ +export class NativeWatch extends WatchLifecycle> implements NativeWatchHandle { + constructor(value: T, detach: () => void, report: (error: unknown) => void) { + // No replacement: an overflow delivers the newest value. + super(value, detach, report, undefined); + } + + /** Queue `value`, the source's new value. */ + advance(value: T, context: Context): void { + this.enqueue(value, NO_OPS, context); + } + + protected invoke(listener: NativeWatchListener, frame: WatchFrame, context: Context): Promise { + return listener(frame.value, context); + } + + protected overflowOps(): readonly Op[] { + return NO_OPS; + } +} diff --git a/packages/chord/test/context.test.ts b/packages/chord/test/context.test.ts index e9a51a744..88b9e7f75 100644 --- a/packages/chord/test/context.test.ts +++ b/packages/chord/test/context.test.ts @@ -91,15 +91,23 @@ describe("Context", () => { expect(replaced.value(key)).toBe("owner"); expect(replaced.abortSignal).toBe(base.abortSignal); expect(withoutAbortSignal(context).telemetry).toBe(parent); - await traceIn(context, { name: "outer" }, async (child) => { - expect(child.value(key)).toBe("owner"); - expect(child.abortSignal).toBe(context.abortSignal); - child.telemetry.addEvent("started"); - await traceIn(child, { name: "inner" }, (nested) => { - nested.telemetry.addLink({ context: child.telemetry }); - return 42; - }); - }); + await traceIn( + { name: "outer" }, + async (child) => { + expect(child.value(key)).toBe("owner"); + expect(child.abortSignal).toBe(context.abortSignal); + child.telemetry.addEvent("started"); + await traceIn( + { name: "inner" }, + (nested) => { + nested.telemetry.addLink({ context: child.telemetry }); + return 42; + }, + child, + ); + }, + context, + ); const [caller, outer, inner] = telemetry.getSpans(); expect(outer?.parentId).toBe(caller?.id); expect(inner?.parentId).toBe(outer?.id); @@ -119,15 +127,23 @@ describe("Context", () => { const gate = new Promise((resolve) => { release = resolve; }); - const first = traceIn(context, { name: "first" }, async (child) => { - await gate; - return traceIn(child, { name: "first.child" }, () => "first"); - }); - const second = traceIn(context, { name: "second" }, async (child) => { - const result = await traceIn(child, { name: "second.child" }, () => "second"); - release?.(); - return result; - }); + const first = traceIn( + { name: "first" }, + async (child) => { + await gate; + return traceIn({ name: "first.child" }, () => "first", child); + }, + context, + ); + const second = traceIn( + { name: "second" }, + async (child) => { + const result = await traceIn({ name: "second.child" }, () => "second", child); + release?.(); + return result; + }, + context, + ); expect(await Promise.all([first, second])).toEqual(["first", "second"]); const spans = telemetry.getSpans(); for (const name of ["first", "second"]) { @@ -136,16 +152,24 @@ describe("Context", () => { } const failure = { reason: "same value" }; await expect( - traceIn(context, { name: "throw" }, () => { - throw failure; - }), + traceIn( + { name: "throw" }, + () => { + throw failure; + }, + context, + ), ).rejects.toBe(failure); - await expect(traceIn(context, { name: "reject" }, () => Promise.reject(failure))).rejects.toBe(failure); + await expect(traceIn({ name: "reject" }, () => Promise.reject(failure), context)).rejects.toBe(failure); expect( - await traceIn(BACKGROUND_CONTEXT, { name: "noop" }, (child) => { - child.telemetry.addLink({ context: parent }); - return 42; - }), + await traceIn( + { name: "noop" }, + (child) => { + child.telemetry.addLink({ context: parent }); + return 42; + }, + BACKGROUND_CONTEXT, + ), ).toBe(42); }); }); diff --git a/packages/chord/test/watch.test.ts b/packages/chord/test/watch.test.ts new file mode 100644 index 000000000..3c8282430 --- /dev/null +++ b/packages/chord/test/watch.test.ts @@ -0,0 +1,490 @@ +// Tests for Chord's watch handles: the shared lifecycle through `Watch`, and what `NativeWatch` does differently. +import { setFlagsFromString } from "node:v8"; +import { runInNewContext } from "node:vm"; +import { describe, expect, expectTypeOf, it } from "vitest"; +import { + BACKGROUND_CONTEXT, + type Context, + createContextKey, + withAbortSignal, + withContextValue, +} from "../src/context/index.ts"; +import type { Op } from "../src/delta/index.ts"; +import type { JsonValue } from "../src/json.ts"; +import { NativeWatch, Watch, type WatchEnd } from "../src/watch.ts"; + +const context = BACKGROUND_CONTEXT; + +function flush(): Promise { + return new Promise((resolve) => setTimeout(resolve, 0)); +} + +function deferred(): { readonly promise: Promise; readonly resolve: () => void } { + const { promise, resolve } = Promise.withResolvers(); + return { promise, resolve: () => resolve() }; +} + +/** A watch whose detach and reports are recorded. */ +function recordedWatch(value: T, replace?: () => T) { + const recorded = { detached: 0, reported: [] as unknown[] }; + const watch = new Watch( + value, + () => recorded.detached++, + (error) => recorded.reported.push(error), + replace, + ); + return { watch, recorded }; +} + +/** A started watch collecting its frames. */ +function collectingWatch(value: T, replace?: () => T) { + const { watch, recorded } = recordedWatch(value, replace); + const frames: Array<{ value: T; ops: readonly Op[] }> = []; + watch.start(async (delivered, ops) => { + frames.push({ value: delivered, ops }); + }); + return { watch, recorded, frames }; +} + +/** The end of `closed`, or undefined while the watch is open. */ +function endOf(watch: { readonly closed: Promise }): { readonly end: WatchEnd | undefined } { + const settled: { end: WatchEnd | undefined } = { end: undefined }; + void watch.closed.then((end) => { + settled.end = end; + }); + return settled; +} + +const collect = (() => { + setFlagsFromString("--expose-gc"); + const gc = runInNewContext("gc") as () => void; + return async (): Promise => { + for (let round = 0; round < 5; round++) { + await flush(); + gc(); + } + }; +})(); + +describe("Watch delivery", () => { + it("keeps the acquisition value until start and delivers exact frames in order", async () => { + const { watch } = recordedWatch({ n: 0 }); + watch.advance({ n: 1 }, [["s", ["n"], 1]], context); + watch.advance({ n: 2 }, [["s", ["n"], 2]], context); + await flush(); + expect(watch.value).toEqual({ n: 0 }); + + const frames: Array<{ value: JsonValue; ops: readonly Op[] }> = []; + watch.start(async (value, ops) => { + frames.push({ value, ops }); + }); + expect(frames).toEqual([]); + await flush(); + expect(frames).toEqual([ + { value: { n: 1 }, ops: [["s", ["n"], 1]] }, + { value: { n: 2 }, ops: [["s", ["n"], 2]] }, + ]); + expect(watch.value).toEqual({ n: 2 }); + }); + + it("never invokes the listener inline", async () => { + const { watch, frames } = collectingWatch(0); + watch.advance(1, [["r", 1]], context); + expect(frames).toEqual([]); + await flush(); + expect(frames).toEqual([{ value: 1, ops: [["r", 1]] }]); + }); + + it("awaits each callback and buffers frames advanced while one is in flight", async () => { + const { watch } = recordedWatch(0); + const gate = deferred(); + const calls: string[] = []; + watch.start(async (value) => { + calls.push(`start ${value}`); + if (value === 1) await gate.promise; + calls.push(`end ${value}`); + }); + watch.advance(1, [["r", 1]], context); + await flush(); + watch.advance(2, [["r", 2]], context); + watch.advance(3, [["r", 3]], context); + await flush(); + expect(calls).toEqual(["start 1"]); + gate.resolve(); + await flush(); + expect(calls).toEqual(["start 1", "end 1", "start 2", "end 2", "start 3", "end 3"]); + }); + + it("delivers a context with the frame's values and without its abort signal", async () => { + const { watch } = recordedWatch(0); + const key = createContextKey("frame"); + const controller = new AbortController(); + const frameContext = withContextValue(key, "first", withAbortSignal(controller.signal, context)); + let delivered: Context | undefined; + watch.start(async (_value, _ops, deliveryContext) => { + delivered = deliveryContext; + }); + watch.advance(1, [["r", 1]], frameContext); + await flush(); + expect(delivered?.value(key)).toBe("first"); + expect(delivered?.abortSignal).toBeUndefined(); + }); +}); + +describe("Watch overflow", () => { + it("collapses 101 pending frames to one root replacement of the newest value", async () => { + const { watch } = recordedWatch(0); + for (let n = 1; n <= 101; n++) watch.advance(n, [["r", n]], context); + const frames: Array<{ value: JsonValue; ops: readonly Op[] }> = []; + watch.start(async (value, ops) => { + frames.push({ value, ops }); + }); + await flush(); + expect(frames).toEqual([{ value: 101, ops: [["r", 101]] }]); + }); + + it("keeps 100 pending frames exact", async () => { + const { watch } = recordedWatch(0); + for (let n = 1; n <= 100; n++) watch.advance(n, [["r", n]], context); + const values: JsonValue[] = []; + watch.start(async (value) => { + values.push(value); + }); + await flush(); + expect(values).toEqual(Array.from({ length: 100 }, (_, index) => index + 1)); + }); + + it("delivers the value of `replace` on overflow", async () => { + const { watch } = recordedWatch(0, () => "replacement"); + for (let n = 1; n <= 101; n++) watch.advance(n, [], context); + const frames: Array<{ value: JsonValue; ops: readonly Op[] }> = []; + watch.start(async (value, ops) => { + frames.push({ value, ops }); + }); + await flush(); + expect(frames).toEqual([{ value: "replacement", ops: [["r", "replacement"]] }]); + }); + + it("never folds the in-flight frame into an overflow", async () => { + const { watch } = recordedWatch(0); + const gate = deferred(); + const values: JsonValue[] = []; + watch.start(async (value) => { + values.push(value); + if (value === 1) await gate.promise; + }); + watch.advance(1, [["r", 1]], context); + await flush(); + for (let n = 2; n <= 102; n++) watch.advance(n, [["r", n]], context); + gate.resolve(); + await flush(); + expect(values).toEqual([1, 102]); + }); +}); + +describe("Watch retirement", () => { + it('delivers `[["r", null]]` as the last frame and then ends retired', async () => { + const { watch, frames, recorded } = collectingWatch(1); + const settled = endOf(watch); + watch.advance(2, [["r", 2]], context); + watch.retire(context); + watch.advance(3, [["r", 3]], context); + await flush(); + expect(frames).toEqual([ + { value: 2, ops: [["r", 2]] }, + { value: null, ops: [["r", null]] }, + ]); + expect(watch.value).toBeNull(); + expect(settled.end).toEqual({ reason: "retired" }); + expect(recorded.detached).toBe(1); + }); + + it("drops an overflowing backlog for the retirement frame", async () => { + const { watch } = recordedWatch(0); + for (let n = 1; n <= 100; n++) watch.advance(n, [["r", n]], context); + watch.retire(context); + const frames: Array<{ value: JsonValue; ops: readonly Op[] }> = []; + watch.start(async (value, ops) => { + frames.push({ value, ops }); + }); + expect(await watch.closed).toEqual({ reason: "retired" }); + expect(frames).toEqual([{ value: null, ops: [["r", null]] }]); + }); + + it("ends retired only on its source's retirement, never on a null value", async () => { + const { watch, frames } = collectingWatch(1); + const settled = endOf(watch); + watch.advance(null, [["r", null]], context); + watch.advance(2, [["r", 2]], context); + await flush(); + expect(frames).toEqual([ + { value: null, ops: [["r", null]] }, + { value: 2, ops: [["r", 2]] }, + ]); + expect(settled.end).toBeUndefined(); + expect(await watch.stop()).toEqual({ reason: "stopped" }); + }); + + it("is only available to watches whose values include null", () => { + const check = (numbers: Watch, documents: Watch<{ n: number } | null>) => { + // @ts-expect-error `retire` delivers `null`, which a number watch cannot hold. + numbers.retire(context); + documents.retire(context); + }; + expectTypeOf(check).toBeFunction(); + }); + + it("discards a retirement still pending when the source ends", async () => { + const { watch } = recordedWatch(1); + watch.retire(context); + watch.end(); + expect(await watch.closed).toEqual({ reason: "ended" }); + expect(watch.value).toBe(1); + }); +}); + +describe("Watch ends", () => { + it("stops idempotently and rejects a repeated or late start", async () => { + const { watch, recorded } = recordedWatch(0); + watch.start(async () => {}); + expect(() => watch.start(async () => {})).toThrow("Watch is already started"); + expect(await watch.stop()).toEqual({ reason: "stopped" }); + expect(await watch.stop()).toEqual({ reason: "stopped" }); + expect(recorded.detached).toBe(1); + + const late = recordedWatch(0).watch; + await late.stop(); + expect(() => late.start(async () => {})).toThrow("Watch is stopped"); + }); + + it("ends `ended` or `failed` from its source, dropping pending frames", async () => { + const { watch, frames } = collectingWatch(0); + watch.advance(1, [["r", 1]], context); + watch.end(); + await flush(); + expect(frames).toEqual([]); + expect(await watch.closed).toEqual({ reason: "ended" }); + + const failure = new Error("source failed"); + const failed = recordedWatch(0).watch; + failed.end({ error: failure }); + failed.end(); + expect(await failed.closed).toEqual({ reason: "failed", error: failure }); + }); + + it("stops future delivery without aborting or awaiting an in-flight callback", async () => { + const { watch } = recordedWatch(0); + const gate = deferred(); + const calls: string[] = []; + watch.start(async (value) => { + calls.push(`start ${value}`); + await gate.promise; + calls.push(`end ${value}`); + }); + watch.advance(1, [["r", 1]], context); + await flush(); + watch.advance(2, [["r", 2]], context); + expect(await watch.stop()).toEqual({ reason: "stopped" }); + gate.resolve(); + await flush(); + expect(calls).toEqual(["start 1", "end 1"]); + }); + + it("ends `cancelled` when its acquisition signal aborts, also when already aborted", async () => { + const controller = new AbortController(); + const { watch, recorded } = recordedWatch(0); + watch.observeCancellation(controller.signal); + expect(() => watch.observeCancellation(controller.signal)).toThrow("Watch cancellation is already installed"); + controller.abort(); + expect(await watch.closed).toEqual({ reason: "cancelled" }); + expect(recorded.detached).toBe(1); + + const aborted = recordedWatch(0).watch; + aborted.observeCancellation(AbortSignal.abort()); + expect(await aborted.closed).toEqual({ reason: "cancelled" }); + + const cancelled = recordedWatch(0).watch; + cancelled.cancel(); + expect(await cancelled.closed).toEqual({ reason: "cancelled" }); + }); + + it("ends `listener_error` on a throw, reports it, and delivers nothing more", async () => { + const { watch, recorded } = recordedWatch(0); + const failure = new Error("listener failed"); + const values: JsonValue[] = []; + watch.start(async (value) => { + values.push(value); + throw failure; + }); + watch.advance(1, [["r", 1]], context); + watch.advance(2, [["r", 2]], context); + expect(await watch.closed).toEqual({ reason: "listener_error", error: failure }); + expect(values).toEqual([1]); + expect(recorded.reported).toEqual([failure]); + expect(recorded.detached).toBe(1); + }); + + it("wraps a thrown non-Error in an Error", async () => { + const { watch } = recordedWatch(0); + watch.start(() => Promise.reject("plain")); + watch.advance(1, [["r", 1]], context); + const end = await watch.closed; + expect(end.reason).toBe("listener_error"); + expect(end.reason === "listener_error" && end.error.message).toBe("plain"); + }); +}); + +describe("Watch references", () => { + it("retains neither its source, its listener, nor its signal once ended, while still referenced", async () => { + /** A watch over a source only its closures reference; `end` ends it before returning. */ + const acquire = async (end: (watch: Watch) => void) => { + const source = { value: 1 as JsonValue }; + const controller = new AbortController(); + const watch = new Watch( + 0, + () => void source, + () => void source, + () => source.value, + ); + const listener = async (): Promise => {}; + watch.observeCancellation(controller.signal); + watch.start(listener); + watch.advance(1, [["r", 1]], context); + await flush(); + end(watch); + return { + watch, + source: new WeakRef(source), + listener: new WeakRef(listener), + signal: new WeakRef(controller.signal), + }; + }; + + // Control: an open watch keeps its source, so the collections below are real. + const open = await acquire(() => {}); + await collect(); + expect(open.source.deref()).toBeDefined(); + expect(open.listener.deref()).toBeDefined(); + expect(open.signal.deref()).toBeDefined(); + await open.watch.stop(); + + for (const end of [ + (watch: Watch) => void watch.stop(), + (watch: Watch) => watch.cancel(), + (watch: Watch) => watch.end(), + (watch: Watch) => watch.retire(context), + ]) { + const ended = await acquire(end); + await flush(); + await collect(); + expect(ended.source.deref()).toBeUndefined(); + expect(ended.listener.deref()).toBeUndefined(); + expect(ended.signal.deref()).toBeUndefined(); + } + }); +}); + +describe("NativeWatch", () => { + it("delivers values by identity in order with a context, never inline", async () => { + const first = { run: () => 1 }; + const second = { run: () => 2 }; + const watch = new NativeWatch<{ run: () => number }>( + { run: () => 0 }, + () => {}, + () => {}, + ); + const key = createContextKey("frame"); + const delivered: Array<{ value: { run: () => number }; frame: string | undefined }> = []; + watch.start(async (value, deliveryContext) => { + delivered.push({ value, frame: deliveryContext.value(key) }); + }); + watch.advance(first, withContextValue(key, "first", context)); + watch.advance(second, withContextValue(key, "second", context)); + expect(delivered).toEqual([]); + await flush(); + expect(delivered).toHaveLength(2); + expect(delivered[0].value).toBe(first); + expect(delivered[1].value).toBe(second); + expect(delivered.map((entry) => entry.frame)).toEqual(["first", "second"]); + expect(watch.value).toBe(second); + }); + + it("collapses 101 pending values to the newest", async () => { + const watch = new NativeWatch( + 0, + () => {}, + () => {}, + ); + for (let n = 1; n <= 101; n++) watch.advance(n, context); + const values: number[] = []; + watch.start(async (value) => { + values.push(value); + }); + await flush(); + expect(values).toEqual([101]); + }); + + it("ends with every reason of its lifecycle", async () => { + const create = () => + new NativeWatch( + 0, + () => {}, + () => {}, + ); + const stopped = create(); + expect(await stopped.stop()).toEqual({ reason: "stopped" }); + + const cancelled = create(); + cancelled.observeCancellation(AbortSignal.abort()); + expect(await cancelled.closed).toEqual({ reason: "cancelled" }); + + const ended = create(); + ended.end(); + expect(await ended.closed).toEqual({ reason: "ended" }); + + const error = new Error("source failed"); + const failed = create(); + failed.end({ error }); + expect(await failed.closed).toEqual({ reason: "failed", error }); + + const reported: unknown[] = []; + const throwing = new NativeWatch( + 0, + () => {}, + (thrown) => reported.push(thrown), + ); + const failure = new Error("listener failed"); + throwing.start(() => Promise.reject(failure)); + throwing.advance(1, context); + expect(await throwing.closed).toEqual({ reason: "listener_error", error: failure }); + expect(reported).toEqual([failure]); + }); + + it("retains neither its source nor its listener once stopped, while still referenced", async () => { + const acquire = async (stop: boolean) => { + const source = { value: 1 }; + const watch = new NativeWatch( + 0, + () => void source, + () => void source, + ); + const listener = async (): Promise => {}; + watch.start(listener); + watch.advance(source.value, context); + await flush(); + if (stop) await watch.stop(); + return { watch, source: new WeakRef(source), listener: new WeakRef(listener) }; + }; + + const open = await acquire(false); + await collect(); + expect(open.source.deref()).toBeDefined(); + await open.watch.stop(); + + const stopped = await acquire(true); + await collect(); + expect(stopped.source.deref()).toBeUndefined(); + expect(stopped.listener.deref()).toBeUndefined(); + expect(stopped.watch.value).toBe(1); + }); +}); diff --git a/packages/codemode/CHANGELOG.md b/packages/codemode/CHANGELOG.md index cc5703ddc..decd59f9e 100644 --- a/packages/codemode/CHANGELOG.md +++ b/packages/codemode/CHANGELOG.md @@ -2,6 +2,8 @@ ## [Unreleased] +## [1.4.0] - 2026-10-21 + ## [1.3.0] - 2026-10-15 ### Breaking Changes diff --git a/packages/codemode/package.json b/packages/codemode/package.json index dd1c22fd5..b1f0fea9c 100644 --- a/packages/codemode/package.json +++ b/packages/codemode/package.json @@ -1,6 +1,6 @@ { "name": "@earendil-works/pi-codemode", - "version": "1.3.0", + "version": "1.4.0", "description": "Sandboxed JavaScript execution where the only capability is calling injected tools", "type": "module", "main": "./dist/index.js", @@ -71,6 +71,6 @@ }, "devDependencies": { "shx": "0.3.4", - "vitest": "4.1.11" + "vitest": "4.1.12" } } diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index 31ada19d9..1870f885f 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -2,15 +2,37 @@ ## [Unreleased] +### Changed + +- `pi update` for installs from the pi.dev installer no longer passes `--min-release-age=0` to `npm ci`. The release lockfile pins every package, so the flag had no effect, and security scanners that block it stopped the update ([#11262](https://github.com/earendil-works/pi/issues/11262)) + +## [1.4.0] - 2026-10-21 + +### New Features + +- **Image input for classifiers**: codemode `models.classify()` accepts `images` for TypeSafe's Jev and Cloudflare's Clef models on `typesafe`, `cloudflare-workers-ai`, and OpenRouter. See [Use classifier models](docs/models.md#use-classifier-models) and [Codemode](docs/codemode.md#models). +- **Models Configuration reference**: every provider, model, model override, and compatibility field of `models.json`, generated from its schema. See [Models Configuration](docs/models-configuration.md). +- **Summaries without a fixed output cap**: compaction and branch summaries use the model's output limit, so long reasoning no longer makes them fail. See [Compaction](docs/compaction.md#compaction). +- **Codemode waits for a free worker**: parallel scripts and in-process subagents queue instead of failing, and `PI_CODEMODE_MAX_WORKERS` raises the limit. See [Codemode limits](docs/codemode.md#limits). +- **Rex terminal support**: OSC 8 hyperlinks, truecolor, and inline images. + ### Added - Added a Models Configuration reference generated from the `models.json` schema, listing all provider, model, model override, and compatibility fields ([#10790](https://github.com/earendil-works/pi/issues/10790)) +- Codemode `models.classify()` now accepts `images` for TypeSafe's Jev and Cloudflare's Clef models on `typesafe`, `cloudflare-workers-ai`, and OpenRouter, and lists Clef Omni and Jev 1.14 on Workers AI. +- Provider-level `compat` in `models.json` now also applies to classifier models, with the System One fields `imageInput` and `sendImageOptInHeader` for proxies that serve another service's classifiers. Extension classifier models accept `compat` too. +- Added `MiniMax-M3.1-Flash` to the `minimax` and `minimax-cn` providers, with thinking levels low through max ([#11121](https://github.com/earendil-works/pi/issues/11121)) +- Added support for the Rex terminal: OSC 8 hyperlinks, truecolor, and inline images ([#11131](https://github.com/earendil-works/pi/issues/11131), [#11150](https://github.com/earendil-works/pi/pull/11150) by [@petrroll](https://github.com/petrroll), [#11183](https://github.com/earendil-works/pi/pull/11183) by [@nihaals](https://github.com/nihaals)) ### Changed - Provider attribution headers now come from pi-ai's client identity. Extensions' `before_provider_headers` handlers no longer see the OpenRouter, NVIDIA NIM, and OpenCode attribution headers; returning `null` for one still removes it. Cloudflare requests send pi's detailed `User-Agent` instead of `pi-coding-agent`, as do GitHub release checks for tool downloads, and OpenCode requests send `x-opencode-client` whenever attribution is enabled, not only with a session. - `generateSummary()` and `generateSummaryWithUsage()` now ignore their `reserveTokens` parameter. Summary requests use the model's output limit instead. - Changed the default GitHub Copilot model from `gpt-5.4`, which Copilot retired, to `gpt-6.1-sol`. +- Changed the default NVIDIA model to `nvidia/nemotron-3.5-super-vl-120b-a12b` ([#10910](https://github.com/earendil-works/pi/pull/10910) by [@cv](https://github.com/cv)) +- Changed the default Xiaomi model to `mimo-v2.6-pro` for the `xiaomi` and Xiaomi Token Plan providers, since `mimo-v2.5-pro` left the catalog. +- Grok 4.3 and Grok 4.5 on the `xai` provider now offer the `xhigh` thinking level. +- Sign in with ChatGPT: ChatGPT Business workspace usage-limit errors are not retried and tell the user to ask their workspace admin to increase the limit. ### Fixed @@ -25,6 +47,20 @@ - Fixed extensions failing to load when importing `@earendil-works/pi-ai/models` (e.g. for `clampThinkingLevel`) by exposing the subpath through the extension loader aliases and virtual modules ([#11156](https://github.com/earendil-works/pi/issues/11156)) - Fixed `mcp.json` files that start with a UTF-8 BOM failing to parse, which skipped all their MCP servers and made `pi mcp add`/`pi mcp remove` fail ([#11191](https://github.com/earendil-works/pi/issues/11191)) - Fixed `/compact ` ignoring the instructions when summarizing the start of a turn that compaction splits. When the split turn was the only new content since the last compaction, the instructions were ignored entirely ([#11058](https://github.com/earendil-works/pi/issues/11058)) +- Fixed resumed sessions and `/tree` navigation leaving extension tools inactive when the branch recorded its tools before they were registered, such as tools from extensions installed after the session started. Extension tools the branch never added or removed are now active unless they are registered with `defaultActive: false` ([#11062](https://github.com/earendil-works/pi/issues/11062), [#11075](https://github.com/earendil-works/pi/issues/11075)) +- Fixed sessions breaking after recovery from a response cut off at the output limit: the truncated tool call was omitted but its tool results were kept, so providers rejected every later request ([#11227](https://github.com/earendil-works/pi/issues/11227), [#11233](https://github.com/earendil-works/pi/pull/11233) by [@petrroll](https://github.com/petrroll)) +- Fixed the `grep` tool following `RIPGREP_CONFIG_PATH`, where options such as `--fixed-strings` silently changed regex searches into literal ones. The tool now runs `rg --no-config` ([#11203](https://github.com/earendil-works/pi/issues/11203), [#11230](https://github.com/earendil-works/pi/pull/11230) by [@petrroll](https://github.com/petrroll)) +- Fixed Windows shell detection rejecting runnable Microsoft Store shell aliases ([#9504](https://github.com/earendil-works/pi/pull/9504) by [@petrroll](https://github.com/petrroll)) +- Fixed Windows shell detection missing PowerShell outside `PATH` and the legacy WSL `bash.exe` launcher when Windows is not installed in `C:\Windows`. pi now checks `%ProgramFiles%\PowerShell\7`, `%SystemRoot%\System32\WindowsPowerShell\v1.0`, and `%SystemRoot%` ([#9501](https://github.com/earendil-works/pi/pull/9501) by [@petrroll](https://github.com/petrroll)) +- Fixed GitHub Copilot sessions with large request bodies, such as many images, failing with HTTP 413 `failed to parse request` instead of compacting and retrying ([#11016](https://github.com/earendil-works/pi/pull/11016) by [@petrroll](https://github.com/petrroll)) +- Fixed the `openai-codex` provider offering `gpt-5.5` and `gpt-5.3-codex-spark`, which always fail for ChatGPT accounts ([#11173](https://github.com/earendil-works/pi/pull/11173) by [@lucasmeijer](https://github.com/lucasmeijer)) +- Fixed whitespace-only tool output (for example a bare `\r\n` from bash) making OpenAI Completions, OpenAI Responses, and Google requests fail with a 400 ([#8720](https://github.com/earendil-works/pi/issues/8720)) +- Fixed automatic retry not triggering for more gateway upstream failures, `stream disconnected before completion` errors relayed by Codex CLI proxies, and transient Google Vertex OAuth token refresh failures, and retrying errors because of digits inside request ids or byte counts ([#10920](https://github.com/earendil-works/pi/issues/10920), [#11057](https://github.com/earendil-works/pi/issues/11057), [#10996](https://github.com/earendil-works/pi/issues/10996)) +- Fixed OpenAI Responses models losing the prompt cache on every deferred tool load after a tool was removed ([#10940](https://github.com/earendil-works/pi/issues/10940)) +- Fixed Cloudflare Workers AI classifier pricing: Clef Flash costs $0.038 and Jev $0.042 per million input tokens instead of Jev being listed as free. +- Fixed an unclosed `$$` display-math block swallowing or corrupting every Markdown table after it ([#11159](https://github.com/earendil-works/pi/issues/11159)) +- Fixed Markdown bold and italic not rendering next to CJK punctuation ([#10730](https://github.com/earendil-works/pi/pull/10730) by [@theBucky](https://github.com/theBucky)) +- Fixed `@` file autocomplete returning no results for queries containing regex metacharacters such as `[` or `(` ([#10972](https://github.com/earendil-works/pi/issues/10972)) ## [1.3.0] - 2026-10-15 @@ -46,10 +82,6 @@ - Changed the `kimi-coding` provider to use OpenAI Chat Completions at `https://api.kimi.com/coding/v1`, the protocol used by the official Kimi Code client. K3, K3-256K, and Kimi For Coding now accept tools added mid-conversation without invalidating the prompt cache. `models.json` overrides of the `kimi-coding` base URL must now include `/v1` ([#10720](https://github.com/earendil-works/pi/issues/10720)) - Changed model switching to drop thinking blocks from earlier assistant messages produced by a different model, instead of sending them as plain text. The new model previously saw the old model's reasoning as part of its replies and could start writing its own reasoning into the visible answer ([#10995](https://github.com/earendil-works/pi/issues/10995)) -### Changed - -- Changed the default NVIDIA model to `nvidia/nemotron-3.5-super-vl-120b-a12b` ([#10910](https://github.com/earendil-works/pi/pull/10910) by [@cv](https://github.com/cv)) - ### Fixed - Pinned internal Pi dependencies to exact release versions to prevent fresh installs from mixing incompatible packages ([#10865](https://github.com/earendil-works/pi/issues/10865)) diff --git a/packages/coding-agent/docs/custom-provider.md b/packages/coding-agent/docs/custom-provider.md index 248d6d0cd..ec8ea52da 100644 --- a/packages/coding-agent/docs/custom-provider.md +++ b/packages/coding-agent/docs/custom-provider.md @@ -138,6 +138,8 @@ An OAuth provider supplies a display name, login flow, token refresh, and access OAuth callbacks are UI-neutral. They can open an authorization URL, show a device code, report progress, request input, or ask the user to choose a login method. Honor cancellation and the supplied abort signal during network requests. +Press Enter (`tui.select.confirm`) to open the device-login URL and copy the code, or log in manually. Providers can disable either action with `openBrowserOnConfirm: false` or `copyCodeOnConfirm: false` in `onDeviceCode` or native `AuthEvent` notifications. + Never write access tokens, refresh tokens, authorization headers, or complete provider responses to ordinary logs. ## Supply and refresh models diff --git a/packages/coding-agent/docs/models-configuration.md b/packages/coding-agent/docs/models-configuration.md index 04787a77c..31bf4b871 100644 --- a/packages/coding-agent/docs/models-configuration.md +++ b/packages/coding-agent/docs/models-configuration.md @@ -185,3 +185,12 @@ Also applies to `azure-openai-responses` and `openai-codex-responses`. | Field | Type | Default | Description | |---|---|---|---| | `supportsMidConvoSystemMessages` | boolean | `false` | Whether the exact model accepts system messages after the conversation has started. When false, later system messages are folded into the leading system message. | + +### `typesafe-system-one` + +Also applies to `cloudflare-workers-ai-system-one`. + +| Field | Type | Default | Description | +|---|---|---|---| +| `imageInput` | `"assets" \| "images" \| "state-image-urls"` | | How the service takes classifier images. "assets" sends TypeSafe's assets map (PNG and JPEG) and references the images from the state, as TypeSafe and Jev on Workers AI expect. "images" sends Clef's images list of data URLs (PNG, JPEG, and WebP, at most four), as Clef on Workers AI expects. "state-image-urls" places image_url parts before the state, as OpenRouter expects; OpenRouter drops assets without an error. Auto-detected from the provider, baseUrl, and model ID. Classifier models read it from the provider-level compat; models and modelOverrides entries apply to chat models only. | +| `sendImageOptInHeader` | boolean | | Whether requests with images send the TypeSafe-Danger-Zone-Images: true header, which TypeSafe's own API requires for its experimental image input. Defaults to true for the typesafe-system-one API unless the model is detected as OpenRouter. | diff --git a/packages/coding-agent/docs/models.md b/packages/coding-agent/docs/models.md index 2014aee50..e076ac139 100644 --- a/packages/coding-agent/docs/models.md +++ b/packages/coding-agent/docs/models.md @@ -141,20 +141,20 @@ Compatibility settings should describe verified differences in the endpoint's re ## Use classifier models -Classifier models do not chat. They answer typed questions about JSON state: pick one of several choices, answer yes or no, or give a score, each with probabilities. Pi includes TypeSafe's Jev model from these providers, Cloudflare's Clef and Clef Flash models from Workers AI, and OpenAI's GPT-6 Luna through the [Decisions API](https://developers.openai.com/api/docs/guides/decisions): +Classifier models do not chat. They answer typed questions about JSON state: pick one of several choices, answer yes or no, or give a score, each with probabilities. Pi includes TypeSafe's Jev model from these providers, Cloudflare's Clef, Clef Flash, and Clef Omni models from Workers AI and OpenRouter, and OpenAI's GPT-6 Luna through the [Decisions API](https://developers.openai.com/api/docs/guides/decisions): | Provider | Model IDs | Authentication | |---|---|---| | `typesafe` | `jev-latest` | `TYPESAFE_API_KEY` | -| `openrouter` | `typesafe/jev-1.13`, `~typesafe/jev-latest` | `OPENROUTER_API_KEY` or `/login` | -| `cloudflare-workers-ai` | `typesafe/jev`, `@cf/cloudflare/clef`, `@cf/cloudflare/clef-flash` | `CLOUDFLARE_API_KEY` and `CLOUDFLARE_ACCOUNT_ID` | +| `openrouter` | `typesafe/jev-1.13`, `typesafe/jev-1.14`, `~typesafe/jev-latest`, `cloudflare/clef`, `cloudflare/clef-flash`, `cloudflare/clef-omni` | `OPENROUTER_API_KEY` or `/login` | +| `cloudflare-workers-ai` | `typesafe/jev`, `typesafe/jev-1.14.0`, `@cf/cloudflare/clef`, `@cf/cloudflare/clef-flash`, `@cf/cloudflare/clef-omni` | `CLOUDFLARE_API_KEY` and `CLOUDFLARE_ACCOUNT_ID` | | `vercel-ai-gateway` | `typesafe-ai/jev` | `AI_GATEWAY_API_KEY` | | `opencode` | `jev-1.13`, `jev-1.13-free` | `OPENCODE_API_KEY` | | `openai` | `gpt-6-luna` | `OPENAI_API_KEY` | Chat models on a [llama.cpp router](llama-cpp.md#classification) are also listed as classifier models. -OpenAI's Decisions API needs an API key. Sign in with ChatGPT credentials do not work with it, so `gpt-6-luna` is not listed as available while `openai` is logged in through `/login`, even when `OPENAI_API_KEY` is set; log out of `openai` to use the key. GPT-6 Luna also judges images passed in `images` (see [Codemode](codemode.md#classify)); other classifier models return an error for them. The API rejects inputs above 922K tokens, but requests that run longer than about five seconds, currently above roughly 600K input tokens, fail with a gateway timeout. +OpenAI's Decisions API needs an API key. Sign in with ChatGPT credentials do not work with it, so `gpt-6-luna` is not listed as available while `openai` is logged in through `/login`, even when `OPENAI_API_KEY` is set; log out of `openai` to use the key. The API rejects inputs above 922K tokens, but requests that run longer than about five seconds, currently above roughly 600K input tokens, fail with a gateway timeout. Classifier models do not appear in `/model`. The model reaches them through the [`codemode`](cli.md#enable-codemode) tool, which is off unless an MCP server turned it on. Enable it with `"defaultTools": ["+codemode"]` in [settings](settings.md#tools). Scripts then list classifier models with `models.getAvailableOfType("classifier")` and call `models.classify(model, { state, questions })`: @@ -175,6 +175,10 @@ return result.answers; [Codemode](codemode.md#classify) describes the question and answer types. +Classifier models whose `input` includes `"image"` also judge images passed in `images`; other classifier models return an error for them. Image input works with GPT-6 Luna, with Jev from `typesafe` and `cloudflare-workers-ai`, with Clef from `cloudflare-workers-ai`, and with the OpenRouter models whose catalog lists image input. Jev takes PNG and JPEG images, and image input is experimental on TypeSafe's own API. Clef takes PNG, JPEG, and WebP images, at most four per request. Jev from `vercel-ai-gateway` and `opencode` takes text only. + +Each System One service takes images in its own request format, set by the model's `compat.imageInput`. Built-in models set it, so overriding a provider's `baseUrl` keeps it. A custom provider that proxies another service's classifiers sets it in its provider-level `compat` in `models.json` (see [System One compatibility](models-configuration.md#typesafe-system-one)). OpenRouter in particular answers requests in TypeSafe's format without an error but drops the images. + When the service reports token counts, as all System One services do, `result.usage` carries them with their cost. Pi adds the usage of a script's classifier calls to the `codemode` tool result, so it counts toward the session cost in the footer and `/session`. The cost uses the model's catalog price; models without one, such as TypeSafe's direct `jev-latest`, report tokens at no cost. Extensions call classifiers through `ctx.modelRegistry.classify()`, without codemode. [Virtual models](virtual-models.md#route-requests) can use them to route requests; see the `jev-router.ts` example. diff --git a/packages/coding-agent/examples/extensions/custom-provider-anthropic/package-lock.json b/packages/coding-agent/examples/extensions/custom-provider-anthropic/package-lock.json index 7a9cc6902..b7d2ebab0 100644 --- a/packages/coding-agent/examples/extensions/custom-provider-anthropic/package-lock.json +++ b/packages/coding-agent/examples/extensions/custom-provider-anthropic/package-lock.json @@ -1,12 +1,12 @@ { "name": "pi-extension-custom-provider", - "version": "1.3.0", + "version": "1.4.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "pi-extension-custom-provider", - "version": "1.3.0", + "version": "1.4.0", "dependencies": { "@anthropic-ai/sdk": "^0.52.0" } diff --git a/packages/coding-agent/examples/extensions/custom-provider-anthropic/package.json b/packages/coding-agent/examples/extensions/custom-provider-anthropic/package.json index 4f3d296a5..b22368863 100644 --- a/packages/coding-agent/examples/extensions/custom-provider-anthropic/package.json +++ b/packages/coding-agent/examples/extensions/custom-provider-anthropic/package.json @@ -1,7 +1,7 @@ { "name": "pi-extension-custom-provider-anthropic", "private": true, - "version": "1.3.0", + "version": "1.4.0", "type": "module", "scripts": { "clean": "echo 'nothing to clean'", diff --git a/packages/coding-agent/examples/extensions/custom-provider-gitlab-duo/package.json b/packages/coding-agent/examples/extensions/custom-provider-gitlab-duo/package.json index f6fff9cf9..dbdc63c72 100644 --- a/packages/coding-agent/examples/extensions/custom-provider-gitlab-duo/package.json +++ b/packages/coding-agent/examples/extensions/custom-provider-gitlab-duo/package.json @@ -1,7 +1,7 @@ { "name": "pi-extension-custom-provider-gitlab-duo", "private": true, - "version": "1.3.0", + "version": "1.4.0", "type": "module", "scripts": { "clean": "echo 'nothing to clean'", diff --git a/packages/coding-agent/examples/extensions/gondolin/package-lock.json b/packages/coding-agent/examples/extensions/gondolin/package-lock.json index 9ad047db2..99a06ff69 100644 --- a/packages/coding-agent/examples/extensions/gondolin/package-lock.json +++ b/packages/coding-agent/examples/extensions/gondolin/package-lock.json @@ -1,14 +1,14 @@ { "name": "pi-extension-gondolin", - "version": "1.3.0", + "version": "1.4.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "pi-extension-gondolin", - "version": "1.3.0", + "version": "1.4.0", "dependencies": { - "@earendil-works/gondolin": "0.12.0" + "@earendil-works/gondolin": "0.14.0" } }, "node_modules/@cto.af/wtf8": { @@ -21,31 +21,30 @@ } }, "node_modules/@earendil-works/gondolin": { - "version": "0.12.0", - "resolved": "https://registry.npmjs.org/@earendil-works/gondolin/-/gondolin-0.12.0.tgz", - "integrity": "sha512-BXbvzQKb5QmxY5NtthRDONJTu7+IDKbzqWGrJyyNXMP7N681Tx0Q9TK8pK1ba8nUvYQTipNJyGZOsJfYiZll1A==", + "version": "0.14.0", + "resolved": "https://registry.npmjs.org/@earendil-works/gondolin/-/gondolin-0.14.0.tgz", + "integrity": "sha512-MSw+EGcqFJeyKHdXEfm+nMRr74ytT4x8DGIohav0oPCHeVQAB9AajquM94cv2xqvIU3VDAaZZVC07IXsOY6OVQ==", "license": "Apache-2.0", "dependencies": { "cbor2": "^2.3.0", - "node-forge": "^1.3.3", "ssh2": "^1.17.0", - "undici": "^6.21.0" + "undici": "^6.29.0" }, "bin": { "gondolin": "dist/bin/gondolin.js" }, "engines": { - "node": ">=23.6.0" + "node": ">=22.19.0" }, "optionalDependencies": { - "@earendil-works/gondolin-krun-runner-darwin-arm64": "0.12.0", - "@earendil-works/gondolin-krun-runner-linux-x64": "0.12.0" + "@earendil-works/gondolin-krun-runner-darwin-arm64": "0.14.0", + "@earendil-works/gondolin-krun-runner-linux-x64": "0.14.0" } }, "node_modules/@earendil-works/gondolin-krun-runner-darwin-arm64": { - "version": "0.12.0", - "resolved": "https://registry.npmjs.org/@earendil-works/gondolin-krun-runner-darwin-arm64/-/gondolin-krun-runner-darwin-arm64-0.12.0.tgz", - "integrity": "sha512-ftDlusht4PcT7Y3TuPrZIKrCXy3isiBTVMvlXYK0pcud2uXY6uwFTGeunYgP+8ND/60ddb+MImqbfmkcK8B84A==", + "version": "0.14.0", + "resolved": "https://registry.npmjs.org/@earendil-works/gondolin-krun-runner-darwin-arm64/-/gondolin-krun-runner-darwin-arm64-0.14.0.tgz", + "integrity": "sha512-fvuhz1CZPaEbmXC0luZoXd1obAb0bjaaGJz33Jo63bhMlosYunAahItakh7cU0dnTlNFzufOSnpGOPqtuYjnpQ==", "cpu": [ "arm64" ], @@ -59,9 +58,9 @@ } }, "node_modules/@earendil-works/gondolin-krun-runner-linux-x64": { - "version": "0.12.0", - "resolved": "https://registry.npmjs.org/@earendil-works/gondolin-krun-runner-linux-x64/-/gondolin-krun-runner-linux-x64-0.12.0.tgz", - "integrity": "sha512-RRYsgwe2r5ApKmFNy469QgwnyjAHpAs9XANdWpTd9ol4iUYOY3sX7e0xIooAKxd+ktxGI4N/xRWicwGen3D/Ow==", + "version": "0.14.0", + "resolved": "https://registry.npmjs.org/@earendil-works/gondolin-krun-runner-linux-x64/-/gondolin-krun-runner-linux-x64-0.14.0.tgz", + "integrity": "sha512-91iw4smoOH/yOoAuQS6N2UZOxqSFwRhtk8rTyrokXBoWiWcveAzg4kHvnLcm40ZlNrWwzIDFrere4bkZeGDQcA==", "cpu": [ "x64" ], @@ -134,15 +133,6 @@ "license": "MIT", "optional": true }, - "node_modules/node-forge": { - "version": "1.4.0", - "resolved": "https://registry.npmjs.org/node-forge/-/node-forge-1.4.0.tgz", - "integrity": "sha512-LarFH0+6VfriEhqMMcLX2F7SwSXeWwnEAJEsYm5QKWchiVYVvJyV9v7UDvUv+w5HO23ZpQTXDv/GxdDdMyOuoQ==", - "license": "(BSD-3-Clause OR GPL-2.0)", - "engines": { - "node": ">= 6.13.0" - } - }, "node_modules/safer-buffer": { "version": "2.1.2", "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", @@ -173,9 +163,9 @@ "license": "Unlicense" }, "node_modules/undici": { - "version": "6.26.0", - "resolved": "https://registry.npmjs.org/undici/-/undici-6.26.0.tgz", - "integrity": "sha512-4yqz8a3n5HmGTlsbADNtr/dJlhkh/55Rq798G6ibiULcXbDtaLpTl1pvdqcbFfeoj3iSi52lePFM7h9H21cw/A==", + "version": "6.29.1", + "resolved": "https://registry.npmjs.org/undici/-/undici-6.29.1.tgz", + "integrity": "sha512-EvRlUt/QqAEvXdQeRMYUfwOQsfzpDztykrG2owGEE/rBOBoYv9uF39ZzsNDh4lTFHhTr2XOZjKkYgueD12ZmGA==", "license": "MIT", "engines": { "node": ">=18.17" diff --git a/packages/coding-agent/examples/extensions/gondolin/package.json b/packages/coding-agent/examples/extensions/gondolin/package.json index 76826f308..d19d7190f 100644 --- a/packages/coding-agent/examples/extensions/gondolin/package.json +++ b/packages/coding-agent/examples/extensions/gondolin/package.json @@ -1,7 +1,7 @@ { "name": "pi-extension-gondolin", "private": true, - "version": "1.3.0", + "version": "1.4.0", "type": "module", "scripts": { "clean": "echo 'nothing to clean'", @@ -14,6 +14,6 @@ ] }, "dependencies": { - "@earendil-works/gondolin": "0.12.0" + "@earendil-works/gondolin": "0.14.0" } } diff --git a/packages/coding-agent/examples/extensions/sandbox/package-lock.json b/packages/coding-agent/examples/extensions/sandbox/package-lock.json index fbe7ec7c6..90ef1c2f5 100644 --- a/packages/coding-agent/examples/extensions/sandbox/package-lock.json +++ b/packages/coding-agent/examples/extensions/sandbox/package-lock.json @@ -1,12 +1,12 @@ { "name": "pi-extension-sandbox", - "version": "1.3.0", + "version": "1.4.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "pi-extension-sandbox", - "version": "1.3.0", + "version": "1.4.0", "dependencies": { "@anthropic-ai/sandbox-runtime": "^0.0.26" } diff --git a/packages/coding-agent/examples/extensions/sandbox/package.json b/packages/coding-agent/examples/extensions/sandbox/package.json index 5d81664e1..8059d577d 100644 --- a/packages/coding-agent/examples/extensions/sandbox/package.json +++ b/packages/coding-agent/examples/extensions/sandbox/package.json @@ -1,7 +1,7 @@ { "name": "pi-extension-sandbox", "private": true, - "version": "1.3.0", + "version": "1.4.0", "type": "module", "scripts": { "clean": "echo 'nothing to clean'", diff --git a/packages/coding-agent/examples/extensions/with-deps/package-lock.json b/packages/coding-agent/examples/extensions/with-deps/package-lock.json index ba9a0ce51..90c4e6339 100644 --- a/packages/coding-agent/examples/extensions/with-deps/package-lock.json +++ b/packages/coding-agent/examples/extensions/with-deps/package-lock.json @@ -1,12 +1,12 @@ { "name": "pi-extension-with-deps", - "version": "1.3.0", + "version": "1.4.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "pi-extension-with-deps", - "version": "1.3.0", + "version": "1.4.0", "dependencies": { "ms": "^2.1.3" }, diff --git a/packages/coding-agent/examples/extensions/with-deps/package.json b/packages/coding-agent/examples/extensions/with-deps/package.json index 846881008..f22d436c7 100644 --- a/packages/coding-agent/examples/extensions/with-deps/package.json +++ b/packages/coding-agent/examples/extensions/with-deps/package.json @@ -1,7 +1,7 @@ { "name": "pi-extension-with-deps", "private": true, - "version": "1.3.0", + "version": "1.4.0", "type": "module", "scripts": { "clean": "echo 'nothing to clean'", diff --git a/packages/coding-agent/install-lock/package-lock.json b/packages/coding-agent/install-lock/package-lock.json index e43090842..5d242776c 100644 --- a/packages/coding-agent/install-lock/package-lock.json +++ b/packages/coding-agent/install-lock/package-lock.json @@ -1,14 +1,14 @@ { "name": "@earendil-works/pi-coding-agent-install", - "version": "1.3.0", + "version": "1.4.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@earendil-works/pi-coding-agent-install", - "version": "1.3.0", + "version": "1.4.0", "dependencies": { - "@earendil-works/pi-coding-agent": "1.3.0" + "@earendil-works/pi-coding-agent": "1.4.0" }, "engines": { "node": ">=22.19.0" @@ -386,11 +386,11 @@ } }, "node_modules/@earendil-works/pi-agent-core": { - "version": "1.3.0", - "resolved": "https://registry.npmjs.org/@earendil-works/pi-agent-core/-/pi-agent-core-1.3.0.tgz", + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/@earendil-works/pi-agent-core/-/pi-agent-core-1.4.0.tgz", "license": "MIT", "dependencies": { - "@earendil-works/pi-ai": "1.3.0", + "@earendil-works/pi-ai": "1.4.0", "typebox": "1.3.27" }, "engines": { @@ -398,13 +398,13 @@ } }, "node_modules/@earendil-works/pi-ai": { - "version": "1.3.0", - "resolved": "https://registry.npmjs.org/@earendil-works/pi-ai/-/pi-ai-1.3.0.tgz", + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/@earendil-works/pi-ai/-/pi-ai-1.4.0.tgz", "license": "MIT", "dependencies": { "@anthropic-ai/sdk": "0.129.0", "@aws-sdk/client-bedrock-runtime": "3.1127.0", - "@earendil-works/pi-telemetry": "1.3.0", + "@earendil-works/pi-telemetry": "1.4.0", "@google/genai": "2.21.0", "@smithy/node-http-handler": "4.12.1", "http-proxy-agent": "9.1.0", @@ -481,8 +481,8 @@ } }, "node_modules/@earendil-works/pi-codemode": { - "version": "1.3.0", - "resolved": "https://registry.npmjs.org/@earendil-works/pi-codemode/-/pi-codemode-1.3.0.tgz", + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/@earendil-works/pi-codemode/-/pi-codemode-1.4.0.tgz", "license": "MIT", "dependencies": { "quickjs-wasi": "3.6.2" @@ -492,15 +492,15 @@ } }, "node_modules/@earendil-works/pi-coding-agent": { - "version": "1.3.0", - "resolved": "https://registry.npmjs.org/@earendil-works/pi-coding-agent/-/pi-coding-agent-1.3.0.tgz", + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/@earendil-works/pi-coding-agent/-/pi-coding-agent-1.4.0.tgz", "license": "MIT", "dependencies": { - "@earendil-works/pi-agent-core": "1.3.0", - "@earendil-works/pi-ai": "1.3.0", - "@earendil-works/pi-codemode": "1.3.0", - "@earendil-works/pi-mcp": "1.3.0", - "@earendil-works/pi-tui": "1.3.0", + "@earendil-works/pi-agent-core": "1.4.0", + "@earendil-works/pi-ai": "1.4.0", + "@earendil-works/pi-codemode": "1.4.0", + "@earendil-works/pi-mcp": "1.4.0", + "@earendil-works/pi-tui": "1.4.0", "@silvia-odwyer/photon-node": "0.3.4", "brace-expansion": "5.0.12", "chalk": "6.0.0", @@ -527,8 +527,8 @@ } }, "node_modules/@earendil-works/pi-mcp": { - "version": "1.3.0", - "resolved": "https://registry.npmjs.org/@earendil-works/pi-mcp/-/pi-mcp-1.3.0.tgz", + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/@earendil-works/pi-mcp/-/pi-mcp-1.4.0.tgz", "license": "MIT", "dependencies": { "cross-spawn": "7.0.6" @@ -538,16 +538,16 @@ } }, "node_modules/@earendil-works/pi-telemetry": { - "version": "1.3.0", - "resolved": "https://registry.npmjs.org/@earendil-works/pi-telemetry/-/pi-telemetry-1.3.0.tgz", + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/@earendil-works/pi-telemetry/-/pi-telemetry-1.4.0.tgz", "license": "MIT", "engines": { "node": ">=22.19.0" } }, "node_modules/@earendil-works/pi-tui": { - "version": "1.3.0", - "resolved": "https://registry.npmjs.org/@earendil-works/pi-tui/-/pi-tui-1.3.0.tgz", + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/@earendil-works/pi-tui/-/pi-tui-1.4.0.tgz", "license": "MIT", "dependencies": { "get-east-asian-width": "1.6.0", diff --git a/packages/coding-agent/install-lock/package.json b/packages/coding-agent/install-lock/package.json index 4f9d4f9aa..004cf3c90 100644 --- a/packages/coding-agent/install-lock/package.json +++ b/packages/coding-agent/install-lock/package.json @@ -1,10 +1,10 @@ { "name": "@earendil-works/pi-coding-agent-install", - "version": "1.3.0", + "version": "1.4.0", "private": true, "description": "Lockfile root used by the Pi installer and updater.", "dependencies": { - "@earendil-works/pi-coding-agent": "1.3.0" + "@earendil-works/pi-coding-agent": "1.4.0" }, "overrides": { "protobufjs": "7.6.6" diff --git a/packages/coding-agent/package.json b/packages/coding-agent/package.json index 33ac1bcfd..616d6a475 100644 --- a/packages/coding-agent/package.json +++ b/packages/coding-agent/package.json @@ -1,6 +1,6 @@ { "name": "@earendil-works/pi-coding-agent", - "version": "1.3.0", + "version": "1.4.0", "description": "Coding agent CLI with read, bash, edit, write tools and session management", "type": "module", "piConfig": { @@ -41,11 +41,11 @@ "prepublishOnly": "npm run clean && npm run build" }, "dependencies": { - "@earendil-works/pi-agent-core": "1.3.0", - "@earendil-works/pi-ai": "1.3.0", - "@earendil-works/pi-codemode": "1.3.0", - "@earendil-works/pi-mcp": "1.3.0", - "@earendil-works/pi-tui": "1.3.0", + "@earendil-works/pi-agent-core": "1.4.0", + "@earendil-works/pi-ai": "1.4.0", + "@earendil-works/pi-codemode": "1.4.0", + "@earendil-works/pi-mcp": "1.4.0", + "@earendil-works/pi-tui": "1.4.0", "@silvia-odwyer/photon-node": "0.3.4", "brace-expansion": "5.0.12", "chalk": "6.0.0", @@ -75,7 +75,7 @@ "@types/semver": "7.7.1", "marked": "18.0.11", "shx": "0.3.4", - "vitest": "4.1.11" + "vitest": "4.1.12" }, "keywords": [ "coding-agent", diff --git a/packages/coding-agent/schemas/models.schema.json b/packages/coding-agent/schemas/models.schema.json index 322602d32..39ec3d63e 100644 --- a/packages/coding-agent/schemas/models.schema.json +++ b/packages/coding-agent/schemas/models.schema.json @@ -1107,6 +1107,27 @@ }, "maxItems": 3, "description": "Models Anthropic accepts for server-side refusal fallback, with local pricing metadata for returned fallback responses. When absent or empty, callers must omit fallbacks; Anthropic rejects the field for models with no permitted fallback targets." + }, + "imageInput": { + "anyOf": [ + { + "type": "string", + "const": "assets" + }, + { + "type": "string", + "const": "images" + }, + { + "type": "string", + "const": "state-image-urls" + } + ], + "description": "How the service takes classifier images. \"assets\" sends TypeSafe's assets map (PNG and JPEG) and references the images from the state, as TypeSafe and Jev on Workers AI expect. \"images\" sends Clef's images list of data URLs (PNG, JPEG, and WebP, at most four), as Clef on Workers AI expects. \"state-image-urls\" places image_url parts before the state, as OpenRouter expects; OpenRouter drops assets without an error. Auto-detected from the provider, baseUrl, and model ID. Classifier models read it from the provider-level compat; models and modelOverrides entries apply to chat models only." + }, + "sendImageOptInHeader": { + "type": "boolean", + "description": "Whether requests with images send the TypeSafe-Danger-Zone-Images: true header, which TypeSafe's own API requires for its experimental image input. Defaults to true for the typesafe-system-one API unless the model is detected as OpenRouter." } }, "additionalProperties": true diff --git a/packages/coding-agent/src/core/agent-session.ts b/packages/coding-agent/src/core/agent-session.ts index 8b330d555..3a9174cf6 100644 --- a/packages/coding-agent/src/core/agent-session.ts +++ b/packages/coding-agent/src/core/agent-session.ts @@ -31,7 +31,7 @@ import { runToolCall, type ThinkingLevel, } from "@earendil-works/pi-agent-core"; -import { contentText, getCurrentSystemMessage, retryDelayMs } from "@earendil-works/pi-ai"; +import { contentText, getCurrentSystemMessage, getCurrentTools, retryDelayMs } from "@earendil-works/pi-ai"; import type { AssistantMessage, AuthResult, @@ -427,7 +427,6 @@ export class AgentSession { private readonly _entryIdsByMessage = new WeakMap(); private readonly _boundaryDispatchedMessages = new WeakSet(); private _lastAssistantMessage: AssistantMessage | undefined; - private _lastAssistantToolResults: AgentMessage[] = []; private _lastActivityOutcome: AgentActivityOutcome = "completed"; private _isBeforeSettle = false; private _abortDuringBeforeSettle = false; @@ -1194,7 +1193,6 @@ export class AgentSession { // extension and listener dispatch above also picks up messages that turn_end // handlers queued. if (event.type === "turn_end") { - this._lastAssistantToolResults = event.toolResults; this._flushPendingCustomMessages(); } }; @@ -1238,7 +1236,24 @@ export class AgentSession { return undefined; } - private _omitRecoveryAttempt(message: AssistantMessage, toolResults: AgentMessage[] = []): void { + /** + * Persistently omit a failed attempt and its tool results from model context. Results are + * found in the projected context, so the pair stays together whether the attempt comes from + * the current run or a resumed session. An unpaired result fails the next request (#11227). + */ + private _omitRecoveryAttempt(message: AssistantMessage): void { + const toolCallIds = new Set(message.content.flatMap((block) => (block.type === "toolCall" ? [block.id] : []))); + // A context edit replaces the projected copy, so match it by source entry as well. + const entryId = this._findPersistedMessageEntryId(message); + const messages = this.agent.state.messages; + const index = messages.findIndex( + (m) => m === message || (entryId !== undefined && this._entryIdsByMessage.get(m) === entryId), + ); + const toolResults: AgentMessage[] = []; + for (const m of index < 0 ? [] : messages.slice(index + 1)) { + if (m.role === "assistant") break; + if (m.role === "toolResult" && toolCallIds.has(m.toolCallId)) toolResults.push(m); + } const targets = [message, ...toolResults]; const targetIds = targets.map((target) => this._findPersistedMessageEntryId(target)); const unresolvedProjectedTarget = targets.some( @@ -1814,13 +1829,34 @@ export class AgentSession { */ private _restoreToolsFromTranscript(modifiers: readonly string[] = []): void { this._pendingToolNames.clear(); - const current = getCurrentSystemMessage(this.sessionManager.buildSessionContext().messages); - if (!current) return; + // All system messages on the branch, including those before a compaction. Compaction snapshots + // are derived from these, so replaying them gives the current loadout. + const systemMessages = this.sessionManager + .getBranch() + .flatMap((entry) => (entry.type === "message" && entry.message.role === "system" ? [entry.message] : [])); + // No recorded loadout: keep the current tools. + if (systemMessages.length === 0) return; + // Tools the branch ever added or removed. Removed tools stay inactive. + const mentioned = new Set( + systemMessages + .flatMap((message) => [...(message.toolsAdded ?? []), ...(message.toolsRemoved ?? [])]) + .map((tool) => tool.name), + ); + // Extension tools the branch never mentioned were registered after it recorded its loadout, so + // they are activated like newly registered tools. Built-in tools follow `defaultTools` instead. + const newTools = [...this._toolRegistry.keys()].filter( + (name) => + !mentioned.has(name) && !this._baseToolDefinitions.has(name) && this._isActivatedOnRegistration(name), + ); + // The recorded loadout plus the new tools, with `--tools +name`/`-name` applied. const names = applyToolModifiers( - (current.toolsAdded ?? []).map((tool) => tool.name), + [...getCurrentTools(systemMessages).map((tool) => tool.name), ...newTools], modifiers, ); + // Recorded tools that are not registered yet, such as MCP tools of servers still connecting, are + // activated when they register. Excluded tools never register, so they are not kept pending. this._pendingToolNames = new Set(names.filter((name) => this._isAllowedTool(name))); + // Ignores names that are not registered. this._setActiveTools(names); } @@ -1942,9 +1978,7 @@ export class AgentSession { private async _handlePostAgentRun(): Promise { const message = this._lastAssistantMessage; - const toolResults = this._lastAssistantToolResults; this._lastAssistantMessage = undefined; - this._lastAssistantToolResults = []; if (this._agentRunAbortRequested) { this._finishCancelledRetry(); return false; @@ -1971,7 +2005,7 @@ export class AgentSession { this._retryAttempt = 0; } - if (await this._checkCompaction(message, true, toolResults)) { + if (await this._checkCompaction(message)) { return !this._agentRunAbortRequested; } @@ -2974,11 +3008,7 @@ export class AgentSession { * @param skipAbortedCheck If false, include aborted messages (for pre-prompt check). Default: true * @returns Whether the post-run loop should call `agent.continue()` for overflow recovery or queued messages */ - private async _checkCompaction( - assistantMessage: AssistantMessage, - skipAbortedCheck = true, - toolResults: AgentMessage[] = [], - ): Promise { + private async _checkCompaction(assistantMessage: AssistantMessage, skipAbortedCheck = true): Promise { const settings = this.settingsManager.getCompactionSettings(this.model); if (!settings.enabled) return false; @@ -3070,7 +3100,7 @@ export class AgentSession { // Persistently omit the selected final attempt before post-run recovery compaction. this._overflowRecoveryAttempted = true; - this._omitRecoveryAttempt(assistantMessage, toolResults); + this._omitRecoveryAttempt(assistantMessage); const retry = await this._runAutoCompaction("overflow", willRetry); if (retry) this._failedResponse = assistantMessage; return retry; diff --git a/packages/coding-agent/src/core/exec.ts b/packages/coding-agent/src/core/exec.ts index 5afe9ec36..53e53366a 100644 --- a/packages/coding-agent/src/core/exec.ts +++ b/packages/coding-agent/src/core/exec.ts @@ -96,12 +96,12 @@ export async function execCommand( } resolve({ stdout, stderr, code: code ?? 0, killed }); }) - .catch((_err) => { + .catch((error: Error) => { if (timeoutId) clearTimeout(timeoutId); if (options?.signal) { options.signal.removeEventListener("abort", killProcess); } - resolve({ stdout, stderr, code: 1, killed }); + resolve({ stdout, stderr: stderr || error.message, code: 1, killed }); }); }); } diff --git a/packages/coding-agent/src/core/export-html/template.css b/packages/coding-agent/src/core/export-html/template.css index 1de5bf46b..2e2489994 100644 --- a/packages/coding-agent/src/core/export-html/template.css +++ b/packages/coding-agent/src/core/export-html/template.css @@ -687,6 +687,15 @@ display: block; } + .system-entry > .message-timestamp { + padding-left: var(--line-height); + margin-bottom: 4px; + } + + .system-entry > :last-child { + margin-bottom: 0; + } + .system-prompt.provider-prompt { border-left: 3px solid var(--warning); } diff --git a/packages/coding-agent/src/core/export-html/template.js b/packages/coding-agent/src/core/export-html/template.js index f845fce75..37022818d 100644 --- a/packages/coding-agent/src/core/export-html/template.js +++ b/packages/coding-agent/src/core/export-html/template.js @@ -98,14 +98,12 @@ } } - // Sort children by timestamp - function sortChildren(node) { + // Sort children by timestamp. Iterative: long sessions form chains deeper than the JS call stack. + for (const node of nodeMap.values()) { node.children.sort((a, b) => new Date(a.entry.timestamp).getTime() - new Date(b.entry.timestamp).getTime() ); - node.children.forEach(sortChildren); } - roots.forEach(sortChildren); return roots; } @@ -134,14 +132,14 @@ const path = []; let current = byId.get(targetId); while (current) { - path.unshift(current); + path.push(current); // Stop if no parent or self-referencing (root) if (!current.parentId || current.parentId === current.id) { break; } current = byId.get(current.parentId); } - return path; + return path.reverse(); } // Tree node lookup for finding leaves @@ -156,12 +154,12 @@ // Build tree node map lazily if (!treeNodeMap) { treeNodeMap = new Map(); - const tree = buildTree(); - function mapNodes(node) { + const stack = [...buildTree()]; + while (stack.length > 0) { + const node = stack.pop(); treeNodeMap.set(node.entry.id, node); - node.children.forEach(mapNodes); + for (const child of node.children) stack.push(child); } - tree.forEach(mapNodes); } const node = treeNodeMap.get(nodeId); @@ -185,16 +183,22 @@ const multipleRoots = roots.length > 1; // Mark which subtrees contain the active leaf + // Iterative post-order: long sessions form chains deeper than the JS call stack. const containsActive = new Map(); - function markActive(node) { - let has = activePathIds.has(node.entry.id); - for (const child of node.children) { - if (markActive(child)) has = true; - } - containsActive.set(node, has); - return has; + const postOrder = []; + const pending = [...roots]; + while (pending.length > 0) { + const node = pending.pop(); + postOrder.push(node); + for (const child of node.children) pending.push(child); + } + for (let i = postOrder.length - 1; i >= 0; i--) { + const node = postOrder[i]; + containsActive.set( + node, + activePathIds.has(node.entry.id) || node.children.some(child => containsActive.get(child)) + ); } - roots.forEach(markActive); // Stack: [node, indent, justBranched, showConnector, isLast, gutters, isVirtualRootChild] const stack = []; @@ -313,6 +317,76 @@ return ''; } + // ============================================================ + // SYSTEM MESSAGES (pure data) + // ============================================================ + + function systemContentText(content) { + if (typeof content === 'string') return content; + if (Array.isArray(content)) { + return content.filter(c => c.type === 'text' && c.text).map(c => c.text).join('\n'); + } + return ''; + } + + /** Full prompt text of a system message, mirroring getSystemMessageText() in pi-ai. */ + function getSystemMessageText(msg) { + const parts = [systemContentText(msg.content)]; + for (const value of Object.values(msg.sections || {})) { + if (value !== null) parts.push(value); + } + return parts.filter(part => part.length > 0).join('\n\n'); + } + + /** Text of a later system message, mirroring renderSystemMessageUpdate() in pi-ai. */ + function renderSystemMessageUpdate(msg) { + const parts = []; + const text = systemContentText(msg.content); + if (text.length > 0) parts.push(text); + for (const [name, value] of Object.entries(msg.sections || {})) { + parts.push(value === null + ? `Removed system prompt section "${name}".` + : `Updated system prompt section "${name}":\n\n${value}`); + } + return parts.join('\n\n'); + } + + function carriesSystemState(entry) { + return (entry.type === 'message' && entry.message.role === 'system') || + (entry.type === 'compaction' && entry.systemMessage); + } + + /** + * Whether a system entry starts the prompt on its branch. Later system entries + * patch the prompt, so they render as updates instead of a full prompt. + */ + function isLeadingSystemEntry(entry) { + let current = entry.parentId && entry.parentId !== entry.id ? byId.get(entry.parentId) : undefined; + while (current) { + if (carriesSystemState(current)) return false; + if (!current.parentId || current.parentId === current.id) break; + current = byId.get(current.parentId); + } + return true; + } + + /** Short description of what a later system message changes, e.g. "project_context, -rules, tools +bash". */ + function describeSystemUpdate(msg) { + const parts = []; + if (systemContentText(msg.content).length > 0) parts.push('instructions'); + for (const [name, value] of Object.entries(msg.sections || {})) { + parts.push(value === null ? `-${name}` : name); + } + const toolChanges = [ + ...(msg.toolsAdded || []).map(t => `+${t.name}`), + ...(msg.toolsRemoved || []).map(t => `-${t.name}`), + ]; + if (toolChanges.length > 0) parts.push(`tools ${toolChanges.join(' ')}`); + return parts.join(', '); + } + + const hasSystemEntries = entries.some(entry => entry.type === 'message' && entry.message.role === 'system'); + /** * Parse a skill block from message text. * Returns null if the text doesn't contain a skill block. @@ -337,6 +411,13 @@ case 'message': { const msg = entry.message; parts.push(msg.role); + if (msg.role === 'system') { + parts.push(isLeadingSystemEntry(entry) ? 'system prompt' : 'system update'); + parts.push(getSystemMessageText(msg)); + for (const name of Object.keys(msg.sections || {})) parts.push(name); + for (const tool of [...(msg.toolsAdded || []), ...(msg.toolsRemoved || [])]) parts.push(tool.name); + break; + } if (msg.content) parts.push(extractContent(msg.content)); if (msg.role === 'bashExecution' && msg.command) parts.push(msg.command); break; @@ -675,6 +756,13 @@ const cmd = truncate(normalize(msg.command || '')); return labelHtml + `[bash]: ${escapeHtml(cmd)}`; } + if (msg.role === 'system') { + if (isLeadingSystemEntry(entry)) { + return labelHtml + `[system prompt]`; + } + const changes = describeSystemUpdate(msg); + return labelHtml + `[system update${changes ? ': ' + escapeHtml(truncate(changes)) : ''}]`; + } return labelHtml + `[${escapeHtml(msg.role)}]`; } case 'compaction': @@ -1311,6 +1399,24 @@ } if (msg.role === 'toolResult') return ''; + + if (msg.role === 'system') { + let html = ''; + if (isLeadingSystemEntry(entry)) { + const text = getSystemMessageText(msg); + if (text) html += renderSystemPromptBlock('System Prompt', text); + html += renderToolsList('Available Tools', msg.toolsAdded); + } else { + const text = renderSystemMessageUpdate(msg); + const changes = describeSystemUpdate(msg); + if (text) html += renderSystemPromptBlock(`System Prompt Update${changes ? ': ' + changes : ''}`, text); + html += renderToolsList('Tools Added', msg.toolsAdded); + if (msg.toolsRemoved && msg.toolsRemoved.length > 0) { + html += `
Tools Removed
${msg.toolsRemoved.map(t => `
${escapeHtml(t.name)}
`).join('')}
`; + } + } + return html ? `
${tsHtml}${html}
` : ''; + } } if (entry.type === 'model_change') { @@ -1351,7 +1457,7 @@ let userMessages = 0, assistantMessages = 0, toolResults = 0; let customMessages = 0, compactions = 0, branchSummaries = 0, toolCalls = 0; const tokens = { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }; - const cost = { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }; + const cost = { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 }; const models = new Set(); for (const entry of entryList) { @@ -1367,10 +1473,18 @@ tokens.cacheRead += msg.usage.cacheRead || 0; tokens.cacheWrite += msg.usage.cacheWrite || 0; if (msg.usage.cost) { + const componentTotal = + (msg.usage.cost.input || 0) + + (msg.usage.cost.output || 0) + + (msg.usage.cost.cacheRead || 0) + + (msg.usage.cost.cacheWrite || 0); cost.input += msg.usage.cost.input || 0; cost.output += msg.usage.cost.output || 0; cost.cacheRead += msg.usage.cost.cacheRead || 0; cost.cacheWrite += msg.usage.cost.cacheWrite || 0; + // Provider-reported totals can intentionally differ from catalog-estimated components. + // Fall back to the component sum for sessions written before cost.total was recorded. + cost.total += typeof msg.usage.cost.total === 'number' ? msg.usage.cost.total : componentTotal; } } toolCalls += msg.content.filter(c => c.type === 'toolCall').length; @@ -1391,7 +1505,7 @@ const globalStats = computeStats(entries); function renderHeader() { - const totalCost = globalStats.cost.input + globalStats.cost.output + globalStats.cost.cacheRead + globalStats.cost.cacheWrite; + const totalCost = globalStats.cost.total; const tokenParts = []; if (globalStats.tokens.input) tokenParts.push(`↑${formatTokens(globalStats.tokens.input)}`); @@ -1429,30 +1543,39 @@ `; - // Render system prompt (user's base prompt, applies to all providers) - if (systemPrompt) { - const lines = systemPrompt.split('\n'); - const previewLines = 10; - if (lines.length > previewLines) { - const preview = lines.slice(0, previewLines).join('\n'); - const remaining = lines.length - previewLines; - html += ``; - } else { - html += `
-
System Prompt
-
${escapeHtml(systemPrompt)}
-
`; - } + // Sessions with system entries render the prompt and tools in the transcript, where they + // occur. Older sessions have none, so fall back to the exporting agent's current state. + if (!hasSystemEntries) { + if (systemPrompt) html += renderSystemPromptBlock('System Prompt', systemPrompt); + html += renderToolsList('Available Tools', tools); } - if (tools && tools.length > 0) { - html += `
-
Available Tools
+ return html; + } + + function renderSystemPromptBlock(title, text) { + const lines = text.split('\n'); + const previewLines = 10; + if (lines.length > previewLines) { + const preview = lines.slice(0, previewLines).join('\n'); + const remaining = lines.length - previewLines; + return ``; + } + return `
+
${escapeHtml(title)}
+
${escapeHtml(text)}
+
`; + } + + function renderToolsList(title, tools) { + if (!tools || tools.length === 0) return ''; + return `
+
${escapeHtml(title)}
${tools.map(t => { const hasParams = t.parameters && typeof t.parameters === 'object' && t.parameters.properties && Object.keys(t.parameters.properties).length > 0; @@ -1477,9 +1600,6 @@ }).join('')}
`; - } - - return html; } // ============================================================ diff --git a/packages/coding-agent/src/core/extensions/types.ts b/packages/coding-agent/src/core/extensions/types.ts index 60388c0eb..b29e2d4b0 100644 --- a/packages/coding-agent/src/core/extensions/types.ts +++ b/packages/coding-agent/src/core/extensions/types.ts @@ -1542,6 +1542,10 @@ export interface MarkdownTransformContext { messageType: "user" | "assistant" | "assistant-thinking"; isStreaming: boolean; availableWidth: number; + /** Persisted session-entry id. Undefined for live messages or live rendering; available when rendering from persisted session entries. */ + messageId?: string; + /** Persisted session-entry ISO timestamp. Undefined for live messages or live rendering; available when rendering from persisted session entries. */ + timestamp?: string; } export type MarkdownTransformer = (markdown: string, context: MarkdownTransformContext) => string; diff --git a/packages/coding-agent/src/core/provider-composer.ts b/packages/coding-agent/src/core/provider-composer.ts index 94bd2ba3e..f7f5c4c59 100644 --- a/packages/coding-agent/src/core/provider-composer.ts +++ b/packages/coding-agent/src/core/provider-composer.ts @@ -7,6 +7,7 @@ import { type AuthInteraction, type AuthResult, type ClassifierApi, + type ClassifierModel, type Credential, type ImageApi, isModelType, @@ -83,6 +84,7 @@ export interface ProviderClassifierModelConfig extends ProviderModelConfigBase { type: "classifier"; api?: ClassifierApi; contextWindow: number; + compat?: ClassifierModel["compat"]; } export type ProviderModelConfig = ProviderChatModelConfig | ProviderImageModelConfig | ProviderClassifierModelConfig; @@ -324,9 +326,12 @@ function applyModelsJson( const models: AnyModel[] = baseModels.map((model) => { const baseUrl = config.oauth === "radius" ? model.baseUrl : (config.baseUrl ?? model.baseUrl); - return isModelType(model, "chat") - ? { ...model, baseUrl, compat: mergeCompat(model.compat, config.compat) } - : { ...model, baseUrl }; + if (isModelType(model, "chat")) return { ...model, baseUrl, compat: mergeCompat(model.compat, config.compat) }; + // Classifier APIs read their own compat fields, such as how a System One service takes images. + if (isModelType(model, "classifier") && config.compat) { + return { ...model, baseUrl, compat: { ...model.compat, ...config.compat } }; + } + return { ...model, baseUrl }; }); for (const definition of config.models ?? []) { const existingIndex = models.findIndex((model) => isModelType(model, "chat") && model.id === definition.id); diff --git a/packages/coding-agent/src/core/tools/grep.ts b/packages/coding-agent/src/core/tools/grep.ts index 44f7e212b..a9562c69f 100644 --- a/packages/coding-agent/src/core/tools/grep.ts +++ b/packages/coding-agent/src/core/tools/grep.ts @@ -159,7 +159,9 @@ export function createGrepToolDefinition( return lines; }; - const args: string[] = ["--json", "--line-number", "--color=never", "--hidden"]; + // --no-config: the tool contract (literal, glob, limit, ...) must not depend on + // ambient user config like RIPGREP_CONFIG_PATH. Users can customize via bash or extensions. + const args: string[] = ["--no-config", "--json", "--line-number", "--color=never", "--hidden"]; if (ignoreCase) args.push("--ignore-case"); if (literal) args.push("--fixed-strings"); if (glob) args.push("--glob", glob); diff --git a/packages/coding-agent/src/core/usage-totals.ts b/packages/coding-agent/src/core/usage-totals.ts index 6bcafbd47..fdc406bcb 100644 --- a/packages/coding-agent/src/core/usage-totals.ts +++ b/packages/coding-agent/src/core/usage-totals.ts @@ -47,6 +47,8 @@ export function combineUsage(first: Usage, second: Usage): Usage { cacheRead: first.cost.cacheRead + second.cost.cacheRead, cacheWrite: first.cost.cacheWrite + second.cost.cacheWrite, total: first.cost.total + second.cost.total, + // A sum is provider-reported only when every part is. + totalFromProvider: first.cost.totalFromProvider === true && second.cost.totalFromProvider === true, }, }; } diff --git a/packages/coding-agent/src/modes/interactive/components/assistant-message.ts b/packages/coding-agent/src/modes/interactive/components/assistant-message.ts index c02e37165..c710c7df7 100644 --- a/packages/coding-agent/src/modes/interactive/components/assistant-message.ts +++ b/packages/coding-agent/src/modes/interactive/components/assistant-message.ts @@ -21,6 +21,8 @@ export class AssistantMessageComponent extends Container { private lastMessage?: AssistantMessage; private hasToolCalls = false; private isStreaming = false; + private messageId?: string; + private timestamp?: string; private thinkingVisibilityOverrides = new Map(); constructor( @@ -30,6 +32,8 @@ export class AssistantMessageComponent extends Container { hiddenThinkingLabel = "Thinking...", outputPad = 1, markdownTransformers: readonly MarkdownTransformer[] = [], + messageId?: string, + timestamp?: string, ) { super(); @@ -38,6 +42,8 @@ export class AssistantMessageComponent extends Container { this.hiddenThinkingLabel = hiddenThinkingLabel; this.outputPad = outputPad; this.markdownTransformers = markdownTransformers; + this.messageId = messageId; + this.timestamp = timestamp; // Container for text/thinking content this.contentContainer = new Container(); @@ -112,7 +118,13 @@ export class AssistantMessageComponent extends Container { // Set paddingY=0 to avoid extra spacing before tool executions this.contentContainer.addChild( new Markdown(content.text.trim(), this.outputPad, 0, this.markdownTheme, undefined, { - transform: createMarkdownTransform("assistant", this.isStreaming, this.markdownTransformers), + transform: createMarkdownTransform( + "assistant", + this.isStreaming, + this.markdownTransformers, + this.messageId, + this.timestamp, + ), }), ); } else if (content.type === "thinking") { @@ -157,6 +169,8 @@ export class AssistantMessageComponent extends Container { "assistant-thinking", this.isStreaming, this.markdownTransformers, + this.messageId, + this.timestamp, ), }, ); diff --git a/packages/coding-agent/src/modes/interactive/components/login-dialog.ts b/packages/coding-agent/src/modes/interactive/components/login-dialog.ts index 20ab7de48..0803ff865 100644 --- a/packages/coding-agent/src/modes/interactive/components/login-dialog.ts +++ b/packages/coding-agent/src/modes/interactive/components/login-dialog.ts @@ -1,5 +1,15 @@ import type { AuthInfoLink, OAuthDeviceCodeInfo } from "@earendil-works/pi-ai"; -import { Container, type Focusable, getKeybindings, Input, Spacer, Text, type TUI } from "@earendil-works/pi-tui"; +import { + Container, + type Focusable, + getKeybindings, + Input, + Spacer, + Text, + TruncatedText, + type TUI, +} from "@earendil-works/pi-tui"; +import { copyToClipboard } from "../../../utils/clipboard.ts"; import { openBrowser } from "../../../utils/open-browser.ts"; import { theme } from "../theme/theme.ts"; import { AuthUrlComponent } from "./auth-url.ts"; @@ -18,6 +28,8 @@ export class LoginDialogComponent extends Container implements Focusable { private abortController = new AbortController(); private inputResolver?: (value: string) => void; private inputRejecter?: (error: Error) => void; + private deviceCodeAction?: { info: OAuthDeviceCodeInfo; hint: Container }; + private deviceCodeHint?: Container; private onComplete: (success: boolean, message?: string) => void; // Focusable implementation - propagate to input for IME cursor positioning @@ -83,7 +95,16 @@ export class LoginDialogComponent extends Container implements Focusable { ); } + private clearDeviceCodeAction(): void { + if (this.deviceCodeHint) { + this.contentContainer.removeChild(this.deviceCodeHint); + } + this.deviceCodeHint = undefined; + this.deviceCodeAction = undefined; + } + private cancel(): void { + this.clearDeviceCodeAction(); this.abortController.abort(); if (this.inputRejecter) { this.inputRejecter(new Error("Login cancelled")); @@ -97,6 +118,7 @@ export class LoginDialogComponent extends Container implements Focusable { * Called by onAuth callback - show URL and optional instructions */ showAuth(url: string, instructions?: string): void { + this.clearDeviceCodeAction(); this.contentContainer.clear(); this.contentContainer.addChild(new Spacer(1)); this.authUrl = new AuthUrlComponent(this.tui, url); @@ -116,6 +138,12 @@ export class LoginDialogComponent extends Container implements Focusable { */ showDeviceCode(info: OAuthDeviceCodeInfo): void { this.authUrl = undefined; + info = { + ...info, + openBrowserOnConfirm: info.openBrowserOnConfirm ?? true, + copyCodeOnConfirm: info.copyCodeOnConfirm ?? true, + }; + this.clearDeviceCodeAction(); this.contentContainer.clear(); this.contentContainer.addChild(new Spacer(1)); const linkedUrl = `\x1b]8;;${info.verificationUri}\x07${info.verificationUri}\x1b]8;;\x07`; @@ -127,13 +155,52 @@ export class LoginDialogComponent extends Container implements Focusable { this.contentContainer.addChild(new Spacer(1)); this.contentContainer.addChild(new Text(theme.fg("warning", `Enter code: ${info.userCode}`), 1, 0)); + if (!this.signal.aborted && (info.openBrowserOnConfirm || info.copyCodeOnConfirm)) { + const actionLabel = info.openBrowserOnConfirm + ? info.copyCodeOnConfirm + ? "to open browser and copy code" + : "to open browser" + : "to copy code"; + // Reuse this single row for progress/result text, including in narrow terminals. + const hint = new Container(); + hint.addChild(new TruncatedText(`(${keyHint("tui.select.confirm", actionLabel)})`, 1, 0)); + this.contentContainer.addChild(hint); + this.deviceCodeHint = hint; + this.deviceCodeAction = { info, hint }; + } this.tui.requestRender(); } + private setDeviceCodeStatus(hint: Container, message: string): void { + if (this.signal.aborted || !this.focused || !this.contentContainer.children.includes(hint)) return; + hint.children = [new TruncatedText(theme.fg("dim", message), 1, 0)]; + this.tui.requestRender(); + } + + private activateDeviceCode(info: OAuthDeviceCodeInfo, hint: Container): void { + this.setDeviceCodeStatus(hint, info.copyCodeOnConfirm ? "Copying code..." : "Browser open requested"); + if (info.copyCodeOnConfirm) { + // Do not delay polling or browser opening while clipboard tools are running. + void copyToClipboard(info.userCode) + .then(() => this.setDeviceCodeStatus(hint, "Code copied to clipboard")) + .catch(() => this.setDeviceCodeStatus(hint, "Copy code manually")); + } + if (info.openBrowserOnConfirm) { + try { + // The shared launcher also opens files; device-code login must only open web URLs. + const url = new URL(info.verificationUri); + if (url.protocol === "https:" || url.protocol === "http:") openBrowser(url.href); + } catch { + // Invalid URLs or launcher failures must not interrupt login. + } + } + } + /** * Show input for manual code/URL entry (for callback server providers) */ showManualInput(prompt: string): Promise { + this.clearDeviceCodeAction(); this.input.setValue(""); this.contentContainer.addChild(new Spacer(1)); this.contentContainer.addChild(new Text(theme.fg("dim", prompt), 1, 0)); @@ -152,6 +219,7 @@ export class LoginDialogComponent extends Container implements Focusable { * Note: Does NOT clear content, appends to existing (preserves URL from showAuth) */ showPrompt(message: string, placeholder?: string): Promise { + this.clearDeviceCodeAction(); this.contentContainer.addChild(new Spacer(1)); this.contentContainer.addChild(new Text(theme.fg("text", message), 1, 0)); if (placeholder) { @@ -178,6 +246,7 @@ export class LoginDialogComponent extends Container implements Focusable { /** Show informational text before another login step. */ showDetails(lines: string[]): void { this.authUrl = undefined; + this.clearDeviceCodeAction(); this.contentContainer.clear(); this.contentContainer.addChild(new Spacer(1)); for (const line of lines) { @@ -232,6 +301,21 @@ export class LoginDialogComponent extends Container implements Focusable { return; } + if ( + this.deviceCodeAction && + !this.inputResolver && + !this.signal.aborted && + this.focused && + kb.matches(data, "tui.select.confirm") + ) { + const action = this.deviceCodeAction; + // Consume once so a repeated Enter cannot launch multiple browsers or clipboard writes. + this.deviceCodeAction = undefined; + this.activateDeviceCode(action.info, action.hint); + this.tui.requestRender(); + return; + } + // Pass to input this.input.handleInput(data); } diff --git a/packages/coding-agent/src/modes/interactive/components/markdown-transform.ts b/packages/coding-agent/src/modes/interactive/components/markdown-transform.ts index 417bac5e9..4ff7685c2 100644 --- a/packages/coding-agent/src/modes/interactive/components/markdown-transform.ts +++ b/packages/coding-agent/src/modes/interactive/components/markdown-transform.ts @@ -4,9 +4,15 @@ export function createMarkdownTransform( messageType: MarkdownTransformContext["messageType"], isStreaming: boolean, transformers: readonly MarkdownTransformer[], + messageId?: string, + timestamp?: string, ): (markdown: string, availableWidth: number) => string { return (markdown, availableWidth) => - applyMarkdownTransformers(markdown, { messageType, isStreaming, availableWidth }, transformers); + applyMarkdownTransformers( + markdown, + { messageType, isStreaming, availableWidth, messageId, timestamp }, + transformers, + ); } function applyMarkdownTransformers( diff --git a/packages/coding-agent/src/modes/interactive/components/session-selector-search.ts b/packages/coding-agent/src/modes/interactive/components/session-selector-search.ts index 9b5bf2327..5b3317faa 100644 --- a/packages/coding-agent/src/modes/interactive/components/session-selector-search.ts +++ b/packages/coding-agent/src/modes/interactive/components/session-selector-search.ts @@ -23,8 +23,83 @@ function normalizeWhitespaceLower(text: string): string { return text.toLowerCase().replace(/\s+/g, " ").trim(); } -function getSessionSearchText(session: SessionInfo): string { - return `${session.id} ${session.name ?? ""} ${session.allMessagesText} ${session.cwd}`; +/** + * Match tiers, best first. Each token is scored by the best field it matches; tiers dominate + * fuzzy scores so an exact hit anywhere beats a scattered subsequence in a short field. + */ +const TIER_NAME = 0; // query text appears in the session name +const TIER_TEXT = 1; // query text appears in the session id, messages, or cwd +const TIER_FUZZY = 2; // query characters appear scattered in the session name or cwd +const TIER_WEIGHT = 1_000_000; + +interface LowercaseCache { + source: string; + lower: string; + normalized?: string; +} + +/** + * Lowercased message text per session. allMessagesText holds every user/assistant message and + * can be megabytes, so lowercase it once instead of on every keystroke. + */ +const messageTextCache = new WeakMap(); + +function getMessageText(session: SessionInfo): LowercaseCache { + let cached = messageTextCache.get(session); + if (!cached || cached.source !== session.allMessagesText) { + cached = { source: session.allMessagesText, lower: session.allMessagesText.toLowerCase() }; + messageTextCache.set(session, cached); + } + return cached; +} + +function getNormalizedMessageText(session: SessionInfo): string { + const cached = getMessageText(session); + cached.normalized ??= cached.lower.replace(/\s+/g, " ").trim(); + return cached.normalized; +} + +function tierScore(tier: number, score = 0): number { + return tier * TIER_WEIGHT + score; +} + +/** Exact (case-insensitive) text match. Messages and ids are only matched exactly: scattered matches in them are noise. */ +function matchLiteralToken(session: SessionInfo, value: string): number | undefined { + const name = session.name?.toLowerCase() ?? ""; + if (name.includes(value)) return tierScore(TIER_NAME, fuzzyMatch(value, name).score); + if (session.id.toLowerCase().includes(value)) return tierScore(TIER_TEXT); + if (getMessageText(session).lower.includes(value)) return tierScore(TIER_TEXT); + if (session.cwd.toLowerCase().includes(value)) return tierScore(TIER_TEXT); + return undefined; +} + +function matchFuzzyToken(session: SessionInfo, value: string): number | undefined { + const literal = matchLiteralToken(session, value.toLowerCase()); + if (literal !== undefined) return literal; + + let best: number | undefined; + for (const field of [session.name ?? "", session.cwd]) { + const m = fuzzyMatch(value, field); + if (m.matches && (best === undefined || m.score < best)) best = m.score; + } + return best === undefined ? undefined : tierScore(TIER_FUZZY, best); +} + +function matchPhraseToken(session: SessionInfo, phrase: string): number | undefined { + const name = normalizeWhitespaceLower(session.name ?? ""); + if (name.includes(phrase)) return tierScore(TIER_NAME, fuzzyMatch(phrase, name).score); + if (session.id.toLowerCase().includes(phrase)) return tierScore(TIER_TEXT); + if (getNormalizedMessageText(session).includes(phrase)) return tierScore(TIER_TEXT); + if (normalizeWhitespaceLower(session.cwd).includes(phrase)) return tierScore(TIER_TEXT); + return undefined; +} + +function matchRegex(session: SessionInfo, regex: RegExp): number | undefined { + if (session.name && regex.test(session.name)) return tierScore(TIER_NAME); + if (regex.test(session.id) || regex.test(session.allMessagesText) || regex.test(session.cwd)) { + return tierScore(TIER_TEXT); + } + return undefined; } export function hasSessionName(session: SessionInfo): boolean { @@ -113,41 +188,32 @@ export function parseSearchQuery(query: string): ParsedSearchQuery { return { mode: "tokens", tokens, regex: null }; } +/** + * Each field is matched separately, so a token never matches across field boundaries. + * Different tokens may match different fields. + */ export function matchSession(session: SessionInfo, parsed: ParsedSearchQuery): MatchResult { - const text = getSessionSearchText(session); - if (parsed.mode === "regex") { if (!parsed.regex) { return { matches: false, score: 0 }; } - const idx = text.search(parsed.regex); - if (idx < 0) return { matches: false, score: 0 }; - return { matches: true, score: idx * 0.1 }; - } - - if (parsed.tokens.length === 0) { - return { matches: true, score: 0 }; + const score = matchRegex(session, parsed.regex); + return score === undefined ? { matches: false, score: 0 } : { matches: true, score }; } let totalScore = 0; - let normalizedText: string | null = null; for (const token of parsed.tokens) { + let score: number | undefined; if (token.kind === "phrase") { - if (normalizedText === null) { - normalizedText = normalizeWhitespaceLower(text); - } const phrase = normalizeWhitespaceLower(token.value); if (!phrase) continue; - const idx = normalizedText.indexOf(phrase); - if (idx < 0) return { matches: false, score: 0 }; - totalScore += idx * 0.1; - continue; + score = matchPhraseToken(session, phrase); + } else { + score = matchFuzzyToken(session, token.value); } - - const m = fuzzyMatch(token.value, text); - if (!m.matches) return { matches: false, score: 0 }; - totalScore += m.score; + if (score === undefined) return { matches: false, score: 0 }; + totalScore += score; } return { matches: true, score: totalScore }; diff --git a/packages/coding-agent/src/modes/interactive/components/user-message.ts b/packages/coding-agent/src/modes/interactive/components/user-message.ts index c187eff43..d8ca373cc 100644 --- a/packages/coding-agent/src/modes/interactive/components/user-message.ts +++ b/packages/coding-agent/src/modes/interactive/components/user-message.ts @@ -15,18 +15,24 @@ export class UserMessageComponent extends Container { private markdownTheme: MarkdownTheme; private outputPad: number; private markdownTransformers: readonly MarkdownTransformer[]; + private messageId?: string; + private timestamp?: string; constructor( text: string, markdownTheme: MarkdownTheme = getMarkdownTheme(), outputPad = 1, markdownTransformers: readonly MarkdownTransformer[] = [], + messageId?: string, + timestamp?: string, ) { super(); this.text = text; this.markdownTheme = markdownTheme; this.outputPad = outputPad; this.markdownTransformers = markdownTransformers; + this.messageId = messageId; + this.timestamp = timestamp; this.rebuild(); } @@ -52,7 +58,13 @@ export class UserMessageComponent extends Container { { preserveOrderedListMarkers: true, preserveBackslashEscapes: true, - transform: createMarkdownTransform("user", false, this.markdownTransformers), + transform: createMarkdownTransform( + "user", + false, + this.markdownTransformers, + this.messageId, + this.timestamp, + ), }, ), ); diff --git a/packages/coding-agent/src/modes/interactive/interactive-mode.ts b/packages/coding-agent/src/modes/interactive/interactive-mode.ts index e24d150be..a596eea88 100644 --- a/packages/coding-agent/src/modes/interactive/interactive-mode.ts +++ b/packages/coding-agent/src/modes/interactive/interactive-mode.ts @@ -3834,7 +3834,10 @@ export class InteractiveMode { this.chatContainer.addChild(component); } - private addMessageToChat(message: AgentMessage, options?: { populateHistory?: boolean }): void { + private addMessageToChat( + message: AgentMessage, + options?: { populateHistory?: boolean; messageId?: string; timestamp?: string }, + ): void { switch (message.role) { case "bashExecution": { const component = new BashExecutionComponent( @@ -3917,6 +3920,8 @@ export class InteractiveMode { this.getMarkdownThemeWithSettings(), this.outputPad, this.getMarkdownTransformers(), + options?.messageId, + options?.timestamp, ); this.chatContainer.addChild(userComponent); } @@ -3926,6 +3931,8 @@ export class InteractiveMode { this.getMarkdownThemeWithSettings(), this.outputPad, this.getMarkdownTransformers(), + options?.messageId, + options?.timestamp, ); this.chatContainer.addChild(userComponent); } @@ -3943,6 +3950,8 @@ export class InteractiveMode { this.hiddenThinkingLabel, this.outputPad, this.getMarkdownTransformers(), + options?.messageId, + options?.timestamp, ); this.chatContainer.addChild(assistantComponent); break; @@ -3959,6 +3968,7 @@ export class InteractiveMode { private renderSessionItems( items: readonly RenderSessionItem[], + messageMeta?: Map, options: { updateFooter?: boolean; populateHistory?: boolean } = {}, ): void { this.pendingTools.clear(); @@ -3991,7 +4001,11 @@ export class InteractiveMode { const message = item; // Assistant messages need special handling for tool calls if (message.role === "assistant") { - this.addMessageToChat(message); + const meta = messageMeta?.get(message); + this.addMessageToChat(message, { + messageId: meta?.id, + timestamp: meta?.timestamp, + }); // Render tool call components for (const content of message.content) { if (content.type === "toolCall") { @@ -4041,7 +4055,12 @@ export class InteractiveMode { } } else { // All other messages use standard rendering - this.addMessageToChat(message, options); + const meta = messageMeta?.get(message); + this.addMessageToChat(message, { + ...options, + messageId: meta?.id, + timestamp: meta?.timestamp, + }); } } @@ -4063,17 +4082,21 @@ export class InteractiveMode { ): void { // Selection coordinates point into the transcript being replaced (#9311). if (this.renderer instanceof TuiAltScreen) this.renderer.resetTextSelection(); + const messageMeta = new Map(); const items = entries.flatMap((entry): RenderSessionItem[] => { if (entry.type === "custom" || (entry.type === "usage" && entry.kind === "cache_warm")) { return [entry]; } const messages = sessionEntryToContextMessages(entry); + for (const msg of messages) { + messageMeta.set(msg, { id: entry.id, timestamp: entry.timestamp }); + } if ((entry.type === "compaction" || entry.type === "branch_summary") && entry.usage && messages.length > 0) { return [...messages, { type: "compaction_cost", kind: entry.type, usage: entry.usage }]; } return messages; }); - this.renderSessionItems(items, options); + this.renderSessionItems(items, messageMeta, options); } private addCacheWarmingUsage(entry: UsageEntry): void { diff --git a/packages/coding-agent/src/package-manager-cli.ts b/packages/coding-agent/src/package-manager-cli.ts index 48d605620..2eda49536 100644 --- a/packages/coding-agent/src/package-manager-cli.ts +++ b/packages/coding-agent/src/package-manager-cli.ts @@ -88,10 +88,11 @@ async function fetchInstallerArtifact(url: string, label: string): Promise { + // No age-gate override: the release lockfile pins every package, so npm never + // resolves versions and a configured min-release-age does not apply. const args = [ "ci", "--ignore-scripts", - "--min-release-age=0", "--omit=dev", "--include=optional", "--no-fund", diff --git a/packages/coding-agent/src/utils/shell.ts b/packages/coding-agent/src/utils/shell.ts index fbe81f965..9289d27e4 100644 --- a/packages/coding-agent/src/utils/shell.ts +++ b/packages/coding-agent/src/utils/shell.ts @@ -236,6 +236,19 @@ export function killProcessTree(pid: number): void { // Ignore errors if taskkill fails. } } else { + if (process.platform === "darwin") { + // macOS signals group members individually after releasing the group lock. A killed + // child can wake its waiting shell before that shell is signalled, letting `a; b` + // start b during cancellation (#11252). Stop the group first to reduce this race; + // this is best-effort, not an atomic freeze. SIGKILL also kills stopped processes. + // Linux holds tasklist_lock across group signalling; child exit notification needs + // its write lock, so the shell is signalled before observing the killed child's exit. + try { + process.kill(-pid, "SIGSTOP"); + } catch { + // A failed stop must not prevent the kill attempt or its single-process fallback. + } + } // Use SIGKILL on Unix/Linux/Mac try { process.kill(-pid, "SIGKILL"); diff --git a/packages/coding-agent/test/compaction-summary-reasoning.test.ts b/packages/coding-agent/test/compaction-summary-reasoning.test.ts index cf3aca03a..a18c5ace2 100644 --- a/packages/coding-agent/test/compaction-summary-reasoning.test.ts +++ b/packages/coding-agent/test/compaction-summary-reasoning.test.ts @@ -314,7 +314,7 @@ describe("generateSummary reasoning options", () => { input: 20, output: 20, totalTokens: 40, - cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 }, + cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0, totalFromProvider: false }, }); }); }); diff --git a/packages/coding-agent/test/default-tools-setting.test.ts b/packages/coding-agent/test/default-tools-setting.test.ts index 3ab70eaa9..9ae2a709e 100644 --- a/packages/coding-agent/test/default-tools-setting.test.ts +++ b/packages/coding-agent/test/default-tools-setting.test.ts @@ -309,15 +309,31 @@ describe("defaultTools setting", () => { }); }; + const toolReferences = (names: string[]) => + names.map((name) => ({ name, description: name, parameters: Type.Object({}) })); + + /** Append a system message that adds and removes tools, as a run records loadout changes. */ + function recordLoadoutChange(sessionManager: SessionManager, added: string[], removed: string[] = []): string { + return sessionManager.appendMessage({ + role: "system", + content: "", + ...(added.length > 0 ? { toolsAdded: toolReferences(added) } : {}), + ...(removed.length > 0 ? { toolsRemoved: toolReferences(removed) } : {}), + timestamp: Date.now(), + }); + } + /** A session whose transcript records the loadout `toolNames`, as a run records it. */ function createRecordedSession(toolNames: string[]): SessionManager { const sessionManager = SessionManager.inMemory(tempDir); - sessionManager.appendMessage({ - role: "system", - content: "", - toolsAdded: toolNames.map((name) => ({ name, description: name, parameters: Type.Object({}) })), - timestamp: Date.now(), - }); + recordLoadoutChange(sessionManager, toolNames); + return sessionManager; + } + + /** A session that recorded `read`, `grep`, and `inactive_tool`, and deactivated `active_tool`. */ + function createChangedSession(): SessionManager { + const sessionManager = createRecordedSession(["read", "grep", "active_tool"]); + recordLoadoutChange(sessionManager, ["inactive_tool"], ["active_tool"]); return sessionManager; } @@ -344,14 +360,68 @@ describe("defaultTools setting", () => { } it("restores the loadout the transcript records", async () => { - // active_tool was deactivated during the session, inactive_tool activated. - const session = await resume(createRecordedSession(["read", "grep", "inactive_tool"])); + const session = await resume(createChangedSession()); expect(session.getActiveToolNames().sort()).toEqual(["grep", "inactive_tool", "read"]); session.dispose(); }); + // #11062 + it("activates extension tools the transcript never added or removed", async () => { + // Built-in tools and tools registered inactive stay inactive. + const session = await resume(createRecordedSession(["read", "grep"])); + expect(session.getActiveToolNames().sort()).toEqual(["active_tool", "grep", "read"]); + session.dispose(); + }); + + it("keeps tools removed before a compaction inactive", async () => { + const sessionManager = createChangedSession(); + const kept = sessionManager.appendMessage({ role: "user", content: "hi", timestamp: Date.now() }); + sessionManager.appendCompaction("summary", kept, 1000); + const session = await resume(sessionManager); + expect(session.getActiveToolNames().sort()).toEqual(["grep", "inactive_tool", "read"]); + session.dispose(); + }); + + // #11075 + it("restores the target branch's loadout on tree navigation", async () => { + // The target branch never mentions active_tool, so it is active there. It removed grep. + const sessionManager = createRecordedSession(["read", "grep"]); + recordLoadoutChange(sessionManager, [], ["grep"]); + const target = sessionManager.appendMessage({ role: "user", content: "first", timestamp: Date.now() }); + recordLoadoutChange(sessionManager, ["grep"], ["active_tool"]); + const session = await resume(sessionManager); + expect(session.getActiveToolNames().sort()).toEqual(["grep", "read"]); + + await session.navigateTree(target, { summarize: false }); + expect(session.getActiveToolNames().sort()).toEqual(["active_tool", "read"]); + session.dispose(); + }); + + it("keeps registered tools outside the tools allowlist inactive on tree navigation", async () => { + // An allowlist without mcp__ entries keeps MCP tools registered but inactive. + const sessionManager = createRecordedSession(["read"]); + const target = sessionManager.appendMessage({ role: "user", content: "first", timestamp: Date.now() }); + const session = await resume(sessionManager, { + tools: ["read"], + customTools: [ + { + name: "mcp__server__lookup", + label: "Lookup", + description: "MCP tool", + parameters: Type.Object({}), + execute: async () => ({ content: [{ type: "text", text: "ok" }], details: {} }), + }, + ], + }); + expect(session.getActiveToolNames()).toEqual(["read"]); + + await session.navigateTree(target, { summarize: false }); + expect(session.getActiveToolNames()).toEqual(["read"]); + session.dispose(); + }); + it("does not restore excluded tools", async () => { - const session = await resume(createRecordedSession(["read", "grep", "inactive_tool"]), { + const session = await resume(createChangedSession(), { excludeTools: ["inactive_tool"], }); expect(session.getActiveToolNames().sort()).toEqual(["grep", "read"]); @@ -359,19 +429,17 @@ describe("defaultTools setting", () => { }); it("uses explicit tool options instead of the recorded loadout", async () => { - const recorded = ["read", "grep", "inactive_tool"]; - - const allowlisted = await resume(createRecordedSession(recorded), { tools: ["read", "write"] }); + const allowlisted = await resume(createChangedSession(), { tools: ["read", "write"] }); expect(allowlisted.getActiveToolNames()).toEqual(["read", "write"]); allowlisted.dispose(); - const builtinless = await resume(createRecordedSession(recorded), { noTools: "builtin" }); + const builtinless = await resume(createChangedSession(), { noTools: "builtin" }); expect(builtinless.getActiveToolNames()).toEqual(["active_tool"]); builtinless.dispose(); }); it("applies +name and -name tool options to the recorded loadout", async () => { - const session = await resume(createRecordedSession(["read", "grep", "inactive_tool"]), { + const session = await resume(createChangedSession(), { tools: ["-grep", "+write"], }); expect(session.getActiveToolNames().sort()).toEqual(["inactive_tool", "read", "write"]); diff --git a/packages/coding-agent/test/exec.test.ts b/packages/coding-agent/test/exec.test.ts new file mode 100644 index 000000000..96232a8b3 --- /dev/null +++ b/packages/coding-agent/test/exec.test.ts @@ -0,0 +1,22 @@ +import { describe, expect, it } from "vitest"; +import { execCommand } from "../src/core/exec.ts"; + +describe("execCommand", () => { + // Regression test for https://github.com/earendil-works/pi/issues/11085 + it("reports spawn errors in stderr", async () => { + const result = await execCommand("pi-command-that-does-not-exist-11085", [], process.cwd()); + + expect(result).toMatchObject({ + code: 1, + killed: false, + stdout: "", + }); + expect(result.stderr).toMatch(/ENOENT/); + }); + + it("keeps stderr empty when a command exits silently", async () => { + const result = await execCommand(process.execPath, ["-e", "process.exit(1)"], process.cwd()); + + expect(result).toEqual({ stdout: "", stderr: "", code: 1, killed: false }); + }); +}); diff --git a/packages/coding-agent/test/export-html-cost.test.ts b/packages/coding-agent/test/export-html-cost.test.ts new file mode 100644 index 000000000..f7291ae06 --- /dev/null +++ b/packages/coding-agent/test/export-html-cost.test.ts @@ -0,0 +1,52 @@ +import { readFileSync } from "node:fs"; +import { runInNewContext } from "node:vm"; +import { describe, expect, it } from "vitest"; + +type ExportStats = { + cost: { + input: number; + output: number; + cacheRead: number; + cacheWrite: number; + total: number; + }; +}; + +describe("export HTML cost", () => { + it("uses recorded totals while falling back to component sums for old sessions", () => { + // Regression test for #9980: provider-reported totals may differ from catalog-estimated components. + const templateJs = readFileSync(new URL("../src/core/export-html/template.js", import.meta.url), "utf-8"); + const functionStart = templateJs.indexOf("function computeStats(entryList)"); + const functionEnd = templateJs.indexOf("const globalStats = computeStats(entries);"); + expect(functionStart).toBeGreaterThanOrEqual(0); + expect(functionEnd).toBeGreaterThan(functionStart); + + const context: { result?: ExportStats } = {}; + runInNewContext( + `${templateJs.slice(functionStart, functionEnd)} + result = computeStats(${JSON.stringify([ + { + type: "message", + message: { + role: "assistant", + content: [], + usage: { cost: { input: 0.1, output: 0.2, cacheRead: 0, cacheWrite: 0, total: 0.9 } }, + }, + }, + { + type: "message", + message: { + role: "assistant", + content: [], + usage: { cost: { input: 0.4, output: 0.1, cacheRead: 0, cacheWrite: 0 } }, + }, + }, + ])});`, + context, + ); + + expect(context.result?.cost.input).toBeCloseTo(0.5); + expect(context.result?.cost.output).toBeCloseTo(0.3); + expect(context.result?.cost.total).toBeCloseTo(1.4); + }); +}); diff --git a/packages/coding-agent/test/export-html-deep-tree.test.ts b/packages/coding-agent/test/export-html-deep-tree.test.ts new file mode 100644 index 000000000..59a8a9851 --- /dev/null +++ b/packages/coding-agent/test/export-html-deep-tree.test.ts @@ -0,0 +1,93 @@ +import { mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { createContext, runInContext } from "node:vm"; +import { afterAll, beforeAll, describe, expect, it } from "vitest"; +import { exportFromFile } from "../src/core/export-html/index.ts"; +import { CURRENT_SESSION_VERSION, type SessionHeader, type SessionMessageEntry } from "../src/core/session-manager.ts"; +import { initTheme } from "../src/modes/interactive/theme/theme.ts"; + +interface TreeNode { + entry: { id: string }; + children: TreeNode[]; +} + +interface TreeApi { + buildTree: () => TreeNode[]; + buildActivePathIds: (targetId: string) => Set; + flattenTree: (roots: TreeNode[], activePathIds: Set) => Array<{ node: TreeNode }>; + findNewestLeaf: (nodeId: string) => string; + getPath: (targetId: string) => Array<{ id: string }>; +} + +function loadTreeApi(html: string): TreeApi { + const sessionData = html.match(/